Restore the AgentProcess a thread already held, rather than clearing it - #1912
Restore the AgentProcess a thread already held, rather than clearing it#1912jasperblues wants to merge 4 commits into
Conversation
igordayen
left a comment
There was a problem hiding this comment.
@jasperblues - apparently serious issue. would be very helpful to have hand-written (not test) scenatio as real case. please consider adding to the last test AND into the issue.
Executor - Thread1 - Agent Process.....
And what is behavopr with virtual threads?
Thanks
| @@ -1,3 +1,18 @@ | |||
| /* | |||
| // Restores rather than clears: nothing leaks between tasks on a pooled thread, and | ||
| // nothing is wiped when the executor runs the task on the submitting thread, which | ||
| // is already inside a process. | ||
| AgentProcessAccessor.with(agentProcess) { |
There was a problem hiding this comment.
Serious issue, requires rigorous testing. Let's add to post 1.5.0 release, so it can be tested more thoroughly.
|
Two review questions answered: a step-by-step scenario, and virtual threads. The second one narrows the scope of this PR, so I've corrected the description above. Does this affect virtual threads? No — and not the platform default either
Neither can run a task on the thread that submitted it, so neither was ever affected. The clearing this PR replaces was harmless on both. Two tests now pin that ( So reaching the defect requires two things together: It is still worth fixing: sharing the app's executor is a supported, documented option, and a propagation primitive that quietly empties a thread local is the kind of thing that costs somebody a day. The scenario, step by stepSetup:
The damage is done at Time 7 and shows up at Time 9, on a different code path, with nothing logged in between. And because Time 4 only happens when the pool is saturated, the same request succeeds whenever a worker happens to be free — so it is intermittent and load-dependent. With the fix, Time 7 restores P instead of clearing it, and Time 9 sees P. What is unchanged
|
f45c23c to
36026b0
Compare
|
@igordayen — right, (That file does still lack its header on Also: the scenario and the virtual-threads answer are in the comment above, and the timeline is now in the issue as well, as you asked. |
|
@alexheifetz — no objection to holding it until after 1.5.0; sequencing is your call. Two things that may be useful in deciding. Scope is narrower than it first reads. With the default configuration this is unreachable. On testing. Agreed this is the part that has to be right, so the suite is built to be falsifiable rather than confirmatory:
Verification: new suite 12/12; 28/28 across the asyncer and accessor tests repeated 5x for flakiness, stable every run; If there is a specific scenario you want covered before it lands — an IT-level one, or a shape I have not thought of — name it and I will add it. |
|
@jasperblues per my understanding scenario is the edge one, when all application threads are busy. I would enhance documentation with note: use two pools with caution, increase size as needed. For Assistant switch to virtual threads. Thanks |
|
@igordayen — but pool sizing changes how often the task lands on the submitting thread. It doesn't change what happens when it does: Thinking about the end user: better that it never happens than that they think "I should have read the small print" after a production crash. |
ExecutorAsyncer set the AgentProcess thread local on the worker and cleared it in a finally. Clearing is right for a pooled worker that arrived empty, and wrong the moment a task runs on a thread that is already inside a process. An Executor is free to do exactly that. A direct executor always does, and a ThreadPoolExecutor with CallerRunsPolicy does once its queue fills — so this appears under load and not in development. The submitting thread comes back from async() holding no process. Nothing throws at that point. The next AgentProcess.get() returns null, usually a blackboard read some distance away, so it presents as "the blackboard lost my object" with the cause several frames back. That is an expensive afternoon. AgentProcessAccessor gains with(), which saves and restores. reset() stays for the callers that want the old semantics. Tests: the direct-executor shape (plain, throwing, nested, repeated), the CallerRunsPolicy pool that reaches it under queue saturation, and — unchanged and still asserted — the pooled-thread isolation that clearing was protecting, so the fix cannot be "stop cleaning up". Five of the ten fail before this change and pass after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review question worth answering in the suite rather than in a thread: does this affect virtual threads, given the default is a pooled executor? It does not, and neither does the platform default. `threading.shared` is false by default, so AsyncConfiguration builds Embabel's own executor — a cached platform pool, or a thread-per-task executor on virtual threads. A cached pool always hands off to a worker and a thread-per-task executor always starts a new thread, so in neither case is the submitting thread the one that runs the task, and the clearing this PR replaces was harmless there. Two tests, one per executor, each asserting the task really did run on another thread before asserting the caller kept its process. Both pass BEFORE this PR's fix as well as after — which is the claim being pinned. Reaching the defect needs threading.shared=true AND an application executor that can run a task on the submitting thread: CallerRunsPolicy with a bounded queue, or a direct executor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ExecutorAsyncerCallerThreadTest proves the propagation behaviour on an executor it constructs itself. That leaves one thing unproven: that a deployment can arrive at that executor through configuration at all. Reaching it needs threading.shared=true AND an application executor that can run a task on the submitting thread — two settings in different places, neither of which mentions the other. SharedExecutorCallerRunsWiringIT boots the real AsyncConfiguration against a bounded CallerRunsPolicy pool and saturates it, so the wiring and the load shape are covered together. It asserts the precondition — the overflow task genuinely ran on the submitting thread — before asserting the outcome, so it cannot pass vacuously. Against the unfixed code it fails with "expected: <AgentProcess> but was: <null>", after that precondition has passed. A second case pins the other side: same application executor, sharing left at its default of false, and the task runs on a worker instead. That is what makes the first case's title true rather than incidental. Named as an IT and run with the suite from #1577 (mvn -Dtest='*IT,!LLMOllama*IT' -Dsurefire.failIfNoSpecifiedTests=false test). It needs no LLM keys, so it is safe in any environment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
36026b0 to
203753f
Compare
|
@igordayen — taking the docs note and the load test. Added On the extra guard — I think it is already there, structurally rather than as configuration. Restore differs from clear only when the calling thread already held a process, which is precisely the caller-runs case; on every other path it restores nothing and behaves exactly as before. A flag would add a configuration in which the defect is still reachable, and a predicate that can itself be wrong. |
|
Please consider adding Tests: with restores previous process after success The last one matters because current code says: if (value == null) { So with(null) does not clear. That is okay if intentional, but the test should pin it. I agree we should not add the shared-executor guard conditions. Docs To Add In This PR Location: embabel-agent-docs/src/main/asciidoc/reference/asynch-mode/page.adoc Place it near the threading.shared explanation. Suggested text: NOTE: Follow-Up After This PR (I can take it): Bare minimum guard work later: Add property: Values: Apply only when sharedExecutor != null. Warn or fail for: Tests for that follow-up: shared=true + CallerRunsPolicy + warn starts and logs warning Please run all IT tests. |
…ts you Review feedback from @igordayen. `AgentProcessAccessorTest` covered getValue/setValue/reset and nothing at all on `with`, so the guarantee this PR exists for was pinned only through the executor - where it fails against the behaviour of a saturated thread pool several layers away rather than against its own rules. Six tests now state it directly: restore after return, restore after a throw, restore at every level on the way out of a nested `with`, and clear when the thread arrived empty. The null case is the one worth having written down. `with(null)` does not clear: a null value means the caller had nothing to propagate, which is what ExecutorAsyncer passes for a task submitted from outside any process, so the block runs against whatever the thread already holds. Intended, easy to read as an oversight, now pinned in both directions. Mutation-checked rather than assumed. Reverting the finally to a bare reset() fails four of them; making with(null) clear fails the two null-value tests. The docs note is Igor's text, next to the threading.shared table. The point it makes is not the AgentProcess bug - that is fixed here - but that sharing couples agent execution to the application executor's backpressure, which survives the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@igordayen — taken all of it. Tests, the docs note, and the follow-up left to you.
|
|
igordayen
left a comment
There was a problem hiding this comment.
@jasperblues - looks good, thank you
Review feedback from @igordayen. Most of this is documentation of things that read fine if you already know the answer. The nested role lookup now carries the yaml it reads and says what a miss returns; the model selection context says who sets it, how long it lives and why nothing disposes it; the credential cache says what two concurrent requests for one user actually do; allWellKnownLlmNames says that "well known" means named in configuration rather than reachable, which is the whole point of it in a BYOK deployment and is not guessable from the name. RoleResolution.Options wraps LlmOptions because the wrapper is what makes it a case rather than a payload: unwrapped, the platform cannot tell it from the other two answers in a `when`. The Asyncer doc is the one substantive change. It told implementations to restore what the worker held before, which the model selection context does and AgentProcess does not - it still clears, which is #1911, fixed in #1912. Igor was right that the sentence belonged to the other PR. The obligation is worth stating here either way, so it now states it and says plainly which half is which rather than describing a state neither branch is in on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@Jasper, the only thing I would like to get clarity on is the use case. |
#1894) Closes #1889. `embabel.models.llms` maps a role to one model name, and a model belongs to one provider, so a role pins its provider: give a deployment only an Anthropic key and every OpenAI-named role fails. Roles now resolve through a `RoleResolver` SPI, with a nested `embabel.models.roles` shape naming a model per provider, so a role means whatever the active provider makes it mean. Application resolvers are consulted before the platform's own, so an application can override any role and ignore the rest. A role reached through a user's own credential resolves against that provider only and never falls back to the deployment's flat map - a user's key must not silently select a model the deployment pays for. Services built from user keys are cached under a configurable bound. The model selection context travels with work the platform moves off the calling thread, because losing it does not fail loudly: resolution would quietly serve a model the deployment is billed for. The AgentProcess half of that propagation is #1912, reviewed separately. Unresolvable names stay fatal at startup for a keyed deployment and warn for one awaiting a key; the reference docs now carry the full matrix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>



Fixes #1911.
ExecutorAsyncerset theAgentProcessthread local on the worker and cleared it in afinally. Clearing is right for a pooled worker that arrived empty, and wrong the moment a task runs on a thread that is already inside a process — the submitting thread then comes back fromasync()holding no process.Nothing throws at that point. The next
AgentProcess.get()returns null, usually a blackboard read some distance away, so it presents as "the blackboard lost my object" with the cause several frames back.AgentProcessAccessorgainswith(), which saves and restores.reset()stays for the callers that want the old semantics.Who is affected — narrower than it first looks
Neither the default configuration nor virtual threads can hit this bug. (Said badly in an earlier revision as "not the default, and not virtual threads", which read to at least one reviewer as a claim about what we run on. It is not — it is about which executors the defect can occur on. Embabel and assistant both use virtual threads.)
threading.sharedis false by default, soAsyncConfigurationbuilds Embabel's own executor:newCachedThreadPoolon platform threads,newThreadPerTaskExecutoron virtual. Neither can run a task on the submitting thread, so neither was ever affected.Reaching the defect needs both:
embabel.agent.platform.threading.shared=true, AND an application executor that can run a task on the submitting thread —CallerRunsPolicywith a bounded queue, or a direct executor. Out of the box, nobody hits this.Worth fixing anyway: sharing the app's executor is a supported option, and a propagation primitive that quietly empties a thread local costs whoever meets it a day.
A step-by-step walkthrough of how it goes wrong is in the comment below. To be explicit, since the walkthrough reads like an incident report and is not one: no incident prompted this PR. There was no saturated pool and no observed failure. It was found by reading
ExecutorAsyncerwhile working on the roles SPI (#1889), and the walkthrough is a constructed scenario whose preconditions are stated in its first line.Testing
ExecutorAsyncerCallerThreadTest, twelve cases in four groups:CallerRunsPolicy, saturated so the third task overflows onto the caller. Asserts the precondition (the task really did run on the submitting thread) before asserting the outcome, so it cannot pass vacuously.parallelMapgives every worker the caller's process while leaving none behind. This is what stops the fix being "just stop cleaning up".Five of the twelve fail on unfixed
mainand pass after — the four direct-executor cases plus the caller-runs one. The other seven pass both before and after; each was run against unfixedmainto confirm that.Verification run:
embabel-agent-apimodule: 4019 tests, 0 failuresFollow-up, not in this PR
AgentProcessis a bareThreadLocalpropagated by hand in exactly one class, so it reaches only the threads the platform starts throughAsyncer— not application threads. Micrometer's context-propagation is already a dependency and already used in this same method for observations. Registering it as aThreadLocalAccessoron the globalContextRegistrywould propagate it at every micrometer-aware boundary (Reactor, Spring AI's reactive chains,ContextPropagatingTaskDecorator) and let the hand-rolled capture/restore go away. Worth its own issue.🤖 Generated with Claude Code