Skip to content

DeleteActor never cleans the actor's node directory #641

Closed

Description

@igooch

Expected Behavior

Deleting an actor releases everything the actor held, including its node-local state: after
DeleteActor, /var/lib/ateom-gvisor/actors/<uid>/ should be removed (or at minimum contain
nothing that marks the actor as still placed on the node).

This matters doubly once #463 Phase 2 lands: the image-cache GC's root set is the per-bundle
rootfs-overlay.json specs — "spec on disk" means "actor placed here; do not evict its image."
A deleted actor should not root an image.

Actual Behavior

DeleteActor is a pure control-plane operation: it deletes the store record and cleans up volumes
(cmd/ateapi/internal/controlapi/delete_actor.go), but nothing ever tells the node. atelet's RPC
surface is only Run/Checkpoint/Restore, and resetActorDirs runs lazily — at the start of the
next Run/Restore or the end of a Checkpoint for the same actorUID. A deleted actor's UID
never comes back, so /var/lib/ateom-gvisor/actors/<uid>/ persists until the node is replaced.

What leaks depends on the state the actor was deleted from (the store allows SUSPENDED or CRASHED,
cmd/ateapi/internal/store/ateredis/ateredis.go):

  • Deleted from CRASHED: a crash means no Checkpoint ran after the last Run/Restore, so the
    entire bundle dir survives — the actor's writable upper/ layer and the
    bundles/<container>/rootfs-overlay.json spec. Post-Design: on-disk, layer-deduplicated OCI image cache for atelet #463-Phase-2, that leaked spec is
    indistinguishable from a live one, so the dead actor's image is rooted forever: GC logs it as
    in-use on every pass and it can never be evicted, regardless of disk pressure. Hit twice during
    Phase 2 GC validation. The GC is behaving as designed (ambiguity must resolve toward retention —
    evicting a mounted lowerdir is catastrophic); the defect is that deletion leaves the "I am placed
    here" marker behind. Filed separately from Design: on-disk, layer-deduplicated OCI image cache for atelet #463 so this over-retention isn't mistaken for a GC bug.
  • Deleted after a clean suspend (the common path): the bundle was wiped at Checkpoint, but the
    ActorPath skeleton, sandbox-assets.json, and any local-checkpoint/ contents are left
    behind.

Steps to Reproduce the Problem

  1. On a Kind cluster with substrate installed, run an actor, then crash it (e.g. kill its
    workload) so it lands in CRASHED.
  2. kubectl ate delete actor <name> — succeeds (CRASHED satisfies the delete precondition).
  3. On the node: docker exec <kind-node> ls /var/lib/ateom-gvisor/actors/<uid>/bundles/<container>/
    → rootfs-overlay.json, upper/, work/, rootfs/ still present, forever.
  4. (Variant for the suspended path) Suspend an actor, delete it, and observe local-checkpoint/
    and sandbox-assets.json survive under the actor's dir.
  5. (Post-Design: on-disk, layer-deduplicated OCI image cache for atelet #463-Phase-2) Put the node under disk pressure: GC logs the deleted actor's image as
    rooted on every pass; it is never evicted.

Anything else we need to know?:

The fix belongs in the deletion path, not in GC: something should remove ActorPath(uid) when the
actor is deleted. Cleanup should key on deletion (the actor record no longer exists), not on
the CRASHED transition — whether a crashed actor should be recoverable from its node-local state
is an open design question on #292, and deletion-keyed cleanup is safe under either answer. Two
candidate approaches:

  • Control plane calls atelet at delete time (a new teardown RPC). Direct, but the control plane
    must still know — and be able to reach — the actor's last node when the delete happens.
  • atelet reconciles ActorsDir against the control plane (periodic sweep removing dirs for
    actors that no longer exist). Self-healing — it also catches missed deletes and actors crashed on
    dead workers ([P3] releaseActorOnDeadWorker does not clear AteomPodUid — diverges from crashActor cleanup #618) — but it gives atelet a control-plane client it deliberately doesn't have
    today.

A full "delete all" of the directory is safe: nothing can reference a deleted actor's node-local
state, because the actor record that would point to it is gone.

Related: #615 (node-side debris on an abnormal path), #172 (local snapshot storage management
umbrella).

Specifications

Activity

  1. added
    kind/bugSomething isn't working / bugfixes
    prio/P2real, but not urgent
    prio/P1Important but not critical
    and removed
    prio/P2real, but not urgent
    on Jul 30, 2026
  2. igooch commented on Aug 12, 2026

    @igooch
    CollaboratorAuthor

    Hold on this until #788 is merged.

  3. igooch commented on Sep 4, 2026

    @igooch
    CollaboratorAuthor

    #788 (merged 2026-08-20) fixed the force-delete path; scoping the rest.

    #788 implemented approach 1 (a teardown RPC): atelet.Terminate → ateom.TerminateWorkload → resetActorDirs, wired into the delete workflow. resetActorDirs does RemoveAllWritable on the bundle dir, removing rootfs-overlay.json — so the GC over-retention concern is closed when the RPC fires.

    But it only fires for a live, reachable worker. ensureAteletTerminated skips when WorkerAssignment == nil or the worker pod is gone — and both states a normal delete can touch clear the assignment first (suspend in FinalizeSuspended, crash in crash.go). So the two paths originally reported here still leak: delete-from-CRASHED and delete-after-SUSPEND. The dead-worker variant (#618) skips too.

    Remaining scope: clean node state when there is no live worker to call — approach 2 (atelet sweep of ActorsDir vs. the control plane, or control plane targeting the last-known node).

    Note: even when terminate runs, resetActorDirs recreates subdirs (MkdirAll) and re-writes sandbox-assets.json rather than deleting ActorPath(uid). Fine if the bar is "nothing roots an image"; a separate sub-item if the bar is literally "ActorPath gone."

    Related: #618, #664.

  4. igooch commented on Sep 4, 2026

    @igooch
    CollaboratorAuthor

    Small correction to the "open question" in the description: approach 2 (an atelet ActorsDir reconcile) no longer requires giving atelet a control-plane client it lacks — on main atelet already dials ateapi and holds ActorIdentityClient + WorkerServiceClient (credential broker / worker-capacity). The list RPCs a sweep needs already exist too: ListWorkerActorAssignments (actors assigned to this worker) and ListActors/GetActor (existence). So the remaining work is just the periodic loop: enumerate ActorsDir, ask ateapi which UIDs still exist / are still assigned here, resetActorDirs (or remove ActorPath(uid)) for the rest. The stated cost of approach 2 is stale.

  5. zlammerts-svg commented on Sep 23, 2026

    @zlammerts-svg
    Collaborator

    Hi Ivy, are you still working on this one? I can see that both #618 and #664 are closed now? What work is left to close this issue?

  6. igooch commented on Sep 24, 2026

    @igooch
    CollaboratorAuthor

    Zheng Lammerts (@zlammerts-svg) thanks for the follow up. Left comment in #1706 if they want to include the remaining scope of this issue in that PR.

  7. aditya-shantanu commented on Oct 7, 2026

    @aditya-shantanu
    Collaborator

    Adding a field data point from scale testing, because this goes beyond slow inode growth: it takes a 3 TB boot disk to DiskPressure in one morning once actors carry real memory.

    Environment: GKE, c3-standard-192-metal, COS, 3 TB Hyperdisk boot disk. Substrate perf/resume-latency @ 584d0318; the control-plane and atelet code paths involved are unchanged from main (see citations).

    Observations

    • 2026-10-05, after a PauseActor ladder with 256 MiB actors: 9,082 orphaned /var/lib/ate/actors/<uid> dirs, 894 GiB.
    • 2026-10-06/07, 1.5 GiB personal-assistant actors (~1 GiB resident; pause checkpoint ~1.4 GiB per actor on microVM, ~1 GiB on gVisor): boot disk went from 69 % to DiskPressure within one morning of create/pause/delete cycles (18,404 orphaned dirs, 2.79 TB at the end). kubelet evicted atelet and the rest of ate-system on that node; the node was unusable until the directories were removed by hand from a privileged node-critical debug pod.

    Code path confirming the mechanism (at 584d031, same as main)

    • The only node-local reclaimer is atelet Terminate -> pruneLocalCheckpoints -> removeActorDirs (cmd/atelet/main.go:1446-1453, removeActorDirs at :2016-2031, added in Remove an actor's directory when atelet terminates it #1654).
    • ensureAteletTerminated returns early when status.worker_assignment is nil (cmd/ateapi/internal/controlapi/workflow_delete.go:128-132) or when the worker no longer hosts the actor (:134-148).
    • Pause (workflow_pause.go:236 releases the worker, :289 nils the assignment), suspend (workflow_suspend.go:373 releaseWorker, :416 nils the assignment) and crash (crash.go:110-139) all clear the assignment in the commit that enters PAUSED/SUSPENDED/CRASHED, so every deletable actor is deleted without any component touching its directory.
    • No startup sweep or periodic GC of actors/; the only periodic reader is the image-cache GC (cmd/atelet/imagegc.go:82), which treats present dirs as pinning layers, so the leak also blocks layer eviction.
    • docs/architecture.md:492 ("After deletion, the state of the actor (i.e., memory+disk snapshots) is garbage collected") holds only for the GCS prefix. docs/dev/scale-test-readiness.md:71,346,455 tracks this as [Bug]: ateapi: a plain actor delete never tells the node, so the actor's state directory is never reclaimed #1757/Improve ate-apiserver / atelet contract #1778.

    Branch-only aggravation (not on main, FYI for anyone measuring on perf/resume-latency): commit 0ccab235 adds --retain-uploaded-snapshots (default true, cmd/atelet/main.go:108; help text says "removed on terminate") which moves the uncompressed snapshot to retained-snapshot/ (cmd/atelet/internal/ateletpath/ateletpath.go:74-77) after a suspend upload. Since Terminate is never reached on the delete-from-SUSPENDED path, suspend-then-delete also leaves the full memory image on the node. Mitigation there: --retain-uploaded-snapshots=false.

    Impact: any pause-path or long-running deployment leaks the full per-actor memory image per deleted actor until the node dies, and the leaked dirs pin image-cache layers. With 1 GiB+ actors this is hours, not weeks.

    Proposed fix (consistent with the remaining-scope comment above and PR #1706):

    1. In DeleteActor, when there is no assignment, route Terminate / a reclaim RPC to the node(s) in status.local_snapshot_info.node_vms_with_local_snapshots (paused) and to the last-known node (suspended/crashed), best-effort so an unreachable node cannot wedge the delete.
    2. An atelet startup + periodic fail-closed sweep of /var/lib/ate/actors/<uid> for UIDs the control plane no longer knows (atelet already holds ateapi clients; ListWorkerActorAssignments / GetActor suffice).
    3. Remove the stale local pause checkpoint after a successful restore (existing TODO(dberkov) at cmd/atelet/main.go:1244).

    Short-term mitigations we are using: delete actors from RUNNING (assignment present, so Terminate fires), and on the branch --retain-uploaded-snapshots=false.

    Found while running the agents-tco host-fill measurements (repo adityashantanu/substrate-agents-tco). Happy to attach the node dmesg / df / du captures if useful. Cross-ref: #1757, #1688, PR #1706.

  8. zlammerts-svg commented on Oct 16, 2026

    @zlammerts-svg
    Collaborator

    igooch Based on the latest comment, are we waiting for #1706 to be merged to close this one? Thanks!

  9. igooch commented on Oct 16, 2026

    @igooch
    CollaboratorAuthor

    #1706 was partially blocked based on changes to the v2 lifecycle. We asked that the 1706 PR will be broken up into a couple of PRs, and one of the future PRs, not yet drafted, will resolve this issue.

  10. igooch commented on Oct 21, 2026

    @igooch
    CollaboratorAuthor

    🤖 Closing, as main now reclaims an actor's node state in these cases.

    Two smaller gaps remain, tracked in #3098.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area/nodekind/bugSomething isn't working / bugfixesprio/P1Important but not critical

Type

No type

Projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions