Repository navigation
Description
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:
- check
resourceCachefor an existing resource - call
ResourceProvider#provide(...)if missing - 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:
JavaActionTaskexecutes Java actions throughContinuationActionExecutor.JavaRunnerContextImpl#durableExecuteAsync(...)delegates toContinuationActionExecutor#executeAsync(...)when a continuation context is present.- The JDK 21
ContinuationActionExecutorimplementation submits the callable to a fixed async thread pool. - If the callable resolves a resource through
RunnerContext#getResource(...),RunnerContextImpl#getResource(...)delegates toagentPlan.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 ofAgentPlanintoResourceCacheand introducedpublic 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.2at commit1df9a1e503b9c062bbe1267d2d3c277ed676c686 - 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
ResourceProvideras shown above.
Are you willing to submit a PR?
- I'm willing to submit a PR!
Activity
weiqingy commentedon Jun 2, 2026
@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.
joeyutong commentedon Jun 2, 2026
@weiqingy Thanks for confirming.
I have not started working on the upstream fix yet, so please feel free to put up the
release-0.2backport PR.The Java-side fix sounds good to me. Making
AgentPlan#getResourcesynchronized looks like the minimal fix forrelease-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.2has a similar unsynchronized cache in PythonAgentPlan.get_resource(). On main this seems to be avoided by the newer resource lifecycle: resources are opened throughResourceCache, and chat model setup resolves its sub-resources duringopen()instead of lazily insidechat(). Do you think therelease-0.2PR should include a minimal Python-side guard as well, or keep the scope to Java only?weiqingy commentedon Jun 2, 2026
@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, andAgentPlan.get_resourcedoes the same non-atomic check-create-cache over a plain dict; the GIL doesn't make it atomic becauseprovider.provide()can release the GIL when it calls into Java via Pemja. So I wrappedget_resourcein a per-instance reentrant lock, mirroring the Javasynchronized. (It's acached_propertyrather 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.
xintongsong commentedon Jun 3, 2026
Won't fix for release-0.1