Repository navigation
A resume that moves an actor finishes its snapshot upload even if the request times out - #2940
Conversation
340f03a to
f0be7dd
Compare
|
🤖 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 |
f0be7dd to
24114fb
Compare
| 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)) | ||
| } |
There was a problem hiding this comment.
🤖 This handoff (mu, callerLeft, done, the double select, and logDetachAfterCallerLeft) and finishDetach can be simplified:
-
Log in
logOpinstead oflogDetachAfterCallerLeft:- The only reason
DetachtrackscallerLeftundermuwith a secondselectis becauselogOp(background.go:104) hascase !background:that skips logging when a foregroundDetachfails (even thoughlogOpalready logs"Snapshot operation finished"when a foregroundDetachsucceeds withbackground=false). - Also, in production
UploadLocalSnapshotuploads via the object-store gRPC plugin (s.uploadSnapshot), which on context cancellation returns a wrappedstatus.Error(codes.Canceled, ...).errors.Is(err, context.Canceled)isfalsefor gRPC status errors, andlogDetachAfterCallerLeftruns afterendOphas already canceledopCtx, so an outlived detach canceled byTerminate(Forget) or atelet shutdown during a plugin upload will log atWARNinstead ofINFO(and withoutsnapshot,generation, andelapsed). - Removing
case !background:fromlogOp(or checking!background && callerCtx.Err() == nilinlogOpbeforeendOpcancelsopCtx) handles all detach logging in one place and eliminatesmu,callerLeft, the secondselect, andlogDetachAfterCallerLeft.
- The only reason
-
Reuse
op.doneinstead of a secondresultchannel and closure:beginOpLockedalready allocatesop := &actorOp{..., done: make(chan struct{})}, andfinishDetachclosesop.doneon return afterendOp.- If
actorOpstoresstate *ateletpb.ActorSnapshotStateanderr error(populated beforeclose(op.done)),Detachshrinks 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() }
-
Merge the back-to-back
updateOp+endOplock acquisitions at the end offinishDetach(detach.go:192-210):- At the end of
finishDetach, it locks the actor viat.updateOp(lines 192–202), unlocks to calllogOpandrecordDetach(lines 205–209), and immediately re-locks the actor viat.endOp(line 210, whose only caller isfinishDetach). - Matching
backgroundUpload(upload.go:124-165),finishDetachcan calllogOpandrecordDetachfirst (whileopCtxis still live), then lockactorUIDonce to callendOpLocked, update/savea,requeueLocked, andqueueReportLocked.
- At the end of
There was a problem hiding this comment.
🤖 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?
There was a problem hiding this comment.
Yes, sending the lock merge in finishDetach as its own PR works for me.
There was a problem hiding this comment.
🤖 Thanks. I'll send it once this merges.
24114fb to
24355d8
Compare
|
🤖 Michelle Au (@msau42), asking again about the report exception in my comment above. Are you OK with |
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.
dce11a7 to
a8e122d
Compare
| 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)) | ||
| } |
There was a problem hiding this comment.
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.
Fixes #2910.
DetachActorran 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 becauseDetachActorcancelled 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 detachDetachActorreturns its state as before. Otherwise it returns the caller's error and the detach runs on. A second call waits for it.DetachActorcall's upload, since the caller's context no longer does.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 regeneratedatelet_grpc.pb.go.Worth knowing
Abortedwhile the actor is RESUMING, thenNotFoundonce it runs elsewhere, and atelet drops it.DetachActorcall ignores the upload timeout. It now asserts the timeout applies.