Skip to content

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

Closed

Description

What happened?

Nothing removes /var/lib/ateom-gvisor/actors/<actor-UID>/ when the worker pod hosting the actor goes away. The directory holds the actor's durable-dir contents, checkpoint/restore state and bundle overlay — for a durable-dir workload that is gigabytes per actor, not the few idle bytes of #1677's socket dirs. The directories persist for the life of the node.

This surfaced as node disk climbing during a durable-dir benchmark and never falling afterwards, eventually reaching ~90% and getting atelet evicted.

Measured on a 3-node dev cluster (c3-standard-4, 100 GB pd-balanced) against a0d306ee. Workload: 15 concurrent clients, each driving an actor that writes a 1 GiB file into its durable dir and then suspends and resumes repeatedly, for 10 minutes.

Metric Measurement / Impact
orphaned actor dirs 23 (19 with payload, 4 empty template actors)
retained under actors/ 27.10 GB
per actor ~1.4 GB
reclaimed by scaling the WorkerPool to 0 0 bytes
reclaimed by --delete-all (full uninstall) 0 bytes
reclaimed by a subsequent reinstall 0 bytes — fresh atelets start on top of it

Reproduced on both sandbox classes; they share ateompath.BasePath, so the layout and the leak are identical. An equivalent micro-VM run left 18.33 GB across 16 dirs, likewise unchanged by scaling to 0.

Note

#1654 ("Remove an actor's directory when atelet terminates it") does not cover this. All figures above are from a build containing it. It removes the directory when Terminate runs; the defect is that Terminate is never reached once the pod is gone.

No delete is required to trigger this — an actor being de-scheduled from a node is enough.

Mechanism

As far as I can tell, two independent gaps compose:

  1. The worker-delete path never contacts the node. WorkerPoolSyncer.reconcile notices the pod is gone and calls Control API DeleteWorker. WorkerWorkflow.ensureBoundActorsReleased → releaseBoundActor moves each bound actor to CRASHED and clears its pod pointers, but nothing asks atelet to terminate it. After the worker record is deleted, the actor names no worker, no worker names a node, and nothing ever revisits the actor UID — the directory is orphaned permanently.

  2. The actor-delete path gives up when the pod is gone. ActorWorkflow.ensureAteletTerminated dials DialForWorker(ns, pod) and returns nil on ErrWorkerPodNotFound. That is the correct shortcut for a pod that legitimately vanished, but it is also exactly how the state comes to be orphaned.

Even when a Terminate is issued, it fails before reaching the directory cleanup: the teardown dials the ateom socket first, and a vanished ateom (connect: no such file or directory, connection refused) aborts the call ahead of the unmounts and the directory removal.

Expected Behavior

Actor node state is reclaimed once no actor can use it again. Two complementary mechanisms, mirroring the split proposed in #1677:

  1. Prompt reclaim on the delete path. The worker-delete workflow is the last moment anything still knows which node holds the state, so it should tell that node to reclaim before releasing the record. Reaching the atelet requires dialing by node rather than by pod, since the pod is precisely what has disappeared. This must be best-effort: an unreachable node cannot be allowed to wedge worker deregistration.

    Terminate also needs to tolerate a vanished ateom — skip the sandbox teardown and still run the unmounts and directory cleanup — otherwise the call fails before doing the part that matters. A reachable ateom that rejects the call should still fail: that sandbox is live.

  2. A sweep as backstop. Evictions, node crashes and uninstalls produce orphans that no delete path will ever visit. A periodic atelet sweep reconciling actors/ against the actors the control plane places on that node covers them. It must fail closed — if the live set cannot be read in full, delete nothing — and must skip actors holding a local pause snapshot, which is node-pinned state whose deletion is unrecoverable.

Neither covers the graceful path. Reclaiming during Checkpoint as well would keep steady state near zero and shrink the sweep's caseload; that is a separate change.

Steps to Reproduce

  1. Install ate-system and deploy a WorkerPool with 15 workers.
  2. Record the node state:
    du -sb /var/lib/ateom-gvisor/* && ls -1 /var/lib/ateom-gvisor/actors | wc -l
  3. Drive 15 concurrent actors for ~10 minutes, each writing a 1 GiB file into its durable dir and suspending/resuming between iterations.
  4. Record the node state again — expect several GB per node under actors/.
  5. Scale the WorkerPool to 0, wait for the pods to drain, and record again.

Every actor directory and every byte is still present. The same holds after hack/install-ate.sh --delete-all and after a fresh reinstall.

Sandbox Runtime

Both / Runtime Agnostic — gVisor and micro-VM share ateompath.BasePath.

Agent Substrate Version / Commit SHA

main @ a0d306ee

Additional Context

Not fixed by #1678. That PR adds defer os.RemoveAll(ateomDir) to the two ateom runtime mains, which covers ateoms/<pod-UID>/ only. actors/<uid>/ is written and owned by atelet, not by ateom, and is not touched by that defer — nor could it be, since the ungraceful exits that strand actor state are exactly the cases where a defer does not run. Same pattern, different directory, very different blast radius:

#1677 / #1678 this issue
path ateoms/<worker-pod-UID>/ actors/<actor-UID>/
written by ateom atelet
contents one dead ateom.sock durable dir, checkpoints, bundle overlay
size a few idle bytes ~1.4 GB per actor
harm dial+probe per stats sweep, cachedPools retention node disk pressure, atelet eviction
trigger any pool rollout worker pod deleted or de-scheduled with actors bound

#1677's proposed janitor (cmd/atelet/ateomgc.go) is close in shape to the sweep described above — both modeled on imagegc.go, both node-scoped, fail-closed, with a min-age. If both land it is worth deciding whether they should be one loop.

Documentation gap. There is no written description of atelet's on-disk layout or how it does bookkeeping, which is part of why the ownership and lifetime of these directories is unclear.

Confirmation

  • I have searched existing issues and verified that this is not a duplicate.
  • I have verified that this issue occurs on the latest commit on main.

Activity

  1. sairajp-rewind commented on Sep 17, 2026

    @sairajp-rewind
    CollaboratorAuthor
  2. ptone commented on Oct 1, 2026

    @ptone

    Here is additional reproduction data and code-read findings from multi-tenant testing on Substrate d277088b regarding leaked host actor directories when an actor is force-cleaned via worker pod deletion:

    Summary

    The only way to clear an actor stuck in DELETING is to delete its worker pod, wait for CRASHED, and call DeleteActor again. That path never runs atelet's actor-directory removal. The actor's host directory under /var/lib/ateom-gvisor/actors/<uid>/ stays on the node, including a populated DurableDir workspace. We found no reconcile of these directories at d277088b.

    Impact

    • Who hits it: any operator who force-cleans a stuck actor. Today that is the workaround for actors stuck in DELETING.
    • What breaks:
      • Node disk fills over time.
      • Workspace contents, which may be sensitive (source code, credentials written by the workload), persist on the host after the actor is gone.
    • Severity: Low / Proposed priority: P2. Less urgent than the stuck-delete defects that force operators down this path, but it leaves tenant data on nodes. The manual cleanup needs host access.

    Environment

    • Substrate d277088b (v1alpha).
    • GKE 1.36.x.
    • gVisor sandbox class, unprivileged WorkerPool workers.

    Steps to reproduce / evidence

    1. Get an actor stuck in DELETING (verified live). For example, delete an actor whose non-root workload wrote to its DurableDir (see atelet drops all capabilities, so it cannot remove a DurableDir populated by a non-root workload #2034).
    2. Force-clean it (verified live):
      • kubectl delete pod <worker-pod>;
      • wait for the actor to reach ACTOR_STATE_CRASHED;
      • call DeleteActor with any_state.
    3. Check the node (verified live). On the node (via a debug pod), /var/lib/ateom-gvisor/actors/<uid>/ still exists, including the populated durable-dir/workspace. This was seen for 3 actors.
    4. Why the removal is skipped (code-read at d277088b). ensureAteletTerminated (cmd/ateapi/internal/controlapi/workflow_delete.go:121-140) skips the atelet terminate call, which is what removes the actor's host directory, when the actor has no worker assignment or when its worker no longer hosts it. When the worker pod is deleted, the worker-gone handling sets the actor to CRASHED and clears its worker assignment (cmd/ateapi/internal/controlapi/workflow_worker_delete.go:215-217), so the next DeleteActor takes the no-assignment branch (workflow_delete.go:122-125) and skips the terminate call. We found no reconcile of /var/lib/ateom-gvisor/actors/ in atelet or ateom at d277088b.

    Expected vs actual

    • Expected: once an actor is deleted, whatever the path, its host directories are removed, either right away or by a later reconcile.
    • Actual: after force-clean, the host actor directories stay on the node indefinitely.

    Workaround

    An operator can remove the orphaned /var/lib/ateom-gvisor/actors/<uid>/ directories by hand, from a privileged debug pod on the node, after confirming that the actor no longer exists in ateapi.

    Proposed behaviour

    • On atelet start, and periodically, reconcile /var/lib/ateom-gvisor/actors/ against the actors assigned to that node's workers, and remove directories for actors that no longer exist.
    • Alternatively, run the directory removal when a CRASHED actor is deleted.
  3. aditya-shantanu commented on Oct 7, 2026

    @aditya-shantanu
    Collaborator

    Field data from a 3 TB c3-standard-192-metal node: 9,082 orphaned actor dirs / 894 GiB after one PauseActor ladder, and DiskPressure + atelet eviction within one morning (18,404 dirs / 2.79 TB) with 1.5 GiB actors. Details and code citations at 584d031 in #641 (comment).

  4. added 3 commits that reference this issue on Oct 20, 2026
    9aaa731
    0a87a28
    9c076b2
  5. added a commit that references this issue on Oct 21, 2026
    e8a0719
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/nodekind/bugSomething isn't working / bugfixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions