Skip to content

ateapi,atelet: reclaim actor node state when a worker's pod is gone - #2797

Merged
Michelle Au (msau42) merged 1 commit into
agent-substrate:mainfrom
sairajp-rewind:durdir-gc-pr1
Oct 21, 2026
Merged

Michelle Au (msau42) merged 1 commit into
agent-substrate:mainfrom
sairajp-rewind:durdir-gc-pr1

Conversation

@sairajp-rewind

@sairajp-rewind Sairaj Pokale (sairajp-rewind) commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

What is this PR and Why we need it ?

A deleted worker pod is deregistered by workersync, which calls DeleteWorker to mark its assigned actors CRASHED and clear their WorkerAssignment. Those actors are never terminated on the node, so /var/lib/ate/actors/<uid> and /var/lib/ate/snapshots/<uid> stay on disk until the actor is later deleted or reverted, if it ever is. (Note: this covers worker-pod removal via DeleteWorker; tearing down a crashed sandbox on a still-live worker is tracked separately in #2355.)

Changes

  • atelet workerless / gone-worker Terminate:
    • Skips TerminateWorkload when worker_pod_uid is empty, the worker socket is gone, sandbox-assets.json is absent (Run and Restore write it before RunWorkload/RestoreWorkload, and an earlier Terminate removes it with the actor directory), or TerminateWorkload returns Unavailable/NotFound. Any other error from a live worker fails Terminate before touching disk.
    • Volume unmount failures in Terminate return Internal without %w-wrapping downstream gRPC errors, so a transient CSI failure does not surface to ateapi as Unavailable (and reaches DeleteActor/RevertActor callers as Internal instead of Unavailable).
  • ateapi (DeleteWorker):
    • DeleteWorker takes each actor's lease (ABORTED if held, requeued by workersync), skips actors that already reached SUSPENDED, and calls best-effort atelet.Terminate with a 30s timeout to reclaim the actor's node state and local snapshots. Once a call times out (DeadlineExceeded) or returns Unavailable, remaining actors on that worker skip the node and leave cleanup to revert/delete.

Testing

  • Unit & functional tests: go test ./cmd/atelet/... ./cmd/ateapi/internal/controlapi/...
  • Live-cluster verification (3 × c3-standard-4 gvisor nodes, 3 × c3-highcpu-4 microvm nodes):
Suite gvisor (3 × c3-standard-4) microvm (3 × c3-highcpu-4)
durdir_data_baseline 1m smoke (8 MiB glutton-durdir-data) 20 reqs, 0 fails (0.00%) 15 reqs, 0 fails (0.00%)
DeleteWorker scale-down reclaim (3 actors, 256 MiB durable-dir/actor) /var/lib/ate/actors: 0.00 GB -> 1.41 GB (3 dirs) -> 0.00 GB (0 dirs); -0.25 GB/node reclaimed (+0.00 GB vs baseline) /var/lib/ate/actors: 0.00 GB -> 1.64 GB (3 dirs) -> 0.00 GB (0 dirs); -0.25 GB/node reclaimed (+0.00 GB vs baseline)
DeleteActor on paused actor with no worker (worker_pod_uid="") local-checkpoint pruned & /var/lib/ate/actors/<uid> removed (1.75 ms) local-checkpoint pruned & /var/lib/ate/actors/<uid> removed (9.45 ms)

Fixes #1688

  • Tests pass
  • Appropriate changes to documentation are included in the PR

@igooch igooch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ateapi side looks good to me. Taking the actor's lease, and giving up on a node after one timeout, both make sense. I have two questions inline on the keep branch in Terminate, and three asks on the description.

Please fix the PR description so it matches the code.

  • The first paragraph says DeleteWorker clears the actor's AssignedNode. It doesn't, and it shouldn't. The crash keeps AssignedNode so a later delete or revert can still clean up the node, and this PR relies on that when it skips a reclaim.
  • One bullet says delete and revert no longer skip Terminate when worker_pod_uid is empty. Main already sends Terminate in that case. The only change here in ensureAteletTerminated is moving the fallback spec into fallbackWorkloadSpec. Please drop the bullet.
  • Fixes #<issue_number_goes_here> is still the placeholder. This is #1688.

#2871 moves resetActorDirs into the snapshottracker package. Whichever of the two PRs merges second needs a one-line rename in the keep branch.

Comment thread cmd/atelet/main.go Resolved
Comment thread cmd/atelet/main.go Resolved
@sairajp-rewind

Sairaj Pokale (sairajp-rewind) commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Traced both questions against what landed on main over the weekend (#2789, #2795, #2804, #2810, #2819), and I think we should drop keep_local_snapshots and have DeleteWorker send a full Terminate:

  1. Nothing uses the kept snapshot or in-flight upload today: releaseBoundActor holds the actor lease and skips SUSPENDED actors, so every actor it reclaims transitions to CRASHED. While CRASHED, ateapi refuses ReportActorSnapshotState with FailedPrecondition (snapshot_report.go:64-65), which atelet treats as terminal (report.go:345-352). RevertActor then sends a full Terminate (workflow_revert.go:188-195), deletes every PENDING durable snapshot from object storage (workflow_revert.go:209-224), and drops all local snapshots (workflow_revert.go:287).
  2. Q1 (no detach scheduled): Since BeginStart clears DetachAt (lifecycle.go:173-182), keeping the snapshot leaves local-checkpoint/ and sandbox-assets/ on disk indefinitely if the crashed actor is never reverted or deleted. Scheduling a detach here would wait 30m and first upload a snapshot that RevertActor later deletes before freeing local disk.
  3. Q2 (background upload left running): Leaving the upload running is safe for durable-snapshot-gc (since DeleteWorker deletes no objects and RevertActor/DeleteActor send a full Terminate before deleting anything), but pointless because the upload lands in a PENDING prefix that RevertActor or DeleteActor will delete.

Sending a full Terminate from DeleteWorker stops any background upload immediately via Forget, cleans up local-checkpoint/ and the actor dir right away, and drops keep_local_snapshots from TerminateRequest (along with the #2871 resetActorDirs conflict).

Does dropping keep_local_snapshots and running a full Terminate here sound good ? igooch Michelle Au (@msau42)

@igooch

igooch commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Does dropping keep_local_snapshots and running a full Terminate here sound good ?

Agreed, drop keep_local_snapshots and send a full Terminate. Nothing can use the kept snapshot once the actor is CRASHED.

A few asks:

Michelle Au (@msau42), with the full Terminate, an async-suspend snapshot whose successful upload wasn't reported before the crash is lost. That's already true on main, since ateapi refuses the report while CRASHED and revert deletes the PENDING prefix. Is that intended, or should it be recoverable?

@sairajp-rewind

Copy link
Copy Markdown
Collaborator Author

Thanks @igooch! Rebased onto latest main (872ab207) and updated in 9aaa731f:

  • Dropped keep_local_snapshots: reverted the proto/validation changes and removed keep_local_snapshots / hasLocalSnapshots so DeleteWorker (workflow_worker_delete.go:358-366) and Terminate (main.go:1705-1757) always run the full teardown.
  • Repeated Terminate tests: updated TestTerminateCanBeRetried to also checkpoint a local snapshot first and cover both a direct retry (worker_pod_uid set) and DeleteActor/RevertActor after DeleteWorker (worker_pod_uid="") when:
    1. the first Terminate fails partway on a populated volume dir and the second removes what's left, and
    2. the first Terminate finishes and the second succeeds as a no-op, checking that both ActorPath and LocalCheckpointsDir are gone (lifecycle_test.go:1291-1296).
  • Reworded ENOENT comment on readSandboxRecord: updated main.go:1800-1808 (and lifecycle_test.go:1141-1144) to note that Run writes sandbox-assets.json before RunWorkload, Restore writes it after RestoreWorkload, and an earlier Terminate removes it with the actor directory.
  • PR description & #2746: updated the PR description (Fixes #1688, noted #2355 is separate, and applied the line fixes). #2746 hasn't merged to main yet; if it merges first I'll rebase onto it and assert SnapshotsActorPath is gone too.

@msau42

Copy link
Copy Markdown
Collaborator

Michelle Au (@msau42), with the full Terminate, an async-suspend snapshot whose successful upload wasn't reported before the crash is lost. That's already true on main, since ateapi refuses the report while CRASHED and revert deletes the PENDING prefix. Is that intended, or should it be recoverable?

That is fine for now. RevertActor reverts the actor to its last known COMPLETED durable snapshot in ateapi and discards any node-local state and PENDING uploads. An async suspend only guarantees node-local persistence upon return; durability in object storage isn't committed until atelet finishes the background upload and reports it.

If the upload had already completed on atelet and only the report raced with the crash, we could potentially recover that snapshot (e.g., by accepting latest_durable reports while CRASHED or returning snapshot state on Restore/Terminate before Forget), but we can leave that as a future optimization. Having DeleteWorker run a full Terminate here makes sense.

Comment thread cmd/atelet/lifecycle_test.go Resolved
@sairajp-rewind
Sairaj Pokale (sairajp-rewind) force-pushed the durdir-gc-pr1 branch 2 times, most recently from 0a87a28 to 9c076b2 Compare October 21, 2026 16:42
@sairajp-rewind

Copy link
Copy Markdown
Collaborator Author

@igooch Rebased onto #2746 (57443d36) in 9c076b24 and updated both TestTerminateReclaimsStateWithoutALiveSandbox and TestTerminateCanBeRetried to assert that both ateletpath.ActorPath (/var/lib/ate/actors/<uid>) and ateletpath.ActorSnapshotPath (/var/lib/ate/snapshots/<uid>) are removed.

@igooch igooch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ateapi side still looks good to me. I have one behavior fix in Terminate and a few small cleanups inline.

Comment thread cmd/atelet/main.go Resolved

@igooch igooch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 All five comments are addressed, thanks. One test now fails in CI, inline. The description's first bullet also still says Restore writes the record after RestoreWorkload, so please update it to match.

@igooch igooch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Michelle Au (@msau42) could you please review?

Comment thread cmd/atelet/main.go Resolved
Comment thread cmd/atelet/main.go Resolved
@msau42
Michelle Au (msau42) added this pull request to the merge queue Oct 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 21, 2026
A deleted worker pod is deregistered, and its bound actors are marked CRASHED, but their node state is never terminated, so actors/<uid> and snapshots/<uid> stay on the node until the actor is deleted or reverted, if it ever is.

The worker-delete workflow now sends atelet's Terminate before releasing each bound actor. It is best-effort, so an unreachable node cannot wedge deregistration. Each Terminate gets 30s, so a hung unmount cannot run out the delete's deadline. Once one times out or cannot reach atelet, other actors on that node skip Terminate for 30s across subsequent worker deletes and are left to their revert or delete.

Each release holds the actor's lease, so it never runs during a suspend. An actor whose lease is taken is left for a retry: DeleteWorker returns ABORTED, which workersync requeues.

Terminate now skips workload teardown when the worker is gone (missing socket, no sandbox record, or UNAVAILABLE) while still reclaiming the actor's node state. Restore writes the sandbox record before RestoreWorkload, matching Run, so a missing record means no sandbox was started.
@msau42
Michelle Au (msau42) added this pull request to the merge queue Oct 21, 2026
Merged via the queue into agent-substrate:main with commit e8a0719 Oct 21, 2026
15 checks passed
igooch pushed a commit to igooch/substrate that referenced this pull request Oct 21, 2026
## What this PR does and why we need it ?

When detaching an actor (either via background timer detach or explicit
`DetachActor` RPC), `atelet` previously only pruned entries inside
`/var/lib/ate/snapshots/<uid>/local-checkpoint/`, leaving the actor's
snapshot directory (`/var/lib/ate/snapshots/<uid>`) and actor directory
(`/var/lib/ate/actors/<uid>`) on the node indefinitely. Because
`sandbox-assets/` holds hard links into the sandbox asset cache
(`st_nlink > 1`), leaving the actor directory also permanently pinned
sandbox binaries in the cache after detach.

### Changes
- Move `ResetDir`, `ResetActorDirs`, `ResetActorDirsKeepingVolumes`,
`RemoveActorFilesExceptState`, and `RemoveActorDirs` into
`cmd/atelet/internal/actordirs` so `Atelet` and
`snapshottracker.Tracker` share directory cleanup logic (preserving
populated external volume mount directories, removing
`ateletpath.ActorSnapshotPath`, and handling read-only bundle upper
directories via `imagecache.RemoveAllWritable`).
- Update `Tracker.finishDetach` to call
`actordirs.RemoveActorFilesExceptState` (removing
`/var/lib/ate/snapshots/<uid>` and all entries under
`/var/lib/ate/actors/<uid>` except `snapshot-state.json`) while
`opDetach` is active, and mark a detach report due
(`markReportDueLocked`) for both background and foreground `Detach`
calls.
- Keep `snapshot-state.json` (with `ReportDueSince`) on disk until the
detach report finishes so a retried `DetachActor` RPC still sees
`LatestDurable` and a pending detach report survives an `atelet`
restart.
- Remove `snapshot-state.json` and the empty `/var/lib/ate/actors/<uid>`
directory (via `removeDetachedActorState` in `finishReportDueLocked`)
once `!Started && LatestLocal == nil && Uploading == nil` and the
control plane acknowledges the detach report or refuses it with
`NotFound`. On give-up or any other refusal (`Unimplemented`,
`FailedPrecondition`, `InvalidArgument`), `snapshot-state.json` stays on
disk with `ReportDueSince` cleared so `DetachActor` keeps returning
`LatestDurable`.
- Require `--snapshot-report-workers >= 1` in
`validateSnapshotWorkerFlags` so background detach reports are always
sent.
- In `cleanUpAfterCheckpoint`, call
`actordirs.RemoveActorFilesExceptState` instead of
`actordirs.ResetActorDirs` when no local snapshot is kept on the node,
so empty subdirectories are not recreated.
- Document in `atelet.proto` (`ActorSnapshotState.latest_durable` and
`DetachActorResponse.state`) that `DetachActor` queues a background
detach report and forgets the actor once acknowledged.

> **Merge order**: Land after agent-substrate#2797 so `Terminate` tolerates a missing
`sandbox-assets.json` after `cleanUpAfterCheckpoint` calls
`RemoveActorFilesExceptState`.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

atelet: actor state directories are never reclaimed when a worker pod goes away

4 participants