Skip to content

Crash a paused actor on resume when its node is gone - #3010

Merged
Zoe Zhao (zoez7) merged 6 commits into
agent-substrate:mainfrom
igooch:resume-node-gone
Oct 21, 2026
Merged

Zoe Zhao (zoez7) merged 6 commits into
agent-substrate:mainfrom
igooch:resume-node-gone

Conversation

@igooch

@igooch igooch commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

#2883 lets resume forget an unreachable node when a paused actor's latest snapshot is durable. When that upload is still PENDING, the actor stays pinned. If the node was deleted, every resume returns Unavailable, and nothing says the snapshot is lost.

Now, when resume finds no atelet on the pinned node and NodeGone reports the Node object deleted, it reads the actor again. If the upload was recorded during the lookup, it detaches as usual. Otherwise it crashes the actor. The crash keeps assigned_node and the PENDING snapshot. RevertActor then skips the terminate, deletes the pending objects, and returns the actor to its last completed durable snapshot. A node that still exists, or a failed Node lookup, keeps the current retry.

There are two known limits. The check runs only after resume finds room on another node, so with no room anywhere resume still returns RESOURCE_EXHAUSTED. And a Node object deleted while the machine keeps running crashes the actor even though its checkpoint is still on that machine. That's the same trade #2486 makes.

A PENDING copy may already be fully uploaded if atelet died before reporting it. Resume still crashes the actor, and revert deletes that copy. Recovering it needs ate-api-server to check for the manifest, which is a follow-up.

No proto changes. ResumeActor can now crash the actor and return FAILED_PRECONDITION in this case, and docs/api-guide.md says so.

Part of #2470

Supersedes #3005. Its "upload completes during lookup" regression case is adapted here.

AI use: Claude Code helped write the change and tests. I reviewed them.

  • Tests pass
  • Docs updated

@igooch
igooch marked this pull request as ready for review October 20, 2026 23:46

// latestLocalNotDurable reports whether the actor's latest local snapshot has
// no completed durable copy.
func latestLocalNotDurable(actor *ateapipb.Actor) bool {

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.

One edge case is: although the latest snapshot was not recorded as durable, atelet has uploaded the snapshots already (and failed to call ateapi to ReportActorSnapshotStatus)
Wdyt of optimistically try to resume the actor on a different node, atelet can try to download the snapshot object, if atelet gets a not found error, we then crash the actor.

substrate/cmd/atelet/main.go

Lines 1615 to 1639 in d04d280

var sandboxRec *sandboxAssetsRecord
localRec, localErr := readLocalSnapshot(localDir)
durable := req.GetSnapshot().GetObject()
if localErr == nil {
directLocal = true
checkpointDir = localDir
sandboxRec = localRec
} else {
if errors.Is(localErr, errUnusableLocalSnapshot) {
// The next checkpoint or terminate prunes the unusable copy.
slog.WarnContext(ctx, "Local snapshot is unusable; restoring from object storage",
slog.String("local_dir", localDir), slog.Any("err", localErr))
}
manifest, err := s.fetchManifestCached(ctx, actor, durable.GetSnapshotUri(), snapshotCacheMode(durable, cacheModeLink))
if status.Code(err) == codes.NotFound {
return nil, apierror.NotFound("snapshot %q has no usable local copy and no uploaded copy: %w",
req.GetSnapshot().GetSnapshotUuid(), localErr)
}
if err != nil {
return nil, fmt.Errorf("while fetching snapshot manifest: %w", err)
}
if sandboxRec, err = unmarshalSandboxRecord(manifest); err != nil {
return nil, fmt.Errorf("while unmarshalling sandbox record: %w", err)
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, this case is real. The manifest goes last, so a PENDING copy with a manifest is complete, and revert would delete it as abandoned. I'd like to do it as a follow-up PR, though. ate-api-server can't check for the manifest today, because ControlProvider only has CleanupSnapshot and CopySnapshot. So it needs either a new RPC there, or a resume that restores the PENDING copy and turns atelet's NotFound into this crash. Which would you prefer? Until then, this PR still beats an actor that's stuck forever. The crash keeps the PENDING record, so nothing is deleted until someone reverts.

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.

I think "a resume that restores the PENDING copy and turns atelet's NotFound into this crash" is simpler

@dims

Copy link
Copy Markdown
Collaborator

some overlap with #3005?

@eren23

Copy link
Copy Markdown
Contributor

Thanks again igooch for reviewing #2486 and picking up the resume follow-up; I'm closing #3005 in favor of this one. One case from that work may be useful here: the latest upload can become COMPLETED while NodeGone is in flight. Could we reload the actor and recheck that the latest snapshot is still incomplete before committing the crash, taking the existing detach path if it completed? crashActor reloads the actor but doesn't recheck snapshot durability, so that timing can still crash an actor with a completed snapshot. The "upload completes during lookup" regression case in #3005 covers this and is available to reuse.

@igooch

igooch commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, good catch. Folded into ada3183. Resume now reads the actor again after NodeGone. If the latest snapshot became durable, it takes the detach path. Otherwise it crashes only the version it checked, so a concurrent write aborts the resume instead. I adapted your "upload completes during lookup" case as a row in TestDetachFromFullNode.

@zoez7

Copy link
Copy Markdown
Collaborator

LGTM. Please fix the CI failures.

A paused actor whose node was deleted before atelet uploaded its latest
snapshot stayed pinned to that node. Every resume returned Unavailable,
and nothing told the caller the snapshot was lost.

When resume finds no atelet on the node and the Node object is gone, it
now crashes the actor. Revert then returns it to its last completed
durable snapshot. A node that still exists, or a failed Node lookup,
keeps the current retry.
Resume crashed the actor on a deleted node whenever it could not detach
without atelet. That also covered an actor with a crash record whose
latest snapshot was durable, and recorded a false crash. Resume now
checks that the latest local snapshot has no completed durable copy.

Move the doc sentence to a ResumeActor errors bullet, note the capacity
precondition, and qualify the detach error rule.
When the caller went away or the lease was lost while resume checked
whether the node still exists, it returned Unavailable. It now returns
the context error, as the DetachActor call below it already does.

Note in the test that a SUSPENDED actor never carries a crash record.
An upload recorded while resume checked the Node could still crash the
actor, since crashActor reloads it without rechecking durability. Resume
now reads the actor again after the lookup. If its latest snapshot became
durable, it detaches. Otherwise it crashes only the version it checked,
and a concurrent write aborts the resume for a retry.

The regression case is adapted from eren23's closed alternative.
crashActorVersion assigned to an err declared by the GetActor call that
moved to crashActor. That only compiled because releaseWorker declared
err as well. Declare it on the UpdateActor call instead.
The docs no longer speak of paused actors. Say a suspended actor loses
its local snapshot instead.
@zoez7
Zoe Zhao (zoez7) added this pull request to the merge queue Oct 21, 2026
Merged via the queue into agent-substrate:main with commit dff068d Oct 21, 2026
15 checks passed
Sneha-at (Sneha-at) pushed a commit to Sneha-at/substrate that referenced this pull request Oct 22, 2026
…strate#3094)

Part of agent-substrate#2470, follow-up from
[3010](agent-substrate#3010 (comment)).

atelet can upload a suspended actor's latest local snapshot and die
before it reports the upload. If the node is then deleted, the upload is
complete but still recorded `PENDING`. Resume crashed the actor, and a
revert then deleted the upload.

Resume now releases the deleted node and restores the snapshot on
another node. atelet there has no local copy, so it reads the upload.
The manifest is written last, so a missing or partial upload returns
`NOT_FOUND`.

- `recordGoneNode` clears `assigned_node`. The snapshot keeps its old
locality, so `snapshotToResume` still picks it.
- A `NOT_FOUND` from that restore crashes the actor with the existing
gone-node message.
- `finalizeRunning` records the restored snapshot `COMPLETED` and clears
its locality.
- `canRelaxRuntime` lets the snapshot fall back to another runtime, as a
durable snapshot can.

No proto changes.

Resume claims a worker before it knows the upload exists, so a missing
upload costs one placement. A revert still deletes an unreported upload,
so the snapshot is lost if its restore fails for another reason or the
actor is reverted before it resumes.

AI use: Claude Code helped write the change and tests. I reviewed them.

- [ ] Tests pass
- [x] Docs updated
@igooch
igooch deleted the resume-node-gone branch October 27, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants