Skip to content

A resume that moves an actor finishes its snapshot upload even if the request times out - #2940

Merged
Michelle Au (msau42) merged 3 commits into
agent-substrate:mainfrom
igooch:detach-outlives-caller
Oct 21, 2026
Merged

Michelle Au (msau42) merged 3 commits into
agent-substrate:mainfrom
igooch:detach-outlives-caller

Conversation

@igooch

@igooch igooch commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2910.

DetachActor ran its detach on the RPC's context. When a resume moved an actor off a full node (#2881) and the caller left mid-upload, for example when the router's resume budget ran out, the upload was cancelled and the next resume started it over. A snapshot slower to upload than that budget never moved. And because DetachActor cancelled atelet's report, nothing corrected the record if the caller failed to write it.

Now the detach runs on the tracker's context and always makes atelet's report due.

sequenceDiagram
    participant A as ate-api-server
    participant L as atelet
    participant O as object storage
    A->>L: DetachActor
    L->>O: upload snapshot
    Note over A: resume budget runs out
    L-->>A: Canceled
    Note over L: the detach keeps running
    O-->>L: upload done
    L->>L: delete local copies
    L->>A: ReportActorSnapshotState
    A->>A: record the detach
Loading

API changes

  • Atelet.DetachActor (atelet internal). The proto comment now says atelet reports the detach and that the detach outlives the call. Comment only, with the regenerated atelet_grpc.pb.go.

Worth knowing

  • Each move now sends one redundant report. It gets Aborted while the actor is RESUMING, then NotFound once it runs elsewhere, and atelet drops it.
  • atelet: Bound background snapshot uploads with a timeout #2831's subtest asserted that a DetachActor call ignores the upload timeout. It now asserts the timeout applies.

@igooch
igooch force-pushed the detach-outlives-caller branch 2 times, most recently from 340f03a to f0be7dd Compare October 20, 2026 18:04
@igooch

igooch commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Michelle Au (@msau42), a design check against #2849. The tracker's rule is that an RPC returning the actor's state cancels the pending report, and #2849 applies it to a report that includes a detach. This PR makes DetachActor the exception, keeping a due report and making one due, per #2910. Its caller may leave mid-upload or fail to record the state, and nothing else would correct the record. A Checkpoint's caller crashes or re-enters on failure, so it can keep the rule. Are you OK with the exception?

@igooch
igooch marked this pull request as ready for review October 20, 2026 18:08
@igooch
igooch force-pushed the detach-outlives-caller branch from f0be7dd to 24114fb Compare October 20, 2026 18:20
Comment on lines 88 to 150
finish, err := t.beginDetachLocked(t.ctx, a, false)
if err != nil {
t.foregroundMu.Unlock()
t.unlock(actorUID)
return nil, err
}
return finish()
type result struct {
state *ateletpb.ActorSnapshotState
err error
}
// mu ensures exactly one of the detach and its caller handles the result.
var mu sync.Mutex
callerLeft := false
done := make(chan result, 1)
t.workersDone.Go(func() {
st, err := finish()
mu.Lock()
defer mu.Unlock()
if callerLeft {
t.logDetachAfterCallerLeft(actorUID, err)
return
}
done <- result{st, err}
})
t.foregroundMu.Unlock()
t.unlock(actorUID)

select {
case r := <-done:
return r.state, t.detachCallError(ctx, r.err)
case <-ctx.Done():
}
mu.Lock()
defer mu.Unlock()
select {
case r := <-done:
return r.state, t.detachCallError(ctx, r.err)
default:
}
callerLeft = true
return nil, ctx.Err()
}

// detachCallError returns err, or Unavailable if the tracker's shutdown ended
// the detach, so the caller doesn't mistake it for its own cancellation.
func (t *Tracker) detachCallError(ctx context.Context, err error) error {
if err != nil && t.ctx.Err() != nil && ctx.Err() == nil {
return apierror.Unavailable("atelet stopped during the detach: %v", err)
}
return err
}

// logDetachAfterCallerLeft logs a failure no caller is left to see.
func (t *Tracker) logDetachAfterCallerLeft(actorUID string, err error) {
if err == nil {
return
}
level := slog.LevelWarn
if errors.Is(err, context.Canceled) {
level = slog.LevelInfo
}
slog.Log(t.ctx, level, "Detach failed after its caller left", slog.String("actorUID", actorUID), slog.Any("err", err))
}

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.

🤖 This handoff (mu, callerLeft, done, the double select, and logDetachAfterCallerLeft) and finishDetach can be simplified:

  1. Log in logOp instead of logDetachAfterCallerLeft:

    • The only reason Detach tracks callerLeft under mu with a second select is because logOp (background.go:104) has case !background: that skips logging when a foreground Detach fails (even though logOp already logs "Snapshot operation finished" when a foreground Detach succeeds with background=false).
    • Also, in production UploadLocalSnapshot uploads via the object-store gRPC plugin (s.uploadSnapshot), which on context cancellation returns a wrapped status.Error(codes.Canceled, ...). errors.Is(err, context.Canceled) is false for gRPC status errors, and logDetachAfterCallerLeft runs after endOp has already canceled opCtx, so an outlived detach canceled by Terminate (Forget) or atelet shutdown during a plugin upload will log at WARN instead of INFO (and without snapshot, generation, and elapsed).
    • Removing case !background: from logOp (or checking !background && callerCtx.Err() == nil in logOp before endOp cancels opCtx) handles all detach logging in one place and eliminates mu, callerLeft, the second select, and logDetachAfterCallerLeft.
  2. Reuse op.done instead of a second result channel and closure:

    • beginOpLocked already allocates op := &actorOp{..., done: make(chan struct{})}, and finishDetach closes op.done on return after endOp.
    • If actorOp stores state *ateletpb.ActorSnapshotState and err error (populated before close(op.done)), Detach shrinks to:
      op, run, err := t.beginDetachLocked(t.ctx, a, false)
      if err != nil {
          t.foregroundMu.Unlock()
          t.unlock(actorUID)
          return nil, err
      }
      t.workersDone.Go(run)
      t.foregroundMu.Unlock()
      t.unlock(actorUID)
      
      select {
      case <-op.done:
          return op.state, t.detachCallError(ctx, op.err)
      case <-ctx.Done():
          return nil, ctx.Err()
      }
  3. Merge the back-to-back updateOp + endOp lock acquisitions at the end of finishDetach (detach.go:192-210):

    • At the end of finishDetach, it locks the actor via t.updateOp (lines 192–202), unlocks to call logOp and recordDetach (lines 205–209), and immediately re-locks the actor via t.endOp (line 210, whose only caller is finishDetach).
    • Matching backgroundUpload (upload.go:124-165), finishDetach can call logOp and recordDetach first (while opCtx is still live), then lock actorUID once to call endOpLocked, update/save a, requeueLocked, and queueReportLocked.

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.

🤖 Done for 1 and 2. logOp now logs a failed foreground detach too, at a level set by the op's context, so one that Terminate cancels logs at Info. The detach returns its result on op.done, and the mutex, the second select, and the separate log are gone.

For 3, I'd send the lock merge as its own PR, since it restructures finishDetach as it was before this change. Does that work for you?

@msau42 Michelle Au (msau42) Oct 21, 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.

Yes, sending the lock merge in finishDetach as its own PR works for me.

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.

🤖 Thanks. I'll send it once this merges.

@igooch
igooch force-pushed the detach-outlives-caller branch from 24114fb to 24355d8 Compare October 21, 2026 04:20
@igooch

igooch commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Michelle Au (@msau42), asking again about the report exception in my comment above. Are you OK with DetachActor keeping a due report and making one due, when an RPC that returns the state otherwise cancels it?

DetachActor ran the detach on the RPC's context, and cancelled atelet's
pending report because the caller read the state from the response. A
resume that detaches an actor from its full node waits for the upload.
If the caller left first, for example when the router's resume budget
ran out, the upload was cancelled and the next resume started it over,
so a snapshot slower to upload than that budget never moved. And if
the caller failed to record the state it got back, nothing corrected
the record until a resume detached again.

The detach now runs on the tracker's context. DetachActor returns its
state if it finishes in time, and otherwise returns the caller's error
while the detach runs on. A call made meanwhile waits for it. Every
detach makes a report of what it changed due, as a background detach
already did, so the control plane records it even when the caller does
not. A DetachActor with nothing left to detach keeps a report already
due, for the same reason.

Run, Restore, Checkpoint, and PromoteSnapshot still wait for a detach in
progress, foreground or background, so a later call on the actor can't
race the detach that outlived its RPC. Tracker.Wait covers that detach
too, so atelet's shutdown waits for it as it does for the workers, and
once Wait has begun, Detach starts no new detach and fails with
Unavailable. A detach the shutdown ends also fails a live caller with
Unavailable, not as its own cancellation. A detach that fails after its
caller left is logged, since no caller sees the error. The caller's
context no longer bounds the detach's upload, so the upload timeout now
bounds it, as it does a background detach's, and Terminate still ends it
through Forget.

ate-api-server's comment on recording a resume's detach now says that
atelet's report records it too.
A detach now returns its result on its op's done channel, so Detach
waits on that instead of a second channel guarded by a mutex. logOp
logs a failed foreground detach the way it logs a background one, at
a level set by the op's context while it is still live, so a detach
that Terminate or shutdown cancels logs at Info with the snapshot's
attributes. That replaces the separate log for a detach whose caller
left.

Comments that said a Detach call cancels the pending report, or that
the upload timeout covers only background uploads, now match the code.
@igooch
igooch force-pushed the detach-outlives-caller branch from dce11a7 to a8e122d Compare October 21, 2026 04:41
Comment on lines 88 to 150
finish, err := t.beginDetachLocked(t.ctx, a, false)
if err != nil {
t.foregroundMu.Unlock()
t.unlock(actorUID)
return nil, err
}
return finish()
type result struct {
state *ateletpb.ActorSnapshotState
err error
}
// mu ensures exactly one of the detach and its caller handles the result.
var mu sync.Mutex
callerLeft := false
done := make(chan result, 1)
t.workersDone.Go(func() {
st, err := finish()
mu.Lock()
defer mu.Unlock()
if callerLeft {
t.logDetachAfterCallerLeft(actorUID, err)
return
}
done <- result{st, err}
})
t.foregroundMu.Unlock()
t.unlock(actorUID)

select {
case r := <-done:
return r.state, t.detachCallError(ctx, r.err)
case <-ctx.Done():
}
mu.Lock()
defer mu.Unlock()
select {
case r := <-done:
return r.state, t.detachCallError(ctx, r.err)
default:
}
callerLeft = true
return nil, ctx.Err()
}

// detachCallError returns err, or Unavailable if the tracker's shutdown ended
// the detach, so the caller doesn't mistake it for its own cancellation.
func (t *Tracker) detachCallError(ctx context.Context, err error) error {
if err != nil && t.ctx.Err() != nil && ctx.Err() == nil {
return apierror.Unavailable("atelet stopped during the detach: %v", err)
}
return err
}

// logDetachAfterCallerLeft logs a failure no caller is left to see.
func (t *Tracker) logDetachAfterCallerLeft(actorUID string, err error) {
if err == nil {
return
}
level := slog.LevelWarn
if errors.Is(err, context.Canceled) {
level = slog.LevelInfo
}
slog.Log(t.ctx, level, "Detach failed after its caller left", slog.String("actorUID", actorUID), slog.Any("err", err))
}

@msau42 Michelle Au (msau42) Oct 21, 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.

Yes, sending the lock merge in finishDetach as its own PR works for me.

logOp logs a failed foreground operation the same way as a background
one. Its doc comment and TestLogOp's still said a foreground failure
went unlogged, and TestLogOp kept a branch for a row that expected no
log.
@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 bb41c52 Oct 21, 2026
15 checks passed
@igooch
igooch deleted the detach-outlives-caller 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.

Resume's detach should keep running after the caller leaves to finish upload large snapshots

2 participants