Skip to content

[Bug] AgentPlan resource getter is not thread-safe on release-0.2 #724

Closed

Description

@joeyutong

Search before asking

  • I searched in the issues and found nothing similar.

Description

On the release-0.2 branch, resource resolution is cached in AgentPlan, but AgentPlan#getResource(String, ResourceType) is not thread-safe.

The method currently performs a multi-step check/create/cache sequence without a shared lock:

  1. check resourceCache for an existing resource
  2. call ResourceProvider#provide(...) if missing
  3. put the new resource into resourceCache

Because the check and put are not atomic, two concurrent first accesses to the same resource can both pass the cache check, both create a resource instance, and then race when storing the result. That violates the expected cached-resource semantics. AgentPlanTest#testGetResourceFromResourceProvider already asserts that repeated getResource(...) calls should return the same resource instance, but the current implementation only guarantees that for sequential calls.

This can be reached from the public runtime path on JDK 21:

  • JavaActionTask executes Java actions through ContinuationActionExecutor.
  • JavaRunnerContextImpl#durableExecuteAsync(...) delegates to ContinuationActionExecutor#executeAsync(...) when a continuation context is present.
  • The JDK 21 ContinuationActionExecutor implementation submits the callable to a fixed async thread pool.
  • If the callable resolves a resource through RunnerContext#getResource(...), RunnerContextImpl#getResource(...) delegates to agentPlan.getResource(...) on that async thread.

Potential symptoms include duplicate model/tool/connection initialization and duplicate registration of resource-side state such as metrics when two instances of the same logical resource are created concurrently.

The issue appears to be incidentally covered on main by later refactoring commits rather than by a dedicated bug-fix commit:

  • f8eac10 ([plan] Extract ResourceCache and PythonResourceBridge from AgentPlan (#548)) moved runtime resource caching out of AgentPlan into ResourceCache and introduced public synchronized Resource getResource(...).
  • 487bd58 ([runtime] Own ResourceContext inside ResourceCache for shared lifecycle.) later adjusted the same cache/lifecycle area while keeping the synchronized resource resolution path.

How to reproduce

A small regression test on release-0.2 can reproduce the race by resolving the same uncached resource from two threads. The provider sleep makes both callers enter AgentPlan#getResource(...) before either one stores the cache entry.

@Test
void getResourceShouldCreateOnlyOneInstanceUnderConcurrentAccess() throws Exception {
    AtomicInteger created = new AtomicInteger();

    ResourceProvider provider =
            new ResourceProvider("shared-tool", ResourceType.TOOL) {
                @Override
                public Resource provide(BiFunction<String, ResourceType, Resource> getResource)
                        throws Exception {
                    Thread.sleep(200);
                    created.incrementAndGet();
                    return new Resource() {
                        @Override
                        public ResourceType getResourceType() {
                            return ResourceType.TOOL;
                        }
                    };
                }
            };

    Map<ResourceType, Map<String, ResourceProvider>> providers = new HashMap<>();
    providers.put(ResourceType.TOOL, Map.of("shared-tool", provider));
    AgentPlan plan = new AgentPlan(new HashMap<>(), new HashMap<>(), providers);

    CyclicBarrier start = new CyclicBarrier(2);
    ExecutorService pool = Executors.newFixedThreadPool(2);
    try {
        Callable<Resource> task =
                () -> {
                    start.await();
                    return plan.getResource("shared-tool", ResourceType.TOOL);
                };

        Future<Resource> first = pool.submit(task);
        Future<Resource> second = pool.submit(task);

        Resource firstResource = first.get(10, TimeUnit.SECONDS);
        Resource secondResource = second.get(10, TimeUnit.SECONDS);

        assertThat(firstResource).isSameAs(secondResource);
        assertThat(created.get()).isEqualTo(1);
    } finally {
        pool.shutdownNow();
    }
}

Expected behavior: the provider is invoked once and both callers receive the same cached resource.

Actual behavior on release-0.2: both callers can invoke the provider and receive different resource instances before the last writer wins in the cache.

Version and environment

  • Apache Flink Agents branch: release-0.2
  • Verified against release-0.2 at commit 1df9a1e503b9c062bbe1267d2d3c277ed676c686
  • Relevant runtime path: Java action execution with JDK 21 async continuation support
  • No external model service is required to reproduce the core cache race; the issue can be reproduced with an in-memory ResourceProvider as shown above.

Are you willing to submit a PR?

  • I'm willing to submit a PR!

Activity

  1. weiqingy commented on Jun 2, 2026

    @weiqingy
    Collaborator

    @joeyutong Thanks for the detailed write-up and repro.
    Confirming the connection you flagged: this was addressed on main as a side effect of #548 (f8eac10), which moved resource caching out of AgentPlan into a dedicated ResourceCache whose getResource is synchronized. That refactor never landed on release-0.2, so the race is still live there — it wasn't backported.

    Backporting the full ResourceCache extraction to a maintenance branch is heavier than warranted. The minimal equivalent for release-0.2 is to make AgentPlan#getResource itself synchronized — the same mechanism main relies on. The intrinsic lock is reentrant, so the nested resource-resolution callback still works. I'd pair it with the regression test from your repro.

    Are you already working on a fix? If not, happy to put up the backport PR against release-0.2.

  2. joeyutong commented on Jun 2, 2026

    @joeyutong
    CollaboratorAuthor

    @weiqingy Thanks for confirming.

    I have not started working on the upstream fix yet, so please feel free to put up the release-0.2 backport PR.

    The Java-side fix sounds good to me. Making AgentPlan#getResource synchronized looks like the minimal fix for release-0.2, and the regression test from the repro should cover it.

    One related question: should we also cover the Python resource path in this backport? release-0.2 has a similar unsynchronized cache in Python AgentPlan.get_resource(). On main this seems to be avoided by the newer resource lifecycle: resources are opened through ResourceCache, and chat model setup resolves its sub-resources during open() instead of lazily inside chat(). Do you think the release-0.2 PR should include a minimal Python-side guard as well, or keep the scope to Java only?

  3. weiqingy commented on Jun 2, 2026

    @weiqingy
    Collaborator

    @joeyutong Thanks — backport PR is up: #727.

    On the Python question: yes, I included a minimal Python-side guard in the same PR. The race is real there too on release-0.2. The async action thread pool runs actions on multiple threads, and AgentPlan.get_resource does the same non-atomic check-create-cache over a plain dict; the GIL doesn't make it atomic because provider.provide() can release the GIL when it calls into Java via Pemja. So I wrapped get_resource in a per-instance reentrant lock, mirroring the Java synchronized. (It's a cached_property rather than a Pydantic private attribute so it stays out of model equality.)

    Both sides ship with the regression test from your repro — a two-thread race on the first access to a shared resource — and each test fails without its guard and passes with it.

  4. xintongsong commented on Jun 3, 2026

    @xintongsong
    Contributor

    Won't fix for release-0.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions