fix(openresponses): make responses visible and cancellable across replicas#11000
Merged
Conversation
…licas In distributed mode the Open Responses store is process-local: a sync.OnceValue over a map behind an RWMutex. With several frontend replicas behind a round-robin load balancer, every request that lands on a replica other than the creator misses. Measured on a live 2-replica cluster (#10993): the same response id returns 200 on the creating replica and 404 on its peer, and a cancel on the peer returns 404 without ever invoking CancelFunc, so generation runs to completion on the other replica while the caller is told the response does not exist. previous_response_id chaining fails through the same lookup. Split the state by what can actually cross a process boundary: - Replicated: response metadata (request, response resource, owner, expiry, stream/background flags) via syncstate.SyncedMap, the same component finetune, quantization and agent tasks already use. A local miss in Get/FindItem now falls back to it and returns a read-only remote view, so polling and chaining resolve on any replica. - Delegated: cancellation. context.CancelFunc is a function pointer and exists only in the creating process, so a cancel that lands elsewhere is broadcast on responses.<id>.cancel and applied by whichever replica holds the function. The broadcast is fire-and-forget rather than request/reply: if the owner crashed or was scaled down nobody answers, and the handler must not block on a reply that will never come. The replicated status moves to cancelled either way, which is truthful, since a dead owner's generation died with its process. - Refused: streaming resume. The resume buffer is a byte log plus a live notification channel and cannot be replicated without shipping every token over the bus. A resume that reaches the wrong replica now returns HTTP 409 naming the owning replica via the new ErrResponseNotLocal, instead of an empty event list that looks like a finished stream. It is deliberately distinct from ErrOffsetLost, which means the owner's buffer evicted the requested events. Standalone deployments never call EnableDistributed and keep exactly the previous process-local behaviour. Fixes #10993 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #10993.
Problem, reproduced on a live cluster
The Open Responses store is a
sync.OnceValueover a process-local map (store.go:39,88-92). Behind a round-robin load balancer, any request landing on a replica other than the creator misses.Measured on a 2-replica controller, same response id:
The cancel is the worst of it: it returned 404 without ever invoking
CancelFunc, so generation ran to completion on A — consuming GPU — while the caller was told the response did not exist.Approach: replicate, delegate, or refuse
StoredResponsewas split by what can actually cross a process boundary.Replicated — request, response resource, owner, timestamps, expiry,
StreamEnabled/IsBackground, plusOwnerReplica. Carried in asyncstate.SyncedMap[string, *syncedResponse](namespaceresponses.metadata) — the same componentfinetune,quantizationand agent tasks use, per #10542.GetandFindItemfall back to it on a local miss and return a read-only view flaggedRemote: true.That fixes polling,
previous_response_idchaining (the request is replicated, so the turn can be replayed) and item lookup.No durable Postgres
Storeis wired: responses are ephemeral, TTL-bounded state that already does not survive a restart, so peers converge through deltas alone — which syncstate explicitly sanctions for ephemeral state. A late-joining replica does not learn about older responses, which is the same visibility a client had before and strictly better than the previous 404-from-every-peer.Delegated — cancellation.
CancelFuncis a function pointer; only the creating process has it. A cancel on a non-owner broadcastsresponses.<id>.cancel, and every replica's wildcard subscriber applies it if it holds the response. Both the direct and delegated paths funnel through onecancelLocal, so terminal-state idempotence is identical.Broadcast rather than addressing
OwnerReplicadirectly, following galleryop'sCancelOperation: a broadcast cannot misroute after a restart and needs no liveness knowledge. (Agent tasks were the other candidate pattern but do not record an owner at all — they delegate throughdispatcher.Cancel.)Refused — streaming resume. The resume buffer is a byte log plus a live channel; replicating it means shipping every token over NATS. New
ErrResponseNotLocal→ HTTP 409, naming the owning replica.Deliberately not
ErrOffsetLost: that means "the owner's buffer evicted your events" — a retention verdict, after which a client may reasonably stop. This means "ask the other replica" — a routing problem. Conflating them would mislead clients into giving up.GetEventsAfter/GetEventsChanreturn it too, so no caller can receive an empty slice that reads as a finished stream.Dead owners
The delegating publish is fire-and-forget. A request/reply would block the HTTP handler for a full timeout precisely when the owner is gone. The delegating replica marks the replicated status
cancelledwithout waiting — truthful either way, since a crashed owner's generation died with its process.Cleanupalso reaps expired replicated entries whose owner never got to expire them.Not fixed, and why
Cross-replica stream resume. Refused with a clear 409 rather than half-implemented. Making it work needs either the event buffer in a shared store — a real durability and throughput decision — or session affinity at the ingress. Both options are documented in
docs/content/features/text-generation.md.Compatibility
Standalone deployments never call
EnableDistributedand are behaviourally unchanged.Testing
Red first, functionally: with
EnableDistributedstubbed to the pre-fix path, 7 of 42 specs failed on behaviour —response not found: resp_cross_replica,resp_dead_owner,resp_cancel_terminal, anderrors.Is(err, ErrResponseNotLocal)returning false. No compile errors.Green: all 42 pass.
-raceclean onopenresponses,routes,messaging,syncstate. Specs cover cross-replica visibility, status propagation, delete propagation, cancel reaching the owner'sCancelFunc, dead-owner non-blocking cancel, terminal-state idempotence, and the resume refusal.go build ./core/... ./pkg/...OK;make lint0 issues. Repo-wide grep includingtests/e2e/found nothing asserting single-store behaviour; the only 404 assertion (core/http/openresponses_test.go:927) is for a genuinely nonexistent id and remains correct.🤖 Generated with Claude Code