Repository navigation
Conversation
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
EItanya
left a comment
There was a problem hiding this comment.
🤖 AI-generated review, left by Claude on behalf of @EItanya.
Thanks for digging into this, the stranded CREATING reservation is a real problem worth fixing. Before going line by line, though, I think the overall approach should change. Keeping DB operations simple and transactional matters a lot in this system, and the repo already has a pattern for exactly this kind of problem. This PR builds a separate system next to it.
The core problem
A CREATING row blocks its source Session in about seven places: task admission, quiescence, recovery, deletion and lifecycle operations. If the inline creator dies or its caller cancels, nothing ever finishes that row. Interrupted deletion is secondary: it only keeps references alive until Delete is retried.
The existing pattern
runtime_instance handles abandoned durable operations like this (runtime_instances.go claimRuntimeOperation / ReleaseRuntimeOperation / completeRuntimeOperation, and session/workflow.go):
- Work runs inline in the request.
- An execution claim (
executor_id,executor_expires_at) is bounded byRuntimeOperationTimeout. - The claim is a single conditional UPDATE.
- Completion clears the claim in the same transaction that records the outcome.
- On error the claim is released; a caller that fails to claim gets the current outcome back.
The PR adds the same claim columns, but moves all work into a leader-elected worker while API replicas poll for the result. Most of the added complexity follows from that one choice:
- Durable
operation_errorcolumn,RecordCheckpointOperationError, and polling waiters: needed only because the caller no longer runs the attempt itself. - Work queue, in-memory retry map, wake channel, worker goroutines, a 1 s
ListPendingCheckpointspoll, and a new index: the machinery for scheduling and discovering that work. BeginCheckpointCleanup/finishCleanupintermediate phase: a failure recorded while still inCREATING.- Activation barrier with a pod annotation, pod/ReplicaSet RBAC and Helm wiring: keeps old inline binaries from racing the new worker.
There's also a concrete bug in the durable-error path, which I reproduced on 6f1dbe8. operation_error is not cleared when a new Delete starts (BeginDeleteSessionCheckpoint) or when a later attempt fails transiently. After the backend is repaired, a retried Delete returns the stale PermissionDenied within about 2 ms. It keeps doing so until the worker's next backoff retry happens to succeed. The design below removes this class of bug entirely.
Suggested design
Make checkpoint creation follow the runtime-operation pattern, and add the smallest possible recovery path:
- Reserve takes the claim.
ReserveSessionCheckpointsetsexecutor_id/executor_expires_atin the same INSERT that creates theCREATINGrow, so the fast path needs no extra query. - Do the work inline, as today:
ensureTagunder the request deadline. - Finish with one atomic update. A single
UPDATE ... WHERE state = 'CREATING' AND executor_id = $me AND executor_expires_at > now()sets READY or FAILED and clears the claim. The fast path stays at two writes: reserve, then finalize. - Drop the cleanup phase. When the copy has finished but fails verification, go to
FAILEDand keeptag_uid. Delete already removes a tag by its UID, and fork only accepts READY. This removesBeginCheckpointCleanup/finishCleanupand the "failure while stillCREATING" state. If holding admission until cleanup is a hard requirement, keep a compensation step, but as one more conditional UPDATE rather than a separate phase. - Leave it pending on uncertain errors. For a transport error or cancellation, leave the row
CREATINGand release the claim (executor_expires_at = now()), likeReleaseRuntimeOperation.CreateTag's serialization on the Actor and tag already covers a copy that is still finishing. - Retrying takes over. A Create retried with the same
request_idclaims the existing row and drives it inline, or returns the current outcome if someone else holds the claim. - One small sweep for callers that never come back. Periodically, one query selects
CREATING(and optionallyDELETING) rows with an expired claim and runs the same function as step 2. Claims already exclude live work, and claim expiry plus the sweep interval act as the retry backoff. No queue, retry map, wake channel or durable error is needed. - No activation barrier. New code never leaves a
CREATINGrow with a NULL claim, so a NULL claim can only come from an old binary. The sweep can skip those until the UUIDv7 id is older thanRuntimeOperationTimeout. That bounds the overlap with old binaries without Kubernetes reads, RBAC or annotations. Pre-1.0, documenting it would also be reasonable.
Compared with the current PR, this means:
- Columns: 2 new (the same claim pair as
runtime_instance) instead of 3. - Index: none, unless the sweep needs one.
- Store methods: about 4 new or changed instead of 8.
- Fast path: reserve and finalize, with no polling.
- Kubernetes and Helm: no changes.
It also removes the dead unclaimed FinalizeSessionCheckpoint / DeleteSessionCheckpoint paths, since finalization would always require the executor.
One open question for both designs: they rely on CreateTag serializing on the Actor and tag, so that AlreadyExists means any earlier copy has finished. It would be good to confirm that with the Substrate side.
Happy to discuss. If there's a requirement I'm missing that makes the worker model necessary, please point it out.
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
|
@EItanya @shashankram — could we settle these two release decisions following the architectural review? Implementation and validation details remain in the PR description. 1. First upgrade from controllers without checkpoint claimsIs a documented, one-time controller API maintenance window acceptable for this transition in 1.0? My recommendation is to follow the release-wide upgrade policy: quiesce checkpoint mutations, stop every legacy controller, wait for its process to exit, then start claim-aware controllers and resume requests. Legacy writers have no overall operation deadline; UUIDv7 age plus the two-minute grace does not fence a paused writer. Losing readiness or requesting scale-to-zero is also insufficient until the old processes exit. Before rollback to a claimless binary, pending checkpoint work must finish, FAILED checkpoints retaining tags must be deleted, and current writers must drain. The old deletion path cannot collect partial tags without a completed URI. The upgrade tests exercise this drain boundary, but the production installer still performs ordinary Helm upgrades. If maintainers accept the maintenance window, the agreed procedure needs public upgrade/rollback instructions and any required installer enforcement before release. If uninterrupted API availability is required, I suggest agreeing on a staged compatibility rollout at the release level before enabling recovery, rather than adding a checkpoint-specific Kubernetes activation protocol. Later claim-aware rollouts can coordinate through database claims, subject to the rest of the release's compatibility requirements. 2. Substrate's serialization guarantee for external writesCan the Substrate side confirm that, after a serialized The live concurrency regression covers a copy still holding valid leases after cancellation delivery is lost: a competing attempt gets My recommendation is to document and fault-test this contract in Substrate. If it does not hold, the owning backend needs to supply a definitive safe outcome; until then, kagent must retain Could we confirm the accepted upgrade policy and who can validate the Substrate contract, so the remaining release work has a clear owner? |
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
|
🤖 Comment left by Claude on behalf of @EItanya. Thanks for reworking this so quickly, @Jacob953. The inline claim design looks much better: reserve takes the claim, finalize is a single conditional update, and the worker, the durable error and the activation barrier are all gone. Answers to your two questions are below, followed by a few remaining concerns about the code. 1. First upgradeI'd rather not have a maintenance window, and from what I can tell we don't need one. I went through each case where an old controller and a new one run at the same time:
So I think an ordinary rolling upgrade is fine. Can we replace the quiesce/drain procedure in the doc with a short note about the mixed-version window, and drop 2. Substrate's guaranteeI read through the v1.0.0 server code (
You're right that this isn't airtight. The copy runs in the object-store plugin, and no fencing token reaches the object store, so an in-flight write can land after the lease is gone. However, I don't think kagent depends on that guarantee. The destination prefix is keyed by the tag UID and never reused. A late write can only leak objects after cleanup (a Substrate storage concern that affects every client) or finish a tag we've already marked FAILED. Fork only accepts READY, and Delete still works by UID. We also never publish READY without verifying that the tag is COMPLETED with the right identity. So I'd trim "Required backend contract: pending Substrate confirmation" down to a short note rather than block on it. A Substrate issue about fencing object-store writes would still be nice to have, but it isn't a release blocker for us. Remaining concernsFAILED checkpoints are now public. Because a FAILED row keeps its tag, Size. The PR is currently +4.2k lines across 58 files. If FAILED stays internal and the upgrade tests go, a lot of that drops out. I'd also prefer not to wire Architecture doc. The new section reads more like an implementation log than a design doc. For example, "Create observers skip obsolete claim releases…" is better as a code comment. I think a few paragraphs are enough: the claim lifecycle, the sweep, and how FAILED is collected. Smaller things:
|
…cope Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
…over Signed-off-by: Junfeng Yu <64075563+Jacob953@users.noreply.github.com>
A canceled request or controller restart can leave a
CREATINGcheckpoint blocking its source Session indefinitely. Checkpoint requests now execute a bounded attempt inline, with a small sweep to recover work when the caller never returns. This addresses the remaining lifecycle recovery scope of #2746 after #3199.Behavior
FAILEDrows retaining a tag UID. Collection deletes by UID, then conditionally clears that UID and the live claim. Errors retain the handle for retry; the terminal request result remains stored, so replay never starts another copy.The explicit transaction around claim acquisition is retained for cancellation safety. A new PostgreSQL regression delays the real UPDATE until after caller cancellation and retirement, suppressing query cancellation to model lost delivery. With autocommit, the delayed UPDATE leaves a live claim; with the transaction, the canceled COMMIT prevents it. Existing coverage also verifies that retirement waits behind an already acquired row lock.
Upgrade and scope
Ordinary rolling upgrades are supported. Migration 000004 adds nullable claim columns; legacy NULL-claim creation/deletion gets the two-minute UUIDv7 grace. During the mixed-version window, old controllers retain their existing unconditional failure-cleanup race; the new code uses UID-conditional cleanup. Rollback leaves the additive schema in place. The checkpoint-specific drain/rollback tests and upgrade hooks have been removed.
Substrate v1.0.0 serializes create/delete with Actor/Tag leases and uses a unique, never-reused tag-UID destination prefix. Object-store writes can outlive lease loss and leak storage or finish an internal FAILED tag, but cannot make it forkable. Stronger object-store fencing is a Substrate follow-up, not a kagent release blocker.
The direct Substrate/object-store fault suites and their credential-exporting CI script are out of this PR. The regular API E2E remains: lose a successful Create response, restart the controller, replay the same request, and fork through the API. The architecture doc now describes the lifecycle and rolling-upgrade boundary without the implementation history.
Validation
238ef3d2unchanged; a validation-only workflow lives on the fork's temporary branch, outside this PR. They use ordinary API E2E and the repository's existing upgrade targets, without exporting controller credentials or object-store keys. Upstream Actions still require maintainer approval; these are independent fork results.