Skip to content

Commit 732cde9

Browse files
scheduler: release knockMu before Restore to unblock Suspend
Keep Place/Evict under knockMu, run Restore outside with a short-lived worker reservation so voluntary Suspend is not stuck behind long restores. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ebf70f6 commit 732cde9

1 file changed

Lines changed: 170 additions & 101 deletions

File tree

internal/scheduler/scheduler.go

Lines changed: 170 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -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

5258
type 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.
292301
func (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.
545576
func (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+
699768
func (s *Scheduler) addResumeWaiter(sb types.Sandbox) {
700769
s.resumeWaitMu.Lock()
701770
defer s.resumeWaitMu.Unlock()

0 commit comments

Comments
 (0)