carlos / platform Public

move holds, fix round 1: held hosts leave the due batch, hold writes are conditional and serialised, switch-off is main's code path (task-6-review)

schedule: with the hold wiring armed the runner asks DueUnheldTimers, so a
held host's rows are excluded before the LIMIT and cannot starve another
host (review Important 1). Every hold check in Tick (the pre-filter, the
decision read inside the claim, the between-timer check) runs only with
Gate set; nil is main's runner exactly (Important 3). The converger was
already main's with nil hooks; a test now says so.

registry: DueUnheldTimers (DueTimers minus held routes, in SQL) and
SetHoldIf, a hold write conditional on the row's current hold_op in one
statement, expecting "" or the same op.

provision: holdExisting's record re-read, re-judgement and write run under
WithHostHoldLock, the per-host lock the landing's open and finalisation
(Task 10) must take, and the write is SetHoldIf expecting the row unheld
(Important 2). A write another op pre-empted is logged and left.

Tests: a full batch of held timers ahead of an unrelated due timer fires
the unrelated one this tick; a nil-gate runner delivers a held row's two
timers and advances attempts; the host switch-off test is behavioural
(runner and converger wired with the switch off); a pause before the hold
write while another op takes the row leaves that op's hold; an open racing
the write waits for the lock and then releases the hold it finds under its
own op; the lock is exclusive per host; six in-place mutants each fail
their test. The wiring in moveshold.go is accepted (fix1 ruling 4).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 350ce6298cf03f94792404ae9cf6ab36405454b7 parent d7958da
11 files changed, +605 −51
  • internal/host/moveshold.go +5 −0
  • internal/host/moveshold_test.go +62 −2
  • internal/provision/reconcile.go +91 −30
  • internal/provision/reconcile_test.go +129 −0
  • internal/registry/hold_test.go +56 −0
  • internal/registry/registry.go +27 −0
  • internal/registry/timers.go +27 −0
  • internal/registry/timers_test.go +59 −0
  • internal/schedule/converge_test.go +35 −0
  • internal/schedule/runner.go +43 −19
  • internal/schedule/runner_test.go +71 −0
diff --git a/internal/host/moveshold.go b/internal/host/moveshold.go
index 375c0037d..9128e77ea 100644
--- a/internal/host/moveshold.go
+++ b/internal/host/moveshold.go
@@ -29,6 +29,11 @@ import (
//
// Only built with the Phase 1 switch on: off, armedHoldView is nil and no
// pass is given a hook.
+//
+// Ending a hold that hold would re-assert (the landing's open, the
+// finalisation: Task 10, movesland.go) must happen under
+// provision.WithHostHoldLock, the lock reconcile's hold decision holds from
+// its record re-read through its conditional write. See that function.
type movesHoldView struct {
box, region string
read func(ctx context.Context, s release.Store, host string) (hostclaim.Observed, error)
diff --git a/internal/host/moveshold_test.go b/internal/host/moveshold_test.go
index 3bc892d4a..f1bbe8f44 100644
--- a/internal/host/moveshold_test.go
+++ b/internal/host/moveshold_test.go
@@ -3,15 +3,25 @@ package host
import (
"context"
"errors"
+ "net/http"
+ "path/filepath"
"testing"
"time"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/schedule"
)
+// statusDeliverer is a schedule.Deliverer answering one status to every tick.
+type statusDeliverer int
+
+func (s statusDeliverer) Deliver(context.Context, registry.Route, *http.Request) (int, error) {
+ return int(s), nil
+}
+
const (
mhHost = "a.go.example.com"
mhID = "mv_abcdefghijklmnopqrstuvwxyz"
@@ -241,11 +251,61 @@ func TestMovesHoldsAreMainWithTheSwitchOff(t *testing.T) {
if cfg.Hold != nil || cfg.KeepParticipant != nil || cfg.HoldWritten != nil {
t.Fatal("reconcile hooks with the switch off")
}
- sd := &scheduleDeps{runner: &schedule.Runner{}, conv: &schedule.Converger{}}
+
+ // The schedule halves, wired as the agent wires them with the switch
+ // off, against a row whose status is a hold: the runner delivers its
+ // due timer and records the outcome, and the converger converges its
+ // declaration, exactly as main does (fix round 1: behaviour, not the
+ // absence of hooks).
+ ctx := context.Background()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ const host = "held.bab.oncarlos.com"
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "bab", App: "app", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertTimer(registry.Timer{Host: host, Name: "remind", Kind: schedule.KindOnce, Spec: "at 1", Path: "/r", NextAt: 1, DueAt: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetHold(host, "moving", mhID); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Date(2026, 8, 23, 13, 7, 0, 0, time.UTC)
+ st := release.NewDirStore(t.TempDir())
+ if _, err := schedule.UpsertSchedule(ctx, st, "bab", "app", schedule.Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now); err != nil {
+ t.Fatal(err)
+ }
+ runner := &schedule.Runner{Reg: reg, Deliver: statusDeliverer(503), Now: func() time.Time { return now }, Timeout: time.Minute,
+ Box: "ie-box", Build: "test", Token: func(string) (string, error) { return "tok", nil }}
+ conv := schedule.NewConverger(reg, st, "ie-box", nil)
+ conv.Now = func() time.Time { return now }
+ sd := &scheduleDeps{runner: runner, conv: conv}
armScheduleHolds(sd, armedHoldView())
- if sd.runner.Gate != nil || sd.conv.Gate != nil || sd.conv.Frozen != nil {
+ if runner.Gate != nil || conv.Gate != nil || conv.Frozen != nil {
t.Fatal("schedule hooks with the switch off")
}
+ runner.Tick(ctx)
+ // Tick launches the delivery and returns: wait for the record, then
+ // for the host's goroutine to let go.
+ var tm *registry.Timer
+ for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
+ if tm, _ = reg.GetTimer(host, "remind"); tm != nil && tm.Attempts == 1 && !runner.Busy(host) {
+ break
+ }
+ }
+ if tm == nil || tm.Attempts != 1 || tm.LastStatus != "503" {
+ t.Fatalf("with the switch off a held row's due timer was not delivered and recorded as main does: %+v", tm)
+ }
+ if err := conv.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := reg.GetTimer(host, "sync"); err != nil {
+ t.Fatalf("with the switch off the held host's declaration did not converge: %v", err)
+ }
+
armedMovesWorker.Store(nil)
if cfg := withMovesHolds(provision.ReconcileConfig{}); cfg.Hold != nil {
t.Fatal("reconcile hooks with no worker armed")
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 96300a8ca..d7a4b696a 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -104,9 +104,10 @@ type ReconcileConfig struct {
// the op holding it. When ok, a row minted for rec is minted held
// (status, HoldOp op) at flat paths, never ready and never nested
// (Phase 1 lands flat, G11); an unheld row of this pass's own is held
- // with SetHold (Upsert never changes a hold on conflict, G10) once a
- // fresh read of the record still asks for it, except under a recovery
- // hold (restoring, releasing), which only mints (holdExisting); a row
+ // with SetHoldIf (Upsert never changes a hold on conflict, G10) once a
+ // fresh read of the record, under the host's hold lock
+ // (WithHostHoldLock), still asks for it, except under a recovery hold
+ // (restoring, releasing), which only mints (holdExisting); a row
// already held, by this op or another, is left as it is. Nil: no move
// holds anything, main's pass exactly.
Hold func(rec Record) (status, op string, ok bool)
@@ -116,10 +117,15 @@ type ReconcileConfig struct {
// says. The operation removes it at its own finalization. Nil: none is.
KeepParticipant func(host string) bool
// HoldWritten is called after every hold this pass writes (a held mint
- // or a SetHold), so the caller can touch the stage-requested marker:
- // root re-judges what it staged under the new hold (registry.SetHold's
- // contract, G10). Nil: nobody is told.
+ // or a SetHoldIf that landed), so the caller can touch the
+ // stage-requested marker: root re-judges what it staged under the new
+ // hold (registry.SetHold's contract, G10). Nil: nobody is told.
HoldWritten func(host string)
+
+ // beforeHoldWrite is the tests' pause under the host's hold lock, just
+ // before holdExisting's conditional write (TestReconcileHoldWrite...).
+ // Nil in the agent.
+ beforeHoldWrite func(host string)
}
// InboundAny and InboundNone are the closed vocabulary ReconcileConfig.Inbound
@@ -1423,13 +1429,51 @@ func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig,
}
}
+// hostHoldLocks is one in-process mutex per host for the hold decision on
+// an existing row: see WithHostHoldLock.
+var hostHoldLocks struct {
+ mu sync.Mutex
+ hosts map[string]*sync.Mutex
+}
+
+// WithHostHoldLock runs fn holding host's hold lock, exclusively within this
+// process. Reconcile's hold decision for an existing row (holdExisting: the
+// record re-read, the hold asked again of it, the conditional write) runs
+// under it, so nothing that ends the hold can run between that read and
+// that write. The landing's open and its finalisation (moves Task 10,
+// movesland.go) MUST take this same lock around their record read and their
+// ReleaseHold or DeleteIfHold, and so must any later writer that ends a hold
+// reconcile could re-assert from the record: without it, an open that lands
+// in the window is re-held on the next line. Writers in other processes
+// (root) are met by SetHoldIf's own condition, not by this lock.
+//
+// A leaf: its holder reads one record, writes one row and takes no other
+// lock. Nothing takes it while holding the held-stage lock (G30), and
+// nothing holds it across a wait on another host.
+func WithHostHoldLock(host string, fn func()) {
+ hostHoldLocks.mu.Lock()
+ if hostHoldLocks.hosts == nil {
+ hostHoldLocks.hosts = map[string]*sync.Mutex{}
+ }
+ l := hostHoldLocks.hosts[host]
+ if l == nil {
+ l = &sync.Mutex{}
+ hostHoldLocks.hosts[host] = l
+ }
+ hostHoldLocks.mu.Unlock()
+ l.Lock()
+ defer l.Unlock()
+ fn()
+}
+
// holdExisting holds an existing row for a move (G10): Upsert never changes
// a hold on conflict, so an unheld row of this pass's own is held with
-// SetHold. A row already held by op is left as is; a row held by another op
-// is that op's, and is left alone; a row this pass did not mint (not
+// SetHoldIf. A row already held by op is left as is; a row held by another
+// op is that op's, and is left alone; a row this pass did not mint (not
// Provisioned, or another app's) is not taken into a move.
//
-// Two rules keep a hold write from closing a row an operation has opened:
+// Three rules keep a hold write from closing a row an operation has opened,
+// or from landing over a hold written since the pass's listing (fix round 1):
//
// - a recovery hold (restoring, releasing) is never written over an
// unheld row. The recovering operation holds its own row as it enters
@@ -1437,12 +1481,19 @@ func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig,
// recovery (moves spec §7.4 reverting `probed`, §7.8 `opened`), so an
// unheld row there is one it opened. Only a missing row is minted.
// - the record is read again just before the write, and the hold is
-// asked again of the fresh record: this pass listed the records before
-// the routes, and a target opens its row only after its commit, so a
-// fresh record still at landing means the row has not been opened.
+// asked again of the fresh record, all under the host's hold lock
+// (WithHostHoldLock), which the open and the finalisation take too:
+// this pass listed the records before the routes, and a target opens
+// its row only after its commit, so a fresh record still at landing
+// means the row has not been opened, and under the lock it cannot be
+// opened before the write lands.
+// - the write is SetHoldIf expecting the row unheld: a hold another op
+// wrote since the listing (or since this pass's own route read) is
+// never overwritten; the row is left to it and the next pass judges
+// it afresh.
//
-// SetHold can wait out root's activation guard and fail (G30): logged, and
-// the next pass tries again.
+// SetHoldIf can wait out root's activation guard and fail (G30): logged,
+// and the next pass tries again.
func holdExisting(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, rec Record, existing registry.Route, status, op string) {
switch {
case !registry.IsHold(status) || op == "":
@@ -1462,22 +1513,32 @@ func holdExisting(ctx context.Context, s release.Store, reg *registry.Registry,
log.Printf("provision: dry-run: would hold %s %s by %s", rec.Host, status, op)
return
}
- fresh, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
- if err != nil {
- log.Printf("provision: %s: not holding this pass: re-read the record: %v", rec.Host, err)
- return
- }
- if st, o, ok := cfg.Hold(*fresh); !ok || st != status || o != op {
- return // the move moved on since the listing; the next pass judges it afresh
- }
- if err := reg.SetHold(rec.Host, status, op); err != nil {
- log.Printf("provision: hold %s %s by %s: %v", rec.Host, status, op, err)
- return
- }
- log.Printf("provision: held %s %s by %s (was %s)", rec.Host, status, op, existing.Status)
- if cfg.HoldWritten != nil {
- cfg.HoldWritten(rec.Host)
- }
+ WithHostHoldLock(rec.Host, func() {
+ fresh, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
+ if err != nil {
+ log.Printf("provision: %s: not holding this pass: re-read the record: %v", rec.Host, err)
+ return
+ }
+ if st, o, ok := cfg.Hold(*fresh); !ok || st != status || o != op {
+ return // the move moved on since the listing; the next pass judges it afresh
+ }
+ if cfg.beforeHoldWrite != nil {
+ cfg.beforeHoldWrite(rec.Host)
+ }
+ held, err := reg.SetHoldIf(rec.Host, status, op, "")
+ if err != nil {
+ log.Printf("provision: hold %s %s by %s: %v", rec.Host, status, op, err)
+ return
+ }
+ if !held {
+ log.Printf("provision: %s: not held %s by %s: the row's hold changed under this pass; the next pass judges it afresh", rec.Host, status, op)
+ return
+ }
+ log.Printf("provision: held %s %s by %s (was %s)", rec.Host, status, op, existing.Status)
+ if cfg.HoldWritten != nil {
+ cfg.HoldWritten(rec.Host)
+ }
+ })
}
// gateWord renders a gate column for a log line: "open" or "set", never the
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index 6a4ff8e4d..03e5ef9bb 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -3353,3 +3353,132 @@ func TestReconcileHoldRereadsTheRecordBeforeHoldingARow(t *testing.T) {
t.Fatalf("an opened row was held again from a stale listing: %+v %v", got, wrote.hosts)
}
}
+
+// Fix round 1 (task-6-review, Important 2): the hold write on an existing
+// row is SetHoldIf, conditional on the row still being unheld in the same
+// statement. Another op that takes the row after the pass's listing (here
+// while the pass is paused just before its write) keeps it: the stale write
+// does not land and nothing is reported.
+func TestReconcileHoldWriteIsRefusedWhenAnotherOpTookTheRow(t *testing.T) {
+ s, sqid := setup(t)
+ rec := movingRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) // unheld at the listing
+ var wrote holdLog
+ cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written,
+ Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }}
+ paused := 0
+ cfg.beforeHoldWrite = func(host string) {
+ paused++
+ // A rollback's hold, from a writer outside this pass's lock (root,
+ // or another process): the row is another op's by the time the
+ // pass writes.
+ if err := reg.SetHold(host, "restoring", "rb_other"); err != nil {
+ t.Error(err)
+ }
+ }
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if paused != 1 {
+ t.Fatalf("the pass paused %d time(s) before a hold write, want once", paused)
+ }
+ if got := mustGet(t, reg, rec.Host); got.Status != "restoring" || got.HoldOp != "rb_other" {
+ t.Fatalf("the stale hold write landed over another op's: %+v", got)
+ }
+ if len(wrote.hosts) != 0 {
+ t.Fatalf("a refused hold write was reported: %v", wrote.hosts)
+ }
+}
+
+// Fix round 1 (task-6-review, Important 2): the hold decision, from the
+// record re-read through the write, runs under the host's hold lock, the
+// one the landing's open and finalisation take (WithHostHoldLock). An open
+// that arrives inside that window waits for the write, then finds the hold
+// under its own op and releases it; it can never run between the re-read
+// and the write, so the pass cannot re-hold a row the move has opened. A
+// later pass sees the committed record and leaves the opened row alone.
+func TestReconcileHoldWriteIsSerialisedWithTheMoveOpening(t *testing.T) {
+ s, sqid := setup(t)
+ rec := movingRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false))
+ var wrote holdLog
+ cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written,
+ Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }}
+ opened := make(chan struct{})
+ cfg.beforeHoldWrite = func(host string) {
+ // The target's open, racing the write: the commit (record CAS) and
+ // the conditional release, under the same lock.
+ go func() {
+ defer close(opened)
+ WithHostHoldLock(host, func() {
+ fresh, etag, err := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ m := *fresh.Move
+ m.State = MoveCommitted
+ fresh.Move = &m
+ if _, err := s.Put(ctx, RecordKey(rec.Account, rec.App, rec.Host), mustJSON(t, fresh), etag); err != nil {
+ t.Error(err)
+ }
+ if ok, err := reg.ReleaseHold(host, rec.Move.ID); err != nil || !ok {
+ t.Errorf("the open found no hold under its own op to release: %v %v", ok, err)
+ }
+ })
+ }()
+ select {
+ case <-opened:
+ t.Error("the open ran inside the pass's hold decision")
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-opened:
+ case <-time.After(10 * time.Second):
+ t.Fatal("the open never got the lock after the pass")
+ }
+ if got := mustGet(t, reg, rec.Host); got.Status != "ready" || got.HoldOp != "" {
+ t.Fatalf("after the open the row is %+v, want ready and unheld", got)
+ }
+ if len(wrote.hosts) != 1 {
+ t.Fatalf("hold writes reported = %v, want the pass's one write before the open", wrote.hosts)
+ }
+ cfg.beforeHoldWrite = func(string) { t.Error("a later pass tried to hold the opened row") }
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustGet(t, reg, rec.Host); got.Status != "ready" || got.HoldOp != "" || len(wrote.hosts) != 1 {
+ t.Fatalf("a later pass re-held the opened row: %+v %v", got, wrote.hosts)
+ }
+}
+
+// WithHostHoldLock is exclusive per host and independent across hosts.
+func TestWithHostHoldLockIsExclusivePerHost(t *testing.T) {
+ entered, release := make(chan struct{}), make(chan struct{})
+ go WithHostHoldLock("a.go.tito.io", func() { close(entered); <-release })
+ <-entered
+ WithHostHoldLock("b.go.tito.io", func() {}) // another host's lock is free
+ second := make(chan struct{})
+ go func() { WithHostHoldLock("a.go.tito.io", func() {}); close(second) }()
+ select {
+ case <-second:
+ t.Fatal("a second holder entered the host's lock")
+ case <-time.After(50 * time.Millisecond):
+ }
+ close(release)
+ select {
+ case <-second:
+ case <-time.After(5 * time.Second):
+ t.Fatal("the lock was not handed on")
+ }
+}
diff --git a/internal/registry/hold_test.go b/internal/registry/hold_test.go
index ddce56e60..34d016f56 100644
--- a/internal/registry/hold_test.go
+++ b/internal/registry/hold_test.go
@@ -412,3 +412,59 @@ func TestWithHoldsFrozenHoldsOffASetHold(t *testing.T) {
t.Fatalf("err = %v", err)
}
}
+
+// Moves Task 6 fix round 1: SetHoldIf writes a hold only while the row's
+// hold_op is what the caller expects, in one statement, so a hold decided
+// from an earlier read cannot land on a row another op has taken since.
+// expectOp is "" (the row was unheld) or op (the op changes its own hold's
+// status); taking over another op's hold is never one statement.
+func TestSetHoldIfIsConditionalOnTheCurrentOp(t *testing.T) {
+ r := holdFixture(t, "h")
+ if ok, err := r.SetHoldIf("h", "moving", holdOpA, ""); err != nil || !ok {
+ t.Fatalf("unheld row, expecting unheld: %v %v", ok, err)
+ }
+ if rt, _ := r.Get("h"); rt.Status != "moving" || rt.HoldOp != holdOpA {
+ t.Fatalf("after the first hold: %+v", rt)
+ }
+ // Held by A now; a writer that still thinks the row is unheld is refused
+ // and changes nothing.
+ if ok, err := r.SetHoldIf("h", "landing", holdOpB, ""); err != nil || ok {
+ t.Fatalf("stale write over A's hold: %v %v", ok, err)
+ }
+ if rt, _ := r.Get("h"); rt.Status != "moving" || rt.HoldOp != holdOpA {
+ t.Fatalf("the refused write changed the row: %+v", rt)
+ }
+ // A may change its own hold's status.
+ if ok, err := r.SetHoldIf("h", "landing", holdOpA, holdOpA); err != nil || !ok {
+ t.Fatalf("A changing its own hold: %v %v", ok, err)
+ }
+ if rt, _ := r.Get("h"); rt.Status != "landing" || rt.HoldOp != holdOpA {
+ t.Fatalf("after A's status change: %+v", rt)
+ }
+ // B expecting A's hold: not a takeover this statement allows.
+ if _, err := r.SetHoldIf("h", "moving", holdOpB, holdOpA); err == nil {
+ t.Fatal("SetHoldIf accepted expectOp of another op")
+ }
+ if rt, _ := r.Get("h"); rt.HoldOp != holdOpA {
+ t.Fatalf("the refused takeover changed the row: %+v", rt)
+ }
+ // SetHold's own refusals hold.
+ if _, err := r.SetHoldIf("h", "ready", holdOpA, holdOpA); err == nil {
+ t.Fatal("ready accepted as a hold")
+ }
+ if _, err := r.SetHoldIf("h", "moving", "", ""); err == nil {
+ t.Fatal("a hold with no op was accepted")
+ }
+ // No such row: nothing matched, not an error (the caller re-judges
+ // next pass either way).
+ if ok, err := r.SetHoldIf("nope", "moving", holdOpA, ""); err != nil || ok {
+ t.Fatalf("unknown host: %v %v", ok, err)
+ }
+ // After a release the row is unheld again and "" matches once more.
+ if ok, _ := r.ReleaseHold("h", holdOpA); !ok {
+ t.Fatal("release")
+ }
+ if ok, err := r.SetHoldIf("h", "restoring", holdOpB, ""); err != nil || !ok {
+ t.Fatalf("hold after release: %v %v", ok, err)
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index ecdc61823..eaf9c13c3 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -1075,6 +1075,33 @@ func (r *Registry) SetHold(host, status, op string) error {
return nil
}
+// SetHoldIf is SetHold conditioned on the row's current hold_op, in one
+// statement: the row is held status under op only while its hold_op is
+// still expectOp, which is "" (the caller saw the row unheld) or op itself
+// (an op changing the status of its own hold). Any other expectOp is
+// refused: a row held by another op leaves that hold only through
+// ReleaseHold, under the op that holds it (§5.2), never by takeover. false
+// with no error means nothing matched (the row's hold changed since the
+// caller read it, or there is no row), so a hold decided from an earlier
+// read never lands over what is there now (moves Task 6 fix round 1).
+//
+// As with SetHold, every caller touches the stage-requested marker after a
+// write that landed.
+func (r *Registry) SetHoldIf(host, status, op, expectOp string) (bool, error) {
+ if !IsHold(status) || op == "" {
+ return false, fmt.Errorf("registry: %q with op %q is not a hold", status, op)
+ }
+ if expectOp != "" && expectOp != op {
+ return false, fmt.Errorf("registry: hold %s by %q expecting %q: another op's hold is released by that op, not taken over", host, op, expectOp)
+ }
+ res, err := r.sql.Exec(`UPDATE routes SET status = ?, hold_op = ? WHERE host = ? AND hold_op = ?`, status, op, host, expectOp)
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n > 0, err
+}
+
// ReleaseHold is §5.2's one conditional statement: a row leaves a hold only
// under the op that holds it. false means nothing matched (a late write from a
// finished operation, an unheld row, or an empty op).
diff --git a/internal/registry/timers.go b/internal/registry/timers.go
index 89f65b2e0..a49f04981 100644
--- a/internal/registry/timers.go
+++ b/internal/registry/timers.go
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"os"
+ "sort"
"strings"
)
@@ -152,8 +153,34 @@ func (r *Registry) TimersFor(host string) ([]Timer, error) {
// where the LIMIT is applied, instead of after it. An empty (or nil) slice
// is no filter, which is the ordinary case: nothing in flight.
func (r *Registry) DueTimers(now int64, limit int, excludeHosts []string) ([]Timer, error) {
+ return r.dueTimers(now, limit, excludeHosts, false)
+}
+
+// DueUnheldTimers is DueTimers without the rows of held routes (moves spec
+// §5.2, HoldStatuses). The exclusion is in the query, before the LIMIT, so
+// a held host's rows cannot fill the batch with rows the runner would only
+// skip and hide another host's due timer behind them (moves Task 6 fix
+// round 1). A row whose host has no route is still returned, as DueTimers
+// returns it. The runner asks this only with its hold wiring armed;
+// DueTimers stays main's query.
+func (r *Registry) DueUnheldTimers(now int64, limit int, excludeHosts []string) ([]Timer, error) {
+ return r.dueTimers(now, limit, excludeHosts, true)
+}
+
+func (r *Registry) dueTimers(now int64, limit int, excludeHosts []string, skipHeld bool) ([]Timer, error) {
q := `SELECT ` + timerCols + ` FROM timers WHERE next_at > 0 AND next_at <= ?`
args := []any{now}
+ if skipHeld {
+ statuses := make([]string, 0, len(HoldStatuses))
+ for s := range HoldStatuses {
+ statuses = append(statuses, s)
+ }
+ sort.Strings(statuses)
+ q += ` AND host NOT IN (SELECT host FROM routes WHERE status IN (?` + strings.Repeat(",?", len(statuses)-1) + `))`
+ for _, s := range statuses {
+ args = append(args, s)
+ }
+ }
if len(excludeHosts) > 0 {
q += ` AND host NOT IN (?` + strings.Repeat(",?", len(excludeHosts)-1) + `)`
for _, h := range excludeHosts {
diff --git a/internal/registry/timers_test.go b/internal/registry/timers_test.go
index 2210ab0e8..808ac5a7c 100644
--- a/internal/registry/timers_test.go
+++ b/internal/registry/timers_test.go
@@ -531,3 +531,62 @@ func TestReplaceTimersWithNoRowsClearsTheHost(t *testing.T) {
t.Fatalf("rows left: %+v", got)
}
}
+
+// Moves Task 6 fix round 1: DueUnheldTimers leaves out every row whose
+// route is held (moves spec §5.2) BEFORE the limit, so a held host's rows
+// cannot fill the batch and hide another host's due timer. DueTimers itself
+// is unchanged: with the hold wiring off the runner still asks it.
+func TestDueUnheldTimersExcludesHeldHostsBeforeTheLimit(t *testing.T) {
+ r := openTimersReg(t)
+ for _, h := range []string{"held.test", "free.test"} {
+ if err := r.Upsert(Route{Host: h, Kind: "instance", Socket: "/s"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := r.SetHold("held.test", "moving", holdOpA); err != nil {
+ t.Fatal(err)
+ }
+ for i, name := range []string{"t0", "t1", "t2"} {
+ if err := r.UpsertTimer(Timer{Host: "held.test", Name: name, Kind: "schedule", Spec: "every 1h0m0s", Path: "/j", NextAt: int64(10 + i)}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Due after every held row; and a row whose host has no route at all,
+ // which neither query drops (the converger prunes those).
+ if err := r.UpsertTimer(Timer{Host: "free.test", Name: "t", Kind: "schedule", Spec: "every 1h0m0s", Path: "/j", NextAt: 50}); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.UpsertTimer(Timer{Host: "gone.test", Name: "t", Kind: "schedule", Spec: "every 1h0m0s", Path: "/j", NextAt: 60}); err != nil {
+ t.Fatal(err)
+ }
+ due, err := r.DueTimers(100, 2, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(due) != 2 || due[0].Host != "held.test" || due[1].Host != "held.test" {
+ t.Fatalf("DueTimers = %+v, want the two earliest held rows (main's answer)", due)
+ }
+ un, err := r.DueUnheldTimers(100, 2, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(un) != 2 || un[0].Host != "free.test" || un[1].Host != "gone.test" {
+ t.Fatalf("DueUnheldTimers = %+v, want free.test then gone.test", un)
+ }
+ // The busy-host exclusion still applies on top.
+ un, err = r.DueUnheldTimers(100, 2, []string{"free.test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(un) != 1 || un[0].Host != "gone.test" {
+ t.Fatalf("DueUnheldTimers excluding free.test = %+v", un)
+ }
+ // Once the hold lifts the rows are due again, in order.
+ if ok, _ := r.ReleaseHold("held.test", holdOpA); !ok {
+ t.Fatal("release")
+ }
+ un, _ = r.DueUnheldTimers(100, 2, nil)
+ if len(un) != 2 || un[0].Host != "held.test" {
+ t.Fatalf("after the release DueUnheldTimers = %+v", un)
+ }
+}
diff --git a/internal/schedule/converge_test.go b/internal/schedule/converge_test.go
index 6c8bb2694..3fc2f4153 100644
--- a/internal/schedule/converge_test.go
+++ b/internal/schedule/converge_test.go
@@ -712,3 +712,38 @@ func TestConvergerSkipsTheTableWidePruneWhileAHostIsFrozen(t *testing.T) {
t.Fatalf("the prune did not resume after the freeze: %+v", tm)
}
}
+
+// Fix round 1 (task-6-review, Important 3): with neither Frozen nor Gate
+// set (the Phase 1 switch off) the converger is main's. A host whose row
+// status is a hold has its declaration converged and its undeclared
+// schedule pruned, and the table-wide prune runs.
+func TestConvergerWithoutTheHooksIsMain(t *testing.T) {
+ reg, st := convergeFixture(t)
+ ctx := context.Background()
+ now := at("2026-08-23T13:07:00Z")
+ const host = "a.bab.oncarlos.com"
+ if err := reg.SetHold(host, "moving", "mv_a"); err != nil {
+ t.Fatal(err)
+ }
+ reg.UpsertTimer(registry.Timer{Host: host, Name: "stale", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/s", NextAt: now.Unix() - 5})
+ old := now.Add(-8 * 24 * time.Hour).Unix()
+ reg.UpsertTimer(registry.Timer{Host: host, Name: "done", Kind: KindOnce, Spec: "at 1", Path: "/d", NextAt: 0, LastAt: old})
+ if _, err := reg.MarkTimerFired(host, "done", scheduleTimerRegistration(t, reg, host, "done"), old, "204", 1, 0, 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now)
+ c := NewConverger(reg, st, "box1", nil)
+ c.Now = func() time.Time { return now }
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := reg.GetTimer(host, "sync"); err != nil {
+ t.Fatalf("with the hold wiring off the held host's declaration did not converge: %v", err)
+ }
+ if tm, _ := reg.GetTimer(host, "stale"); tm != nil {
+ t.Fatalf("with the hold wiring off the undeclared schedule survived: %+v", tm)
+ }
+ if tm, _ := reg.GetTimer(host, "done"); tm != nil {
+ t.Fatalf("with the hold wiring off the finished once-row survived the table-wide prune: %+v", tm)
+ }
+}
diff --git a/internal/schedule/runner.go b/internal/schedule/runner.go
index e6631c955..494d010f3 100644
--- a/internal/schedule/runner.go
+++ b/internal/schedule/runner.go
@@ -133,7 +133,18 @@ func (r *Runner) Tick(ctx context.Context) {
// with rows this tick is about to skip, and another host's due timer
// would never be seen. The skip below stays as belt-and-braces — a host
// can go busy between the query and the claim.
- due, err := r.Reg.DueTimers(now.Unix(), dueBatch, r.busyHosts())
+ //
+ // With the hold wiring armed, held hosts are excluded in the query for
+ // the same reason: a held row is skipped below with no bookkeeping, so
+ // a held host's rows would sit at the front of every tick's batch and
+ // starve the rest. Off, the query is main's.
+ var due []registry.Timer
+ var err error
+ if r.holdsArmed() {
+ due, err = r.Reg.DueUnheldTimers(now.Unix(), dueBatch, r.busyHosts())
+ } else {
+ due, err = r.Reg.DueTimers(now.Unix(), dueBatch, r.busyHosts())
+ }
if err != nil {
log.Printf("schedule: due query: %v", err)
return
@@ -177,7 +188,8 @@ func (r *Runner) Tick(ctx context.Context) {
// activator and recorded as a retry, so a held row's attempts
// advanced while its instance was moving. This read is only the
// cheap pre-filter; the decision is the read inside the claim below.
- if registry.IsHold(rt.Status) {
+ // Only with the hold wiring armed: off, the row is main's.
+ if r.holdsArmed() && registry.IsHold(rt.Status) {
continue
}
if r.beforeClaim != nil {
@@ -186,23 +198,25 @@ func (r *Runner) Tick(ctx context.Context) {
if !r.claim(host) {
continue // a previous tick's delivery to this host is still running, or a move is draining it
}
- // The decision read, inside the claim. A move holds the row and then
- // drains the host (HostGate.Drain), and the drain is sound only if
- // every tick that looks at the row after the hold does so while it
- // holds the claim: a tick that read the row ready above and was
- // descheduled before claim must not slip past the drain and record
- // an attempt.
- err = withTimerWork(hctx, func() error {
- var readErr error
- rt, readErr = r.Reg.Get(host)
- return readErr
- })
- if err != nil || rt == nil || registry.IsHold(rt.Status) {
- if err != nil {
- log.Printf("schedule: %s: registry read inside the claim: %v. skipping this tick", host, err)
+ if r.holdsArmed() {
+ // The decision read, inside the claim. A move holds the row and
+ // then drains the host (HostGate.Drain), and the drain is sound
+ // only if every tick that looks at the row after the hold does
+ // so while it holds the claim: a tick that read the row ready
+ // above and was descheduled before claim must not slip past the
+ // drain and record an attempt.
+ err = withTimerWork(hctx, func() error {
+ var readErr error
+ rt, readErr = r.Reg.Get(host)
+ return readErr
+ })
+ if err != nil || rt == nil || registry.IsHold(rt.Status) {
+ if err != nil {
+ log.Printf("schedule: %s: registry read inside the claim: %v. skipping this tick", host, err)
+ }
+ r.release(host)
+ continue
}
- r.release(host)
- continue
}
// The slot is taken HERE, before the goroutine exists, so the bound
// is on hosts in flight and never on goroutines waiting to be. Hosts
@@ -223,7 +237,8 @@ func (r *Runner) Tick(ctx context.Context) {
// A hold written while this host's earlier timers were
// delivering stops the rest: each later timer re-reads the
// row first. The drain waits for this goroutine either way.
- if i > 0 && r.held(ctx, host) {
+ // Only with the hold wiring armed.
+ if i > 0 && r.holdsArmed() && r.held(ctx, host) {
break
}
r.fire(ctx, rt, t)
@@ -379,6 +394,15 @@ type SocketRetainer interface {
RetainSockets(live map[string]bool)
}
+// holdsArmed reports whether the move-hold wiring is on. The host package
+// sets Gate (armScheduleHolds) only with the Phase 1 switch on and
+// scheduled work armed; Gate nil is the switch off, and then every hold
+// check in Tick is skipped and the runner is main's code path exactly: the
+// due query is DueTimers, there is no decision read inside the claim, no
+// check between a host's timers, and a row whose status is a hold is
+// delivered and recorded like any other.
+func (r *Runner) holdsArmed() bool { return r.Gate != nil }
+
// claim marks host in flight and enters its gate; false if it already was in
// flight, or a move's drain holds or waits for the host (HostGate).
func (r *Runner) claim(host string) bool {
diff --git a/internal/schedule/runner_test.go b/internal/schedule/runner_test.go
index e4cfcac63..0cf11fb0c 100644
--- a/internal/schedule/runner_test.go
+++ b/internal/schedule/runner_test.go
@@ -1075,6 +1075,7 @@ func TestHoldMidDeliveryStopsTheRest(t *testing.T) {
reg, r := runnerFixture(t, nil)
now := at("2026-08-23T13:07:00Z")
r.Now = func() time.Time { return now }
+ r.Gate = &HostGate{} // the hold wiring armed; off, both would be delivered (TestRunnerWithoutTheGateIsMain)
var delivered []string
r.Deliver = deliverFunc(func(rt registry.Route, req *http.Request) (int, error) {
delivered = append(delivered, req.Header.Get("X-Carlos-Schedule"))
@@ -1187,3 +1188,73 @@ func TestDrainWaitsForTheDeliveryInFlight(t *testing.T) {
t.Fatalf("the table changed after the drain:\n got %+v\nwant %+v", got, snap)
}
}
+
+// Fix round 1 (task-6-review, Important 1): a held host's rows must not
+// consume the due batch. DueTimers applies its LIMIT before the runner's
+// hold filter, so a full batch of held rows due before an unrelated host's
+// row would be selected on every tick and that row never seen. With the
+// hold wiring armed the query itself leaves held hosts out, and the
+// unrelated timer runs this tick.
+func TestHeldHostsDoNotStarveAnotherHostsTimer(t *testing.T) {
+ d := &fakeDeliverer{status: 204}
+ reg, r := runnerFixture(t, d)
+ now := at("2026-08-23T13:07:00Z")
+ r.Now = func() time.Time { return now }
+ r.Gate = &HostGate{}
+ const quiet = "quiet.bab.oncarlos.com"
+ if err := reg.Upsert(registry.Route{Host: quiet, Kind: "instance", Account: "bab", App: "app", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ // A full batch of rows on the held host, every one due BEFORE the quiet
+ // host's single row.
+ for i := 0; i < dueBatch; i++ {
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: fmt.Sprintf("j%02d", i), Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/j", NextAt: now.Unix() - 1000 + int64(i)})
+ }
+ reg.UpsertTimer(registry.Timer{Host: quiet, Name: "j", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/j", NextAt: now.Unix() - 1})
+ if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil {
+ t.Fatal(err)
+ }
+ before := timersOf(t, reg, holdHost)
+ r.tick()
+ if tm, _ := reg.GetTimer(quiet, "j"); tm == nil || tm.LastStatus != "204" {
+ t.Fatalf("the quiet host's timer did not fire this tick behind a full batch of a held host's rows: %+v", tm)
+ }
+ for _, h := range d.hosts {
+ if h != quiet {
+ t.Fatalf("delivered to %s, a held host", h)
+ }
+ }
+ if got := timersOf(t, reg, holdHost); !sameTimers(got, before) {
+ t.Fatalf("the held host's bookkeeping moved:\n got %+v\nwant %+v", got, before)
+ }
+}
+
+// Fix round 1 (task-6-review, Important 3): with no gate (the Phase 1
+// switch off, or no hold wiring on this box) the runner is main's code
+// path. A row whose status is a hold is delivered like any other and its
+// bookkeeping moves: the batch includes it, the pre-filter and the decision
+// read do not look at the hold, and the between-timer check does not run.
+func TestRunnerWithoutTheGateIsMain(t *testing.T) {
+ d := &fakeDeliverer{status: 503}
+ reg, r := runnerFixture(t, d)
+ now := at("2026-08-23T13:07:00Z")
+ r.Now = func() time.Time { return now }
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "first", Kind: KindOnce, Spec: "at 1", Path: "/1", NextAt: 1, DueAt: 1})
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "second", Kind: KindOnce, Spec: "at 2", Path: "/2", NextAt: 2, DueAt: 2})
+ if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil {
+ t.Fatal(err)
+ }
+ r.tick()
+ if len(d.calls) != 2 {
+ t.Fatalf("with the hold wiring off a held row's due timers were delivered %d time(s), want both", len(d.calls))
+ }
+ for _, name := range []string{"first", "second"} {
+ tm, err := reg.GetTimer(holdHost, name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tm.Attempts != 1 || tm.LastStatus != "503" || tm.NextAt != now.Add(backoffBase).Unix() {
+ t.Fatalf("%s: bookkeeping did not advance as main's does: %+v", name, tm)
+ }
+ }
+}