@@ -45,8 +45,14 @@ type Scheduler struct {
4545 resumeWaitMu sync.Mutex
4646 resumeWaiters map [string ]resumeWaiter
4747
48- // knockMu serializes the single active knocker's Place/Evict/Restore.
48+ // knockMu serializes the active knocker's Place/Evict (checkpoint) only.
49+ // Restore runs outside so voluntary Suspend is not blocked by long restores.
4950 knockMu sync.Mutex
51+
52+ // restoreReserve marks workers claimed for an in-flight Restore after knockMu
53+ // is released, so another Place cannot double-book the same slot.
54+ restoreReserveMu sync.Mutex
55+ restoreReserve map [string ]string // workerID -> sandboxID
5056}
5157
5258type resumeWaiter struct {
@@ -69,7 +75,8 @@ func New(st store.Store, pol policy.Policy, snapRoot string, log *slog.Logger, m
6975 log : log ,
7076 metrics : m ,
7177 signals : sig ,
72- resumeWaiters : make (map [string ]resumeWaiter ),
78+ resumeWaiters : make (map [string ]resumeWaiter ),
79+ restoreReserve : make (map [string ]string ),
7380 }
7481 if m != nil {
7582 m .SetPoolStats (s )
@@ -289,6 +296,8 @@ func (s *Scheduler) EnsureGolden(ctx context.Context) (string, error) {
289296// only the highest keepScore waiter holds knockMu and keeps knocking (Place/Evict)
290297// until admitted or timed out. Other policies share knockMu so only one checkpoint
291298// runs at a time, but any waiter may become the knocker (arrival order / lock).
299+ // Restore runs outside knockMu (with a short-lived worker reservation) so voluntary
300+ // Suspend is not stuck behind long restores.
292301func (s * Scheduler ) Resume (ctx context.Context , id string ) (types.Sandbox , error ) {
293302 resumeStart := time .Now ()
294303 sb , err := s .store .GetSandbox (ctx , id )
@@ -321,48 +330,129 @@ func (s *Scheduler) Resume(ctx context.Context, id string) (types.Sandbox, error
321330 return types.Sandbox {}, err
322331 }
323332
324- // Single knocker: hold through decide + evict + restore.
325- s .knockMu .Lock ()
326- if cur , err := s .store .GetSandbox (ctx , id ); err == nil {
327- sb = cur
328- s .addResumeWaiter (sb )
329- }
330- if err := s .ensureKnockerTurn (sb ); err != nil {
331- s .knockMu .Unlock ()
332- if errors .Is (err , policy .ErrNotBestWaiter ) {
333+ // Decide + optional Evict under knockMu; Restore happens after unlock.
334+ var (
335+ res policy.PlaceResult
336+ w types.Worker
337+ objectKey string
338+ localPath string
339+ localOnly bool
340+ usedGolden bool
341+ )
342+ admitted := false
343+ func () {
344+ s .knockMu .Lock ()
345+ defer s .knockMu .Unlock ()
346+
347+ if cur , err := s .store .GetSandbox (ctx , id ); err == nil {
348+ sb = cur
349+ s .addResumeWaiter (sb )
350+ }
351+ if err := s .ensureKnockerTurn (sb ); err != nil {
352+ lastErr = err
353+ return
354+ }
355+
356+ workers , running , err := s .resumeCandidates (ctx , sb )
357+ if err != nil {
358+ lastErr = err
359+ return
360+ }
361+ now := time .Now ().UTC ()
362+ sandboxSig , workerSig := s .signalViews (now )
363+ waiting , waitingSince := s .listResumeWaitState ()
364+ res , err = s .policy .Resume (ctx , policy.ResumeRequest {
365+ Sandbox : sb , Workers : workers , Running : running ,
366+ SandboxSignals : sandboxSig , WorkerSignals : workerSig ,
367+ Waiting : waiting , WaitingSince : waitingSince ,
368+ })
369+ if err != nil {
370+ lastErr = err
371+ if resumeRetryable (err ) && wait > 0 {
372+ s .log .Info ("resume knocker retrying" ,
373+ "sandboxID" , sb .ID ,
374+ "err" , err .Error (),
375+ "running" , len (running ),
376+ "workers" , len (workers ),
377+ "waiters" , len (waiting ),
378+ )
379+ }
380+ return
381+ }
382+
383+ if enteredSemanticWait {
384+ s .metrics .RecordSemanticStarvationWait (ctx , metrics .StarvationResolved )
385+ }
386+ s .metrics .RecordDecisionLatency (ctx , time .Since (decStart ))
387+
388+ if res .VictimID != "" {
389+ evictStart := time .Now ()
390+ phase , lock := s .victimPhaseLock (res .VictimID )
391+ if s .signals != nil {
392+ s .signals .OnEvict (res .VictimID )
393+ }
394+ if _ , err := s .suspendLocked (ctx , res .VictimID ); err != nil {
395+ lastErr = fmt .Errorf ("evict %s: %w" , res .VictimID , err )
396+ s .metrics .RecordDecision (ctx , "evict" , "error" , err .Error ())
397+ return
398+ }
399+ s .metrics .RecordEviction (ctx , res .Reason , phase , lock )
400+ s .metrics .RecordPreemptCost (ctx , time .Since (evictStart ))
401+ s .metrics .RecordDecision (ctx , "evict" , "ok" , res .Reason )
402+ }
403+ w , err = s .store .GetWorker (ctx , res .WorkerID )
404+ if err != nil {
405+ lastErr = err
406+ return
407+ }
408+
409+ objectKey = sb .ObjectKey
410+ localPath = sb .LocalSnapshotPath
411+ switch {
412+ case sb .State == types .SandboxPaused || sb .SnapshotSource == types .SnapshotLocal :
413+ if localPath == "" {
414+ localPath = s .localPath (w .ID , id )
415+ }
416+ objectKey = ""
417+ localOnly = true
418+ case sb .SnapshotSource == types .SnapshotExternal && objectKey != "" :
419+ if localPath == "" || w .ID != sb .WorkerID {
420+ localPath = s .localPath (w .ID , id )
421+ }
422+ default :
423+ g , err := s .EnsureGolden (ctx )
424+ if err != nil {
425+ lastErr = err
426+ return
427+ }
428+ objectKey = g
429+ localPath = s .localPath (w .ID , id )
430+ usedGolden = true
431+ }
432+
433+ // Claim the worker before releasing knockMu so Restore is exclusive.
434+ s .reserveRestore (w .ID , id )
435+ admitted = true
436+ lastErr = nil
437+ }()
438+
439+ if ! admitted {
440+ if lastErr == nil {
441+ lastErr = fmt .Errorf ("resume: not admitted" )
442+ }
443+ if errors .Is (lastErr , policy .ErrNotBestWaiter ) {
333444 select {
334445 case <- ctx .Done ():
335446 return types.Sandbox {}, ctx .Err ()
336447 case <- time .After (200 * time .Millisecond ):
337448 }
338449 continue
339450 }
340- s .metrics .RecordDecision (ctx , "resume" , "error" , err .Error ())
341- return types.Sandbox {}, err
342- }
343-
344- workers , running , err := s .resumeCandidates (ctx , sb )
345- if err != nil {
346- s .knockMu .Unlock ()
347- s .metrics .RecordDecision (ctx , "resume" , "error" , err .Error ())
348- return types.Sandbox {}, err
349- }
350- now := time .Now ().UTC ()
351- sandboxSig , workerSig := s .signalViews (now )
352- waiting , waitingSince := s .listResumeWaitState ()
353- res , err := s .policy .Resume (ctx , policy.ResumeRequest {
354- Sandbox : sb , Workers : workers , Running : running ,
355- SandboxSignals : sandboxSig , WorkerSignals : workerSig ,
356- Waiting : waiting , WaitingSince : waitingSince ,
357- })
358- if err != nil {
359- lastErr = err
360- s .knockMu .Unlock ()
361- if ! resumeRetryable (err ) || wait <= 0 {
362- s .metrics .RecordDecision (ctx , "resume" , "error" , err .Error ())
363- return types.Sandbox {}, err
451+ if ! resumeRetryable (lastErr ) || wait <= 0 {
452+ s .metrics .RecordDecision (ctx , "resume" , "error" , lastErr .Error ())
453+ return types.Sandbox {}, lastErr
364454 }
365- if errors .Is (err , policy .ErrAllSemanticLocked ) && ! enteredSemanticWait {
455+ if errors .Is (lastErr , policy .ErrAllSemanticLocked ) && ! enteredSemanticWait {
366456 enteredSemanticWait = true
367457 s .metrics .RecordSemanticStarvationWait (ctx , metrics .StarvationEnter )
368458 }
@@ -374,71 +464,12 @@ func (s *Scheduler) Resume(ctx context.Context, id string) (types.Sandbox, error
374464 s .metrics .RecordDecision (ctx , "resume" , "error" , out .Error ())
375465 return types.Sandbox {}, out
376466 }
377- s .log .Info ("resume knocker retrying" ,
378- "sandboxID" , sb .ID ,
379- "err" , err .Error (),
380- "running" , len (running ),
381- "workers" , len (workers ),
382- "waiters" , len (waiting ),
383- )
384467 select {
385468 case <- ctx .Done ():
386469 return types.Sandbox {}, ctx .Err ()
387470 case <- time .After (200 * time .Millisecond ):
388471 }
389- continue // still the knocker if top-ranked — keep knocking
390- }
391-
392- if enteredSemanticWait {
393- s .metrics .RecordSemanticStarvationWait (ctx , metrics .StarvationResolved )
394- }
395- s .metrics .RecordDecisionLatency (ctx , time .Since (decStart ))
396-
397- if res .VictimID != "" {
398- evictStart := time .Now ()
399- phase , lock := s .victimPhaseLock (res .VictimID )
400- if s .signals != nil {
401- s .signals .OnEvict (res .VictimID )
402- }
403- if _ , err := s .suspendLocked (ctx , res .VictimID ); err != nil {
404- s .knockMu .Unlock ()
405- s .metrics .RecordDecision (ctx , "evict" , "error" , err .Error ())
406- return types.Sandbox {}, fmt .Errorf ("evict %s: %w" , res .VictimID , err )
407- }
408- s .metrics .RecordEviction (ctx , res .Reason , phase , lock )
409- s .metrics .RecordPreemptCost (ctx , time .Since (evictStart ))
410- s .metrics .RecordDecision (ctx , "evict" , "ok" , res .Reason )
411- }
412- w , err := s .store .GetWorker (ctx , res .WorkerID )
413- if err != nil {
414- s .knockMu .Unlock ()
415- return types.Sandbox {}, err
416- }
417-
418- objectKey := sb .ObjectKey
419- localPath := sb .LocalSnapshotPath
420- localOnly := false
421- usedGolden := false
422- switch {
423- case sb .State == types .SandboxPaused || sb .SnapshotSource == types .SnapshotLocal :
424- if localPath == "" {
425- localPath = s .localPath (w .ID , id )
426- }
427- objectKey = ""
428- localOnly = true
429- case sb .SnapshotSource == types .SnapshotExternal && objectKey != "" :
430- if localPath == "" || w .ID != sb .WorkerID {
431- localPath = s .localPath (w .ID , id )
432- }
433- default :
434- g , err := s .EnsureGolden (ctx )
435- if err != nil {
436- s .knockMu .Unlock ()
437- return types.Sandbox {}, err
438- }
439- objectKey = g
440- localPath = s .localPath (w .ID , id )
441- usedGolden = true
472+ continue
442473 }
443474
444475 path := metrics .ClassifyResumePath (prevWorkerID , w .ID , localOnly , objectKey , usedGolden )
@@ -455,23 +486,24 @@ func (s *Scheduler) Resume(ctx context.Context, id string) (types.Sandbox, error
455486 opts := workerclient.RestoreOpts {ImagePath : localPath , ObjectKey : objectKey }
456487 restoreStart := time .Now ()
457488 if err := s .workers .Restore (ctx , w .Address , id , opts ); err != nil {
458- s .knockMu . Unlock ( )
489+ s .clearRestoreReserve ( w . ID , id )
459490 return types.Sandbox {}, err
460491 }
461492 s .metrics .RecordRestoreLatency (ctx , path , time .Since (restoreStart ))
462493 if s .signals != nil {
463494 s .signals .RecordRestore (id , time .Now ().UTC (), time .Since (restoreStart ), time .Now ().UTC ())
464495 }
465496
466- now = time .Now ().UTC ()
497+ now : = time .Now ().UTC ()
467498 sb .State = types .SandboxRunning
468499 sb .WorkerID = w .ID
469500 sb .LocalSnapshotPath = localPath
470501 sb .UpdatedAt = now
471502 if err := s .store .PutSandbox (ctx , sb ); err != nil {
472- s .knockMu . Unlock ( )
503+ s .clearRestoreReserve ( w . ID , id )
473504 return types.Sandbox {}, err
474505 }
506+ s .clearRestoreReserve (w .ID , id )
475507 s .metrics .MarkRunning (ctx , id , w .ID , now )
476508 if s .signals != nil {
477509 s .signals .MarkRunning (id , now )
@@ -482,7 +514,6 @@ func (s *Scheduler) Resume(ctx context.Context, id string) (types.Sandbox, error
482514 Policy : s .policy .Name (), Action : "resume" ,
483515 SandboxID : id , WorkerID : w .ID , VictimID : res .VictimID , Reason : res .Reason ,
484516 })
485- s .knockMu .Unlock ()
486517 return sb , nil
487518 }
488519}
@@ -541,7 +572,7 @@ func (s *Scheduler) Suspend(ctx context.Context, id string) (types.Sandbox, erro
541572}
542573
543574// suspendLocked checkpoints a running sandbox. Caller must hold knockMu
544- // (Resume knocker path) so voluntary Suspend and eviction cannot race.
575+ // (Resume knocker Evict path) so voluntary Suspend and eviction cannot race.
545576func (s * Scheduler ) suspendLocked (ctx context.Context , id string ) (types.Sandbox , error ) {
546577 return s .pauseOrSuspend (ctx , id , true )
547578}
@@ -668,6 +699,7 @@ func (s *Scheduler) resumeCandidates(ctx context.Context, sb types.Sandbox) ([]t
668699 if err != nil {
669700 return nil , nil , err
670701 }
702+ workers = s .applyRestoreReserves (workers , sb .ID )
671703 running , err := s .running (ctx )
672704 if err != nil {
673705 return nil , nil , err
@@ -696,6 +728,43 @@ func (s *Scheduler) resumeCandidates(ctx context.Context, sb types.Sandbox) ([]t
696728 return workers , running , nil
697729}
698730
731+ func (s * Scheduler ) reserveRestore (workerID , sandboxID string ) {
732+ s .restoreReserveMu .Lock ()
733+ defer s .restoreReserveMu .Unlock ()
734+ if s .restoreReserve == nil {
735+ s .restoreReserve = make (map [string ]string )
736+ }
737+ s .restoreReserve [workerID ] = sandboxID
738+ }
739+
740+ func (s * Scheduler ) clearRestoreReserve (workerID , sandboxID string ) {
741+ s .restoreReserveMu .Lock ()
742+ defer s .restoreReserveMu .Unlock ()
743+ if cur , ok := s .restoreReserve [workerID ]; ok && cur == sandboxID {
744+ delete (s .restoreReserve , workerID )
745+ }
746+ }
747+
748+ // applyRestoreReserves marks workers held by another sandbox's in-flight Restore
749+ // as full so Place/Resume cannot double-book them.
750+ func (s * Scheduler ) applyRestoreReserves (workers []types.Worker , selfID string ) []types.Worker {
751+ s .restoreReserveMu .Lock ()
752+ defer s .restoreReserveMu .Unlock ()
753+ if len (s .restoreReserve ) == 0 {
754+ return workers
755+ }
756+ out := append ([]types.Worker (nil ), workers ... )
757+ for i := range out {
758+ if holder , ok := s .restoreReserve [out [i ].ID ]; ok && holder != selfID {
759+ out [i ].UsedSlots = out [i ].MaxSlots
760+ if out [i ].UsedSlots < 1 {
761+ out [i ].UsedSlots = 1
762+ }
763+ }
764+ }
765+ return out
766+ }
767+
699768func (s * Scheduler ) addResumeWaiter (sb types.Sandbox ) {
700769 s .resumeWaitMu .Lock ()
701770 defer s .resumeWaitMu .Unlock ()
0 commit comments