Skip to content

atelet: detach Checkpoint from caller cancellation once underway - #2602

Merged
Dmitry Berkovich (dberkov) merged 1 commit into
agent-substrate:mainfrom
chw120:atelet-local-checkpoint-idempotent
Oct 22, 2026
Merged

Dmitry Berkovich (dberkov) merged 1 commit into
agent-substrate:mainfrom
chw120:atelet-local-checkpoint-idempotent

Conversation

@chw120

@chw120 Chenyi Wang (chw120) commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Once a checkpoint is underway on atelet, canceling it mid-flight (for example, when a caller with a shorter deadline times out and retries) kills runsc checkpoint while the Sentry continues stopping the workload, leaving the checkpoint incomplete and causing the retry to fail.

Detach in-flight checkpoints from caller cancellation once past lockActor and BeginCheckpoint, keeping the later of the caller's deadline and defaultCheckpointTimeout (5m) so a hung worker or upload cannot hold the per-actor lock indefinitely without shortening a longer caller deadline. When a caller times out and retries, the original attempt finishes and commits under lockActor, and the queued retry answers from the committed snapshot (#2849).

Part of #2836

AI usage: Used Gemini to help trace the checkpoint cancellation flow, draft the fix, and write unit tests.

  • Tests pass

@chw120
Chenyi Wang (chw120) force-pushed the atelet-local-checkpoint-idempotent branch 2 times, most recently from 424cfb7 to 160c01a Compare October 14, 2026 01:05
@chw120 Chenyi Wang (chw120) changed the title atelet: make local actor pause checkpoints idempotent on retry atelet: make local Checkpoint idempotent and serialize it per actor Oct 14, 2026
@chw120
Chenyi Wang (chw120) force-pushed the atelet-local-checkpoint-idempotent branch 2 times, most recently from 5cc0675 to 60152c9 Compare October 14, 2026 01:08

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This shape looks right to me - the two failure modes you describe are the classic retry-after-timeout pair, and serializing per actor plus using the manifest as a commit marker is a good way to handle them.

One thing I checked before raising it: the fast path returns an empty CheckpointResponse, which at first looked like a retry getting a degenerate answer. It isn't - the normal path at main.go:842 returns the same empty response, and the proto documents state as deliberately unset for now. So no issue there.

Two things I did wonder about.

The idempotent fast path is gated on UPLOAD_MODE_DEFERRED. That makes sense for the manifest check itself, since the proto says IMMEDIATE keeps no local copy, so there'd be no manifest.json to find. But that leaves IMMEDIATE with only half the protection: the lock still stops the overlap race, while the "first attempt already finished and cleaned up" case would presumably still fail with sandbox.json: no such file or directory. Is that deliberate, or just out of scope here?

Smaller one: if rec, err := unmarshalSandboxRecord(manifest); err == nil drops the error. A manifest that exists but doesn't parse would silently leave op.sandboxClass empty, so the checkpoint metric ends up unattributed with nothing explaining why. Might be worth a log line on that branch.

@chw120
Chenyi Wang (chw120) force-pushed the atelet-local-checkpoint-idempotent branch from 60152c9 to e99e1e5 Compare October 14, 2026 17:56
@chw120

Copy link
Copy Markdown
Collaborator Author

This shape looks right to me - the two failure modes you describe are the classic retry-after-timeout pair, and serializing per actor plus using the manifest as a commit marker is a good way to handle them.

One thing I checked before raising it: the fast path returns an empty CheckpointResponse, which at first looked like a retry getting a degenerate answer. It isn't - the normal path at main.go:842 returns the same empty response, and the proto documents state as deliberately unset for now. So no issue there.

Two things I did wonder about.

The idempotent fast path is gated on UPLOAD_MODE_DEFERRED. That makes sense for the manifest check itself, since the proto says IMMEDIATE keeps no local copy, so there'd be no manifest.json to find. But that leaves IMMEDIATE with only half the protection: the lock still stops the overlap race, while the "first attempt already finished and cleaned up" case would presumably still fail with sandbox.json: no such file or directory. Is that deliberate, or just out of scope here?

Smaller one: if rec, err := unmarshalSandboxRecord(manifest); err == nil drops the error. A manifest that exists but doesn't parse would silently leave op.sandboxClass empty, so the checkpoint metric ends up unattributed with nothing explaining why. Might be worth a log line on that branch.

Thanks for the review!

  1. UPLOAD_MODE_IMMEDIATE: Yes, targeting on UPLOAD_MODE_DEFERRED for now. In atelet.proto, UPLOAD_MODE_IMMEDIATE is defined as "Upload the snapshot synchronously before Checkpoint returns, while keeping the local copy" with TODO: Temporarily this deletes the local snapshot after the upload. Once that TODO is resolved and IMMEDIATE keeps its local copy too, this same local manifest.json check can cover both modes.
  2. unmarshalSandboxRecord error: Good catch — added a slog.WarnContext when unmarshaling fails.

@chw120
Chenyi Wang (chw120) force-pushed the atelet-local-checkpoint-idempotent branch 4 times, most recently from 2605c44 to c092950 Compare October 16, 2026 05:23
@chw120 Chenyi Wang (chw120) changed the title atelet: make local Checkpoint idempotent and serialize it per actor atelet: make Checkpoint idempotent and serialize it per actor Oct 16, 2026
@chw120

Copy link
Copy Markdown
Collaborator Author

This shape looks right to me - the two failure modes you describe are the classic retry-after-timeout pair, and serializing per actor plus using the manifest as a commit marker is a good way to handle them.
One thing I checked before raising it: the fast path returns an empty CheckpointResponse, which at first looked like a retry getting a degenerate answer. It isn't - the normal path at main.go:842 returns the same empty response, and the proto documents state as deliberately unset for now. So no issue there.
Two things I did wonder about.
The idempotent fast path is gated on UPLOAD_MODE_DEFERRED. That makes sense for the manifest check itself, since the proto says IMMEDIATE keeps no local copy, so there'd be no manifest.json to find. But that leaves IMMEDIATE with only half the protection: the lock still stops the overlap race, while the "first attempt already finished and cleaned up" case would presumably still fail with sandbox.json: no such file or directory. Is that deliberate, or just out of scope here?
Smaller one: if rec, err := unmarshalSandboxRecord(manifest); err == nil drops the error. A manifest that exists but doesn't parse would silently leave op.sandboxClass empty, so the checkpoint metric ends up unattributed with nothing explaining why. Might be worth a log line on that branch.

Thanks for the review!

  1. UPLOAD_MODE_IMMEDIATE: Yes, targeting on UPLOAD_MODE_DEFERRED for now. In atelet.proto, UPLOAD_MODE_IMMEDIATE is defined as "Upload the snapshot synchronously before Checkpoint returns, while keeping the local copy" with TODO: Temporarily this deletes the local snapshot after the upload. Once that TODO is resolved and IMMEDIATE keeps its local copy too, this same local manifest.json check can cover both modes.
  2. unmarshalSandboxRecord error: Good catch — added a slog.WarnContext when unmarshaling fails.

Synced with #2677. The path now checks the snapshot tracker state instead of manifest.json, so it covers IMMEDIATE too.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both answered well, thanks.

The IMMEDIATE reasoning makes sense — the proto's TODO about deleting the local snapshot after upload is what made the manifest check inapplicable there, not an oversight. And syncing with #2677 closes it anyway: checking the tracker state rather than manifest.json covers both modes, and I can see the retry test now runs under UPLOAD_MODE_IMMEDIATE as well.

Good catch adding the WarnContext on the unmarshal failure — that was the bit that would have been silent.

Nothing further from me.

@chw120
Chenyi Wang (chw120) force-pushed the atelet-local-checkpoint-idempotent branch from 27c217f to 3074738 Compare October 20, 2026 22:47
@chw120 Chenyi Wang (chw120) changed the title atelet: make Checkpoint idempotent and serialize it per actor atelet: detach Checkpoint from caller cancellation once underway Oct 20, 2026
@chw120
Chenyi Wang (chw120) force-pushed the atelet-local-checkpoint-idempotent branch from 3074738 to 9198624 Compare October 20, 2026 23:13
@chw120

Copy link
Copy Markdown
Collaborator Author

Both answered well, thanks.

The IMMEDIATE reasoning makes sense — the proto's TODO about deleting the local snapshot after upload is what made the manifest check inapplicable there, not an oversight. And syncing with #2677 closes it anyway: checking the tracker state rather than manifest.json covers both modes, and I can see the retry test now runs under UPLOAD_MODE_IMMEDIATE as well.

Good catch adding the WarnContext on the unmarshal failure — that was the bit that would have been silent.

Nothing further from me.

Thanks for the review Abhishek Sharma (@abhisheksharma2411)!

Quick update on the scope here: since #2849 now covers answering retried Checkpoint calls from snapshottracker (across both DEFERRED and IMMEDIATE modes). So I removed the duplicate commit-check logic from this PR.

@chw120
Chenyi Wang (chw120) force-pushed the atelet-local-checkpoint-idempotent branch 3 times, most recently from 1893e19 to 83b7290 Compare October 22, 2026 00:26
Comment thread cmd/atelet/main.go Resolved
Comment thread cmd/atelet/main.go Resolved
Comment thread cmd/atelet/lifecycle_test.go Resolved
Comment thread cmd/atelet/main.go Resolved
Comment thread cmd/atelet/main.go Resolved
Comment thread cmd/atelet/lifecycle_test.go Resolved
Detach Checkpoint from caller cancellation once past lockActor and BeginCheckpoint so that if a caller times out mid-checkpoint, the in-flight attempt finishes its worker RPC, snapshot commit, and cleanup while holding the actor lock.
Comment thread cmd/atelet/main.go
// attempt to finish and commit. Keep the later of the caller's deadline
// and checkpointTimeout so a hung worker or upload cannot hold the
// actor's lock forever without shortening a longer caller deadline.
deadline := time.Now().Add(s.checkpointTimeout)

@zoez7 Zoe Zhao (zoez7) Oct 22, 2026 •

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.

so this means, the checkpoint will still continue, and all other RPCs for this actor on the same node will need to wait for the actorLock to be released.

If we receive another Checkpoint() request, the second Checkpoint() will wait for this operation to finish, and either return deadline exceeded (if timeout), or success (if the first Checkpoint succeeds)

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.

Yes, exactly! Thanks Zoe Zhao (@zoez7) for review.

@dberkov
Dmitry Berkovich (dberkov) added this pull request to the merge queue Oct 22, 2026
Merged via the queue into agent-substrate:main with commit 00f0291 Oct 22, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants