Repository navigation
Conversation
A clean shutdown lost the saves it held, in two ways. The branch workers ran on a context derived from the manager's, so the manager's cancel also canceled the shutdown's own last push: over a real transport it was refused before it reached the remote, and the save attached to the open window failed with "worker stopped before the commit reached the remote". A worker now stops on one context and works on another, which ends 10s after the stop or when its loop exits, so the last window is committed, pushed and its saves settled. The CommitRequest controllers stop at the same moment as the workers (controller-runtime stops every leader-election runnable together), so nothing read the outcomes the workers decided while they drained, and the next process attached the request again as new: NoWindow, or its message on a later window. The core now remembers every request it attaches, with its path, and once every worker has stopped the worker manager runs CommitRequestsInFlight.Settle on a fresh 5s context: one step per request that reads the stopped worker's outcome (LookupCommitRequestOutcome, which sends nothing) and writes it with the core's own decide, conditional on resourceVersion, on the control cluster and on source clusters alike. A request no worker decided keeps its status for the next process, as before. Nothing is persisted: a crash, or a stop past its grace period, behaves as before. docs/TODO.md folds the withheld-field item into one "Restart recovery: persist current state" entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 59 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Local e2e, focused on the new spec
An earlier baseline attempt failed for an unrelated reason: the spec asked for 10m timers, and the schema caps them at 5m. That was fixed before the push. The full suite runs in the CI legs. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A graceful stop (an upgrade, a rollout, a node drain) now finishes the saves it holds: every branch
worker closes its windows and pushes them, and once every worker has stopped, the outcome of each
save they settled is written to its
CommitRequest's status before the process exits. Restartrecovery stays in memory: nothing is persisted.
The race, verified
Two defects, both on the clean-shutdown path. The second is the one the plan named; the first was
under it, and made it worse.
1. The shutdown push never reached the remote. A branch worker runs on a context derived from
the manager's, so the moment the manager is canceled every worker's context is canceled too, and
handleShutdownthen finalized the window, committed locally, and pushed on that canceled context.Over a real transport the push was refused at once (
getAuthFromSecretreads uncached Secretsthrough the API, and go-git's HTTP and SSH calls honor the context): the save attached to the window
failed in memory with "worker stopped before the commit reached the remote", and the edit was lost
with the pod's
emptyDircheckout. Unit tests never saw it because go-git v6'sfile://transportignores the context.
TestShutdown_TheLastWindowIsPushedOnceTheManagerCancels(real githttp-backend) fails on the base with exactly that error, and passes here.
2. Nothing wrote the outcome. controller-runtime v0.25 stops runnables by group: non-leader
runnables, then every leader-election runnable at once, then the caches. The
WorkerManagerand theCommitRequestcontrollers (andCommitRequestServer, and the wakes) are all in that one group. Oncancel the controllers shut their queues and finish in-flight reconciles on the canceled context; the
workers drain concurrently, and every outcome they decide lands in
crOutcomeswith no step left toread it. The next pod's core resumes from status (Ready=Unknown,
CollectingWindow), re-sends theattach, and a worker that never saw the request registers it as new:
NoWindow, or its message on alater window of the same author. With
CommitEmpty, a second commit.The new e2e spec is queued locally against an unmodified copy of the base tree to show it end to end
there too; its result will follow as a PR comment.
Deployment facts that shape the budget: the chart sets
terminationGracePeriodSeconds: 20andmaxSurge: 0; the manager'sGracefulShutdownTimeoutis the 30s default (so the kubelet's SIGKILL isthe real bound); no leader election is configured.
The fix
BranchWorker.Start). The parent's cancel isthe stop signal; the work context is
WithoutCancel(parent), endedworkerShutdownBudget(10s)after the stop, or when the loop exits. So the last window, its push and its saves' settlement run
live, bounded. All workers see the cancel together, so the drain is about as long as the slowest
worker, not the sum. A worker retired or replaced while the process runs gets the same last push,
which
RetireGitBranchalready promised.CommitRequestsInFlight). The core remembersevery request it attaches, with the path that attached it (control cluster, or a KubeProvider's
watch through its own dynamic client), and forgets it once an outcome is written or the request is
gone.
WorkerManager.SetAfterWorkersStoppedrunsSettleonce every worker has stopped, on afresh context bounded at 5s, still inside the leader-election group's stop, so the caches the core
reads are still running.
Settletakes one step per remembered request (4 at a time): read it,ask the router for the outcome the holding worker decided (
LookupCommitRequestOutcome, new: itsends nothing), and write it with the core's
decide, so every rule holds: the owner path writes,the write is conditional on resourceVersion, and a conflict re-reads and re-applies the owner's
outcome without restarting the save.
Why this shape over the alternatives:
wait on a "workers drained" signal) needs either the worker manager outside the leader-election
group, which would run workers on non-leaders, or a controller that keeps reconciling after its
context is canceled. Neither is available from controller-runtime without wrapping its controller,
and the reconcile queue would still be asynchronous: nothing tells you when the last outcome is
written. A synchronous last pass knows when it is done and what it left.
source-cluster path's client into
internal/git. The pass reuses the core's owndecide, throughthe same store each path already uses.
settleis narrower than a normal step on purpose: no attach, no withdraw, no phase write. Arequest no worker decided anything for keeps its status, so the next process resumes it exactly
as it does today, rather than seeing a misleading
WaitingForWorkeror a "no branch worker withinthe safety window" failure written by a process that is exiting.
Total: 10s + 5s inside the chart's 20s. The audit ingress and the push receiver are
leader-election runnables too: the receiver's
NeedLeaderElectionreturns true, andauditServerRunnabledefines none, so controller-runtime puts it in that group. The non-leadergroup holds only the certificate watchers, which stop at once. So the two servers' HTTP drains
(at most 10s each, normally at once) run alongside the workers' drain rather than before it, and
the 15s still bounds the group. On overrun a push is cut off and its saves fail, or
Settlestops early and logs therequests it left; those behave as today.
What a deploy now looks like for an editor
reports
Ready=True Committedwith the SHA before the pod is gone. The next pod sees a finishedrequest.
Stalled=Truewith the reason, instead of starting over.next pod attaches it again from its status, with a fresh
attachTimeout, and it attaches to thenext window its author opens there. Unchanged.
Stated limits
behaves as before.
docs/TODO.md's "Persist the withheld-field record" is folded into one "Restart recovery: persistcurrent state" entry: what must survive, the push-intent record that makes "Git took the push,
then the process died" safe, and the two restore traps (the cold replay closing restored windows
via
resync_flush.go, andfinalizeWindowdropping a window while render fidelity is Unknown),plus the
emptyDirworker state root.maxSurge: 0does not stop Kubernetes from creating thereplacement as soon as the old pod is Terminating, and no leader election is configured, so a fast
new pod can re-attach a request the old one is still settling. The old pod's push and status write
should take about a second after SIGTERM, inside a new pod's start, but it is a race. The e2e
manifest (
config/deployment.yaml) uses the default surge, so its replacement pod is Ready beforethe old one even stops. That is why the e2e spec scales to zero instead of running
rollout restart.Tests
internal/git/shutdown_drain_test.go(real git http-backend): the last window is pushed after themanager cancels, and the save reports the remote's commit; a stalled last push ends at the budget
and fails the save; the manager runs the settle step after every worker stopped, on a live
bounded context, with the outcome already decided.
internal/controller/commitrequest_settle_test.go: the reconciler's attached save is written fromthe worker's lookup alone, no attach; a shutdown failure is written; an undecided request is left
untouched; a conflict re-applies the outcome; a source-cluster request is written on its own cluster
through the dynamic client after the server stopped; a canceled context writes nothing.
Save Request across a controller restart(labelcommit-request-restart, Serial, registeredin
docs/spec/e2e-serial-registry.md; runs in thefull-coreleg): edit, attach a request with5m timers (the schema's maximum), scale the controller to zero, read
Committedwith its SHA while no controller runs,scale back up, assert exactly one commit with the message and that the request still reports it.
Validation on the final commit:
task fmt,generate,manifests,vet,lintandtestare green(coverage 82.6%, baseline raised). The shutdown tests also pass 5x under
-race. Not yet run locally:e2e. The focused restart spec is queued on the shared e2e cluster, once on this branch and once on
the unfixed base tree, and the CI legs run the full suite. Results follow as a PR comment.
🤖 Generated with Claude Code