Repository navigation
Preserve system-info registrations during stale lifecycle cleanup - #1944
Conversation
52cb860 to
d71b87c
Compare
|
Dmitry Berkovich (@dberkov) please let me know if you need additional context. This PR and #1945 are spilt from PR #1634 for the CI fixes. These both need to go in before we can rebase and kick off CI to merge the main PR |
| defer func() { | ||
| if err != nil { | ||
| s.systemInfoVolumes.Deregister(actorUID) | ||
| if err != nil && registration != nil { |
There was a problem hiding this comment.
With this guard, a Run/Restore that fails before Register now preserves a pre-existing registration for the same actor. But resetActorDirs(actorUID) at the top of this same request already removed SystemInfoVolumeRootsDir(actorUID), so the preserved entry points at files this request deleted. refreshBundle only rewrites on a hash change, so nothing recreates the bundle until the next CTB rotation, and the entry keeps being iterated by projecting() indefinitely. The old unconditional Deregister cleared this case.
Scenario: stale entry for X (crashed worker, no Terminate) → Restore X runs resetActorDirs → ensureSandboxAssets fails before Register → defer skips → X's stale entry stays live with its trust files gone.
One option: have the pre-Register failure path still drop whatever entry is there (it is stale by construction once the dirs are reset), and only use the owner check after Register has run. Same applies to the Run defer above.
There was a problem hiding this comment.
Thanks for the suggestion! Run and Restore remove the old registration before they reset the actor directories.
If Run fails before this step, it keeps the existing registration. If either operation fails after Register succeeds, cleanup removes only that operation’s registration.
| certA := string(testCertPEM(t)) | ||
| store.set(t, certA) | ||
| refresher := newSystemInfoVolumeRefresher(store.lister, nil) | ||
| registeredDir := t.TempDir() |
There was a problem hiding this comment.
This registers the "existing" volume under a separate t.TempDir() rather than ateletpath.SystemInfoVolumeRoot(actorUID, ...), which is where systemInfoVolumesFor would put it. So the test never models resetActorDirs deleting that root during the failed Restore, and the final readProjected(...) == certB check only passes because refreshBundle happens to MkdirAll and rewrite on the hash change. The intermediate state (registration live, files missing) is never asserted. Suggest using the real root and asserting the files right after the failed Restore, before rotating the CTB.
There was a problem hiding this comment.
switched to use the actual path with SystemInfoVolumeRoot, moved checks right after failure and before bundle rotation
| if err == nil { | ||
| t.Fatal("Run succeeded with an invalid pause image") | ||
| } | ||
| if got := refresher.actors["actor-uid-run"]; got != nil { |
There was a problem hiding this comment.
This never asserts that the failure happened after Register (the Restore twin below checks for while creating pause OCI bundle). If mountExternalVolumes, resetActorDirs, or ensureSandboxAssets starts failing in this fixture, or someone reorders Run so prepareOCIBundles precedes Register, Run still errors, actors["actor-uid-run"] is nil because Register never ran, and the test stays green without ever exercising DeregisterOwned on the Run path. Pin the error, or assert registration was non-nil.
There was a problem hiding this comment.
Both tests now require the OCI preparation error and the projected ca.pem file. This ensures that the Register ran before the failure.
| actor.stale = true | ||
| } | ||
| r.mu.Unlock() | ||
| return actor, fmt.Errorf("while populating system-info volume %q: %w", v.Name, err) |
There was a problem hiding this comment.
Returning a non-nil *registeredActor together with a non-nil error, after this block has already removed it from the map, means the callers' deferred DeregisterOwned(actorUID, registration) takes r.mu only to find a mismatch and return. Either return nil, err here (both callers already guard on registration != nil) and keep this self-cleanup, or drop the self-cleanup and let the defer do it. Doing both is redundant and the value-with-error return is easy to misread.
Related: Deregister, DeregisterOwned, and this branch now hand-roll the same "remove from r.actors under r.mu, then mark stale" sequence, with one of them setting stale under r.mu and the other two after releasing it. A single remove(uid, match func(*registeredActor) bool) helper would keep the three in step.
There was a problem hiding this comment.
Register now removes its failed entry and returns nil, err. Kept this cleanup separate because Register already holds the actor lock. The other cleanup paths take that lock themselves.
| if err := r.write(ref, actorUID, v); err != nil { | ||
| return fmt.Errorf("while populating system-info volume %q: %w", v.Name, err) | ||
| r.mu.Lock() | ||
| if r.actors[actorUID] == actor { |
There was a problem hiding this comment.
When the initial write fails after this Register superseded a live prev, prev has already been marked stale and dropped, and nothing restores it. So a retried B for X that fails here leaves X with no registration at all, which contradicts the PR's stated invariant that failure cleanup is scoped to the operation's own registration. Pre-existing in effect, and in production prev's files were wiped by resetActorDirs anyway, so this may just warrant a comment rather than a fix.
There was a problem hiding this comment.
Added a comment and test to make this behavior explicit: a failed replacement does not restore the previous registration. Run and Restore remove that registration before resetting its files.
|
|
||
| // DeregisterOwned removes actorUID only when it still points at owner. This | ||
| // protects a newer registration from cleanup belonging to an older operation. | ||
| func (r *systemInfoVolumeRefresher) DeregisterOwned(actorUID string, owner *registeredActor) { |
There was a problem hiding this comment.
owner.uid already carries the UID, so taking actorUID separately opens two silent failure modes: a mismatched pair (DeregisterOwned("uid-b", regForA)) compares the wrong slot, never matches, and leaves regForA live with no signal; and a nil owner for an absent UID passes the nil == nil guard, no-ops the delete, then panics on owner.mu.Lock(). Suggest DeregisterOwned(owner *registeredActor) that returns early on nil and keys off owner.uid.
There was a problem hiding this comment.
DeregisterOwned now takes only the owner, uses owner.uid, and returns immediately for nil. Tests check that cleanup from an old owner preserves the newer registration.
| return nil, err | ||
| } | ||
|
|
||
| s.systemInfoVolumes.Deregister(actorUID) |
There was a problem hiding this comment.
This closes the stale-files case, but it also makes the underlying hazard sharper. Two overlapping activations for the same actorUID still destroy each other: if the control plane times out Run #1 while prepareOCIBundles is still unpacking and issues Run #2, this line marks Run #1's live registration stale and resetActorDirs deletes its half-written bundle. Run #1 then fails on a half-deleted tree, or if its RunWorkload already went out, leaves a running actor whose only registration was built from Run #2's reset.
As far as I can tell atelet has no per-actor lock around Run/Restore/Checkpoint/Terminate (the only keyed mutex in cmd/atelet is in filecache). A per-UID mutex around those bodies would remove the whole class, and Register's supersede branch plus a plain Deregister in the defer would be correct again, so the token API could go away.
Not asking for that in this PR. But if that is the intended end state, it would be good to say so, since otherwise DeregisterOwned reads as the fix rather than a stopgap.
There was a problem hiding this comment.
I think we need to track this one in particular for follow-up. It's broader but should-fix.
| defer func() { | ||
| if err != nil { | ||
| s.systemInfoVolumes.Deregister(actorUID) | ||
| if err != nil && registration != nil { |
There was a problem hiding this comment.
This is correct today, but only because nothing returns between this defer and g.Wait() further down, and that constraint lives in the comment. A future early return nil, err in between (say a goldenRec validation moved below op.kind) makes the defer read registration while the prep goroutine may still be assigning it: a -race hit, and in the lost-write case a registration that is never cleaned up.
A shape that does not depend on the ordering: have the prep goroutine own cleanup of what it registered (call DeregisterOwned on its own error path), or install this defer only after g.Wait() and handle the Wait-failure branch explicitly.
| defer func() { | ||
| if err != nil { | ||
| s.systemInfoVolumes.Deregister(actorUID) | ||
| if err != nil && registration != nil { |
There was a problem hiding this comment.
Nit: registration != nil here (and at the Restore defer) duplicates the nil guard inside DeregisterOwned, which also has its own test. The call-site check is dead and tells the next reader the callee is not nil-safe. Keep one contract, either the callee guard or the call-site check.
| return err | ||
| } | ||
| if err = s.systemInfoVolumes.Register(actorUID, actorRef, systemInfoVolumesFor(actorUID, req.GetSpec())); err != nil { | ||
| if registration, err = s.systemInfoVolumes.Register(actorUID, actorRef, systemInfoVolumesFor(actorUID, req.GetSpec())); err != nil { |
There was a problem hiding this comment.
Minor, non-blocking: Register can now fail after writing some volumes (say metadata succeeds, trust fails because the ClusterTrustBundle is not synced yet). It self-removes and returns nil, so nothing identifies or cleans the files it left under the freshly reset root. Harmless today because every activation resets first, but if Register ever gains a caller that does not reset, those become stale projections. Simplest hardening would be for Register to remove the roots it created in this call before returning the error.
| @@ -107,8 +107,9 @@ func newSystemInfoVolumeRefresher(lister certlisters.ClusterTrustBundleLister, i | |||
| // Register records actorUID's system-info volumes and writes their contents | |||
| // from current cluster state. If actorUID is already registered (for example | |||
There was a problem hiding this comment.
This comment still motivates supersede-on-collision with the crash-recovery case, but after this PR Run and Restore Deregister before reset, so that path never reaches Register with a prev entry. The only remaining way the superseding branch fires is two activations overlapping for one UID. That is worth saying here, and the Info-level "Superseded a stale system-info volume registration" log probably deserves Warn now, since it signals the race rather than routine recovery.
| for _, v := range volumes { | ||
| if err := r.write(ref, actorUID, v); err != nil { | ||
| return fmt.Errorf("while populating system-info volume %q: %w", v.Name, err) | ||
| r.mu.Lock() |
There was a problem hiding this comment.
Understood on keeping this separate because actor.mu is already held here. That said, there are now three copies of "delete map entry under r.mu, then mark stale" (here, Deregister, DeregisterOwned) with slightly different lock shapes. Splitting out just the map part, something like unlink(uid string, owner *registeredActor) *registeredActor that does the r.mu section and returns what it removed, would let all three share it while each caller handles stale under whatever lock state it is in.
| } | ||
| }) | ||
|
|
||
| t.Run("failed initial write cleanup preserves a newer registration", func(t *testing.T) { |
There was a problem hiding this comment.
This subtest passes a nil owner (the preceding subtest asserts that Register returns nil on failure), so DeregisterOwned returns at the nil guard and the ownership comparison it is named for is never reached. Replacing the r.actors[owner.uid] != owner check with an unconditional delete would not fail this test. The behaviour the name promises is covered by StaleDeregisterPreservesNewerRegistration already. Either drop this one as redundant with DeregisterOwnedNil, or rename it to say it checks that a failed Register yields a nil token whose cleanup is a no-op.
c9d19a3 to
bac0e4a
Compare
Invalidate obsolete projections before resetting actor files, while keeping failure cleanup scoped to its registration owner. Return no owner after an initial write fails and make owner cleanup safe for nil input. Cover the reset boundary and registration error paths with lifecycle tests.
bac0e4a to
3d615ba
Compare
|
Dmitry Berkovich (@dberkov) let me think about this a little bit more. My current thoughts are to see what's absolutely necessary for addressing the core intent of the PR. |
Benjamin Elder (BenTheElder)
left a comment
There was a problem hiding this comment.
I see a couple nice to have cleanups that would be ideal from changes specific to this PR (e.g. the nit about outdated logging), and one broad issue we should deal with in a separate issue / PR https://github.com/agent-substrate/substrate/pull/1944/changes#r4167941631
I don't think we need to block on any of these.
|
I'll add another PR with a quick followup to do the logging changes and create a separate issue for the broader fixes |
…rate#1634) Addresses agent-substrate#1017 by adding E2E tests for HTTPS and WebSocket egress. Added scope after the egress policy was finalized: [review comment](agent-substrate#1634 (comment)). Updates the gateways to reject HTTP/1.1 WebSocket upgrades with 403. PRs merge order/stack: agent-substrate#1945 agent-substrate#1944 and then this PR
ateletlifecycle fixes from CI failures on PR #1634