carlos / platform Public

reconcile and schedule honour move holds: participant rows kept, held rows never ready, timers frozen and drained (§5.2, §9.4, §9.6, K9)

provision: ReconcileConfig.Hold mints a participant's row held at flat
paths (G11) and holds an unheld row of its own with SetHold after a fresh
record read (G10); recovery holds only mint. KeepParticipant keeps a
participant's row through removal. HoldWritten touches the
stage-requested marker after every hold write.

schedule: HostGate, one per-host gate for the runner, the converger and
a cut's drain. The runner skips held rows with no bookkeeping, decides
inside its claim and re-checks between timers; Runner.Busy. The
converger leaves a frozen host's timers alone inside the gate and holds
back the table-wide prune while any host is frozen.

host: movesHoldView (hold, keep, frozen; no strict, G9), rebuilt each
moves pass from the record list and claim reads, failing closed before
its first build and on an unreadable claim; wired into reconcile, the
schedule halves and the startup converge. Nothing with the switch off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1998953f9534d389615a7dff7e51fc34d35ee58e parent fc29d1b
13 files changed, +1493 −4
  • approved_moves_copy_test.go +5 −0
  • internal/host/agent.go +11 −0
  • internal/host/moveshold.go +280 −0
  • internal/host/moveshold_test.go +260 −0
  • internal/host/movespass.go +9 −0
  • internal/provision/reconcile.go +136 −0
  • internal/provision/reconcile_test.go +203 −0
  • internal/schedule/converge.go +40 −1
  • internal/schedule/converge_test.go +106 −0
  • internal/schedule/hostgate.go +86 −0
  • internal/schedule/hostgate_test.go +74 −0
  • internal/schedule/runner.go +72 −3
  • internal/schedule/runner_test.go +211 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index a2cdc2c3b..e0cebe924 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -551,6 +551,7 @@ var movesScanFiles = []string{
"internal/host/movesguard.go",
"internal/host/movesheldenv.go",
"internal/host/movesheldlock.go",
+ "internal/host/moveshold.go",
"internal/host/movesmeasure.go",
"internal/host/movesmeasure_linux.go",
"internal/host/movesmeasure_other.go",
@@ -594,11 +595,15 @@ var movesScanFiles = []string{
"internal/moves/switch.go",
"internal/moves/timers.go",
"internal/provision/provision.go",
+ "internal/provision/reconcile.go",
"internal/registry/registry.go",
"internal/release/adopt.go",
"internal/release/materializeheld.go",
"internal/release/proofgate.go",
"internal/release/refusal.go",
+ "internal/schedule/converge.go",
+ "internal/schedule/hostgate.go",
+ "internal/schedule/runner.go",
"internal/stopevidence/fdidentity_linux.go",
"internal/stopevidence/reconstruct.go",
"internal/stopevidence/reconstruct_linux.go",
diff --git a/internal/host/agent.go b/internal/host/agent.go
index bd96fc692..0d1b4e502 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -519,6 +519,10 @@ func runAgent(args []string, buildClaims ClaimFactory) {
}
schedStore, schedErr := openDeployStore(context.Background())
sd := armSchedule(reg, schedStore, schedErr, svc, sink, dd)
+ // Move holds (moves spec §9.4): one gate for the runner, the converger
+ // and a cut's drain, and the converger's freeze. Nothing with the
+ // Phase 1 switch off.
+ armScheduleHolds(sd, armedHoldView())
if err := configureScheduleClaims(claims, sd); err != nil {
log.Fatalf("agent: host claims: %v", err)
}
@@ -1128,6 +1132,9 @@ func reconcileSerialized(ctx context.Context, s release.Store, reg *registry.Reg
return mintLayoutIntentWith(ctx, s, reg.List)
}
}
+ // Move holds (moves spec §9.6): participant rows minted held and kept.
+ // Unchanged with the Phase 1 switch off.
+ cfg = withMovesHolds(cfg)
return errors.Join(
provision.Reconcile(ctx, s, reg, cfg),
provision.ReconcileSidecars(ctx, s, reg, cfg),
@@ -2189,6 +2196,10 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
sidecarWakeMem := newSidecarWakeMemory()
sidecarWakeSeed(ctx, reg, store, sidecarWakeMem)
sidecarWake := wakeFn(svc)
+ // The moves hold view, built before the converge below: that converge
+ // runs before the first moves pass, and an unbuilt view freezes every
+ // host (moves spec §9.4). Nothing with the Phase 1 switch off.
+ refreshMovesHolds(ctx, store)
// The schedule converge's own seed, on restartSeed's precedent and for the
// same shape of reason: the leg below runs only on a serial MOVE, so
// without one pass here a box that booted after the bump would carry no
diff --git a/internal/host/moveshold.go b/internal/host/moveshold.go
new file mode 100644
index 000000000..375c0037d
--- /dev/null
+++ b/internal/host/moveshold.go
@@ -0,0 +1,280 @@
+package host
+
+import (
+ "context"
+ "log"
+ "slices"
+ "sync/atomic"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/schedule"
+)
+
+// movesHoldView is what this box's other passes ask about running moves
+// (moves spec §5.2, §9.4, §9.6, §19 K9): reconcile mints and keeps a
+// participant's row through hold and keep, and the schedule converger
+// leaves a frozen host's timers alone through frozen. Strictness is not
+// here: the moves worker's setStrict is the one strict source (G9).
+//
+// It is rebuilt from every moves pass's record list, plus one claim read
+// per host with a Phase 1 move in flight, and read from the reconcile and
+// schedule loops in between. It fails closed: until the first build, keep
+// and frozen answer true for every host (a removal or a schedule converge
+// waits a pass rather than act on a host it cannot vouch for), and a host
+// whose claim would not read is kept and frozen until it does. hold judges
+// the record the caller passes, not the view's copy, so a flip the view has
+// not seen yet still mints the landing row held.
+//
+// Only built with the Phase 1 switch on: off, armedHoldView is nil and no
+// pass is given a hook.
+type movesHoldView struct {
+ box, region string
+ read func(ctx context.Context, s release.Store, host string) (hostclaim.Observed, error)
+ // written is reconcile's HoldWritten: the stage-requested marker after
+ // a hold write (G10). Nil writes nothing.
+ written func(host string)
+ // gate is the schedule wiring's HostGate, set by armScheduleHolds; nil
+ // until then (no scheduled work armed: nothing to drain).
+ gate atomic.Pointer[schedule.HostGate]
+ snap atomic.Pointer[holdSnapshot]
+}
+
+// holdSnapshot is one build: what each in-flight move's claim said.
+type holdSnapshot struct {
+ claims map[string]holdClaim // host -> the claim's op, for hosts whose claim read
+ unknown map[string]bool // hosts whose claim would not read
+}
+
+type holdClaim struct {
+ kind string
+ participants []string
+}
+
+func newMovesHoldView(box, region string, read func(context.Context, release.Store, string) (hostclaim.Observed, error), written func(host string)) *movesHoldView {
+ return &movesHoldView{box: box, region: region, read: read, written: written}
+}
+
+// moveKinds are the claim op kinds a move holds (§19.1). Region HA's
+// relocating is its own op and freezes through its own wiring.
+var moveKinds = map[string]bool{hostclaim.KindMoving: true, hostclaim.KindLanding: true,
+ hostclaim.KindReverting: true, hostclaim.KindRestoring: true}
+
+// refresh rebuilds the view from recs: every host with a Phase 1 move in
+// flight has its claim read. Called only with a list that was read; a
+// failed listing leaves the last build in place.
+func (v *movesHoldView) refresh(ctx context.Context, s release.Store, recs []provision.Record) {
+ next := &holdSnapshot{claims: map[string]holdClaim{}, unknown: map[string]bool{}}
+ for _, rec := range recs {
+ if rec.Move == nil || rec.Move.Legacy() {
+ continue
+ }
+ if _, seen := next.claims[rec.Host]; seen || next.unknown[rec.Host] {
+ continue
+ }
+ obs, err := v.read(ctx, s, rec.Host)
+ if err != nil {
+ log.Printf("moves: %s: claim unreadable (%v); its row is kept and its timers frozen until it reads", rec.Host, err)
+ next.unknown[rec.Host] = true
+ continue
+ }
+ if obs.Absent || obs.Claim == nil {
+ continue
+ }
+ next.claims[rec.Host] = holdClaim{kind: obs.Claim.Op.Kind, participants: slices.Clone(obs.Claim.Op.Participants)}
+ }
+ v.snap.Store(next)
+}
+
+// keep is reconcile's KeepParticipant: this box is a participant of a
+// move-kind op on host.
+func (v *movesHoldView) keep(host string) bool {
+ snap := v.snap.Load()
+ if snap == nil || snap.unknown[host] {
+ return true
+ }
+ c, ok := snap.claims[host]
+ return ok && moveKinds[c.kind] && slices.Contains(c.participants, v.box)
+}
+
+// frozen is the converger's Frozen: a move-kind op holds host's claim.
+func (v *movesHoldView) frozen(host string) bool {
+ snap := v.snap.Load()
+ if snap == nil || snap.unknown[host] {
+ return true
+ }
+ c, ok := snap.claims[host]
+ return ok && moveKinds[c.kind]
+}
+
+// The roles a box plays in one move.
+const (
+ roleNone = ""
+ roleSource = "source"
+ roleTarget = "target"
+)
+
+// role is this box's part in rec's move: by the claim's participants
+// ({source, target}, §19.1) when the view read the claim, else by the
+// record's regions. A box the claim does not name plays none.
+func (v *movesHoldView) role(rec provision.Record) (role, claimKind string) {
+ if snap := v.snap.Load(); snap != nil {
+ if c, ok := snap.claims[rec.Host]; ok && moveKinds[c.kind] && len(c.participants) == 2 {
+ switch v.box {
+ case c.participants[0]:
+ return roleSource, c.kind
+ case c.participants[1]:
+ return roleTarget, c.kind
+ }
+ return roleNone, c.kind
+ }
+ }
+ m := rec.Move
+ switch {
+ case m.From == v.region && m.To != v.region:
+ return roleSource, ""
+ case m.To == v.region && m.From != v.region:
+ return roleTarget, ""
+ case m.To == v.region:
+ // Both ends in this region and no claim to say which box is which:
+ // the state decides, and either answer holds the row.
+ switch m.State {
+ case provision.MoveLanding, provision.MoveCommitted:
+ return roleTarget, ""
+ }
+ return roleSource, ""
+ }
+ return roleNone, ""
+}
+
+// hold is reconcile's Hold: the status and op a participant's row is held
+// under, from the record's state and this box's role (§5.2, §7.4, §7.6,
+// §7.8, §19.1): the source moving from stopping until its row is deleted at
+// finalization, restoring through a revert or a rollback; the target landing
+// from the flip until the commit opens its row, releasing through a
+// rollback. A rollback's holds are under the rollback id; a rollback that
+// has none (malformed) holds under the move id rather than not at all.
+func (v *movesHoldView) hold(rec provision.Record) (status, op string, ok bool) {
+ m := rec.Move
+ if m == nil || m.Legacy() {
+ return "", "", false
+ }
+ role, kind := v.role(rec)
+ rollbackOp := m.ID
+ if m.Rollback != nil && m.Rollback.ID != "" {
+ rollbackOp = m.Rollback.ID
+ }
+ switch role {
+ case roleSource:
+ switch m.State {
+ case provision.MoveCutting:
+ switch m.CutPhase {
+ case provision.CutStopping, provision.CutStopped, provision.CutStaged, provision.CutArchived, provision.CutFlipped:
+ return "moving", m.ID, true
+ }
+ case provision.MoveLanding, provision.MoveCommitted:
+ return "moving", m.ID, true
+ case provision.MoveReverting:
+ return "restoring", m.ID, true
+ case provision.MoveAborting:
+ // Aborting follows a revert (claim reverting, the row held
+ // restoring) or comes before any cut (claim moving, the row
+ // serving): only the first is held, and only the claim can say.
+ if kind == hostclaim.KindReverting {
+ return "restoring", m.ID, true
+ }
+ case provision.MoveRollingBack, provision.MoveStuck:
+ return "restoring", rollbackOp, true
+ }
+ case roleTarget:
+ switch m.State {
+ case provision.MoveLanding:
+ return "landing", m.ID, true
+ case provision.MoveRollingBack, provision.MoveStuck:
+ return "releasing", rollbackOp, true
+ }
+ }
+ return "", "", false
+}
+
+// drainTimers is the cut's timer drain (moves spec §9.4 "drained", Task 5's
+// moveCfg.TimersDrained): after SetHold, it waits until no delivery or
+// convergence that could have missed the hold is still running for host.
+// No gate (no scheduled work armed on this box) has nothing to drain.
+func (v *movesHoldView) drainTimers(ctx context.Context, host string) error {
+ return v.gate.Load().Drain(ctx, host)
+}
+
+// armedHoldView is the armed worker's hold view, or nil when no worker is
+// armed or the Phase 1 switch is off.
+func armedHoldView() *movesHoldView {
+ w := armedMovesWorker.Load()
+ if w == nil || !w.cfg.Enabled {
+ return nil
+ }
+ return w.holds
+}
+
+// withMovesHolds gives a reconcile pass the hold view's hooks, unless the
+// caller set its own. With the switch off it returns cfg unchanged: main.
+func withMovesHolds(cfg provision.ReconcileConfig) provision.ReconcileConfig {
+ v := armedHoldView()
+ if v == nil || cfg.Hold != nil || cfg.KeepParticipant != nil {
+ return cfg
+ }
+ cfg.Hold, cfg.KeepParticipant = v.hold, v.keep
+ if v.written != nil && cfg.HoldWritten == nil {
+ cfg.HoldWritten = v.written
+ }
+ return cfg
+}
+
+// armScheduleHolds wires the schedule halves to the hold view: one HostGate
+// shared by the runner, the converger (across both of the agent's converge
+// loops: there is one Converger) and the cut's drain, and the converger's
+// freeze. v nil (switch off) or sd nil (no scheduled work) leaves both as
+// main has them.
+func armScheduleHolds(sd *scheduleDeps, v *movesHoldView) {
+ if sd == nil || v == nil {
+ return
+ }
+ gate := &schedule.HostGate{}
+ sd.runner.Gate = gate
+ if sd.conv != nil {
+ sd.conv.Gate = gate
+ sd.conv.Frozen = v.frozen
+ }
+ v.gate.Store(gate)
+}
+
+// refreshMovesHolds builds the hold view once, outside a moves pass: the
+// agent's startup converge runs before the first moves pass, and an unbuilt
+// view freezes every host. A failed listing leaves it unbuilt; the first
+// moves pass that lists builds it.
+func refreshMovesHolds(ctx context.Context, s release.Store) {
+ v := armedHoldView()
+ if v == nil || s == nil {
+ return
+ }
+ recs, err := provision.ListAllRecords(ctx, s)
+ if err != nil {
+ log.Printf("moves: hold view: list records: %v; rows are kept and timers frozen until a moves pass lists them", err)
+ return
+ }
+ v.refresh(ctx, s, recs)
+}
+
+// holdStageRequester is the hold view's HoldWritten: the stage-requested
+// marker, under its own throttle, so root re-judges what it staged under
+// the new hold (registry.SetHold's contract, G10).
+func holdStageRequester(dd string) func(host string) {
+ req := markerRequester(dd, func(host, _ string, err error) {
+ if err != nil {
+ log.Printf("agent: %s: held for a move, but cannot request a root stage pass: %v", host, err)
+ return
+ }
+ log.Printf("agent: %s: held for a move; asked root to re-judge what it staged", host)
+ })
+ return func(host string) { req(host, "", nil) }
+}
diff --git a/internal/host/moveshold_test.go b/internal/host/moveshold_test.go
new file mode 100644
index 000000000..3bc892d4a
--- /dev/null
+++ b/internal/host/moveshold_test.go
@@ -0,0 +1,260 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/schedule"
+)
+
+const (
+ mhHost = "a.go.example.com"
+ mhID = "mv_abcdefghijklmnopqrstuvwxyz"
+ mhRbID = "rb_abcdefghijklmnopqrstuvwxyz"
+)
+
+// mhRecord is a Phase 1 record mid-move from ie to uk, in state with the
+// given phases; region is the record's region at that state.
+func mhRecord(region, state, cutPhase string) provision.Record {
+ return provision.Record{Schema: 1, Host: mhHost, App: "app", Account: "bab", Region: region, Channel: "stable",
+ Move: &provision.MoveState{ID: mhID, From: "ie", To: "uk", State: state, CutPhase: cutPhase,
+ Rollback: &provision.MoveRollback{ID: mhRbID}}}
+}
+
+// mhClaims is a claim reader answering kind with participants ie-box and
+// uk-box for mhHost; err makes every read fail.
+func mhClaims(kind string, err error) func(context.Context, release.Store, string) (hostclaim.Observed, error) {
+ return func(_ context.Context, _ release.Store, host string) (hostclaim.Observed, error) {
+ if err != nil {
+ return hostclaim.Observed{}, err
+ }
+ return hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host,
+ Op: hostclaim.Op{Kind: kind, ID: mhID, Participants: []string{"ie-box", "uk-box"}}}}, nil
+ }
+}
+
+func mhView(box, region, kind string, err error, recs ...provision.Record) *movesHoldView {
+ v := newMovesHoldView(box, region, mhClaims(kind, err), nil)
+ v.refresh(context.Background(), nil, recs)
+ return v
+}
+
+// hold's table (moves spec §5.2, §9.6, §19.1): the target holds its row
+// landing after the flip, the source moving from stopping, restoring in a
+// revert or a rollback, and the target releasing in a rollback. A box the
+// claim does not name, and any state with nothing held, answers no hold.
+func TestMovesHoldViewHold(t *testing.T) {
+ cases := []struct {
+ name string
+ box, region, kind string
+ rec provision.Record
+ wantStatus, wantOp string
+ wantOK bool
+ }{
+ {"source preparing", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MovePreparing, ""), "", "", false},
+ {"source cut requested", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveCutting, provision.CutRequested), "", "", false},
+ {"source cut admitted", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveCutting, provision.CutAdmitted), "", "", false},
+ {"source stopping", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveCutting, provision.CutStopping), "moving", mhID, true},
+ {"source archived", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveCutting, provision.CutArchived), "moving", mhID, true},
+ {"source after the flip", "ie-box", "ie", hostclaim.KindLanding, mhRecord("uk", provision.MoveLanding, provision.CutFlipped), "moving", mhID, true},
+ {"target after the flip", "uk-box", "uk", hostclaim.KindLanding, mhRecord("uk", provision.MoveLanding, provision.CutFlipped), "landing", mhID, true},
+ {"target committed", "uk-box", "uk", hostclaim.KindLanding, mhRecord("uk", provision.MoveCommitted, provision.CutFlipped), "", "", false},
+ {"source reverting", "ie-box", "ie", hostclaim.KindReverting, mhRecord("ie", provision.MoveReverting, provision.CutStopped), "restoring", mhID, true},
+ {"source aborting a revert", "ie-box", "ie", hostclaim.KindReverting, mhRecord("ie", provision.MoveAborting, ""), "restoring", mhID, true},
+ {"source aborting before the cut", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveAborting, ""), "", "", false},
+ {"source rolling back", "ie-box", "ie", hostclaim.KindRestoring, mhRecord("ie", provision.MoveRollingBack, provision.CutFlipped), "restoring", mhRbID, true},
+ {"target rolling back", "uk-box", "uk", hostclaim.KindRestoring, mhRecord("ie", provision.MoveRollingBack, provision.CutFlipped), "releasing", mhRbID, true},
+ {"target stuck", "uk-box", "uk", hostclaim.KindRestoring, mhRecord("ie", provision.MoveStuck, provision.CutFlipped), "releasing", mhRbID, true},
+ {"not a participant", "uk-box-2", "uk", hostclaim.KindLanding, mhRecord("uk", provision.MoveLanding, provision.CutFlipped), "", "", false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ v := mhView(c.box, c.region, c.kind, nil, c.rec)
+ status, op, ok := v.hold(c.rec)
+ if status != c.wantStatus || op != c.wantOp || ok != c.wantOK {
+ t.Fatalf("hold = (%q, %q, %v), want (%q, %q, %v)", status, op, ok, c.wantStatus, c.wantOp, c.wantOK)
+ }
+ })
+ }
+ // No move, or a legacy one: nothing held.
+ v := mhView("uk-box", "uk", hostclaim.KindLanding, nil)
+ if _, _, ok := v.hold(provision.Record{Host: mhHost}); ok {
+ t.Fatal("a record at rest is held")
+ }
+ if _, _, ok := v.hold(provision.Record{Host: mhHost, Move: &provision.MoveState{To: "uk", State: provision.MoveCutting}}); ok {
+ t.Fatal("a legacy move is held")
+ }
+}
+
+// Without a claim answer for the host (the view was built before the move,
+// or its claim would not read) hold falls back to the record's regions, and
+// never answers "no hold" for a state that holds: the row is never minted
+// ready. A rollback with no rollback id holds under the move id.
+func TestMovesHoldViewHoldFailsClosedWithoutTheClaim(t *testing.T) {
+ stale := mhView("uk-box", "uk", hostclaim.KindLanding, nil) // built from a list with no move in it
+ rec := mhRecord("uk", provision.MoveLanding, provision.CutFlipped)
+ if status, op, ok := stale.hold(rec); status != "landing" || op != mhID || !ok {
+ t.Fatalf("stale view: hold = (%q, %q, %v), want landing by the move", status, op, ok)
+ }
+ unread := mhView("ie-box", "ie", "", errors.New("store down"), mhRecord("ie", provision.MoveCutting, provision.CutStopping))
+ if status, _, ok := unread.hold(mhRecord("ie", provision.MoveCutting, provision.CutStopping)); status != "moving" || !ok {
+ t.Fatalf("unreadable claim: hold = (%q, %v), want moving", status, ok)
+ }
+ noRb := mhRecord("ie", provision.MoveRollingBack, provision.CutFlipped)
+ noRb.Move.Rollback = nil
+ if status, op, ok := stale.hold(noRb); status != "releasing" || op != mhID || !ok {
+ t.Fatalf("rollback with no id: hold = (%q, %q, %v), want releasing by the move", status, op, ok)
+ }
+ never := newMovesHoldView("uk-box", "uk", mhClaims(hostclaim.KindLanding, nil), nil) // never built
+ if status, _, ok := never.hold(rec); status != "landing" || !ok {
+ t.Fatalf("unbuilt view: hold = (%q, %v), want landing", status, ok)
+ }
+}
+
+// keep and frozen: a move-kind op freezes the host, and keeps it on a box
+// the op names as a participant. A claim that is serving does neither. Fail
+// closed: before the first build, and for a host whose claim would not read,
+// both answer true.
+func TestMovesHoldViewKeepAndFrozen(t *testing.T) {
+ rec := mhRecord("uk", provision.MoveLanding, provision.CutFlipped)
+ cases := []struct {
+ name, box, kind string
+ err error
+ keep, frozen bool
+ }{
+ {"moving, source", "ie-box", hostclaim.KindMoving, nil, true, true},
+ {"landing, source", "ie-box", hostclaim.KindLanding, nil, true, true},
+ {"landing, target", "uk-box", hostclaim.KindLanding, nil, true, true},
+ {"reverting, target", "uk-box", hostclaim.KindReverting, nil, true, true},
+ {"restoring, target", "uk-box", hostclaim.KindRestoring, nil, true, true},
+ {"landing, not a participant", "uk-box-2", hostclaim.KindLanding, nil, false, true},
+ {"serving", "ie-box", hostclaim.KindServing, nil, false, false},
+ {"relocating is region HA's", "ie-box", hostclaim.KindRelocating, nil, false, false},
+ {"unreadable claim", "ie-box", "", errors.New("store down"), true, true},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ v := mhView(c.box, "", c.kind, c.err, rec)
+ if got := v.keep(mhHost); got != c.keep {
+ t.Errorf("keep = %v, want %v", got, c.keep)
+ }
+ if got := v.frozen(mhHost); got != c.frozen {
+ t.Errorf("frozen = %v, want %v", got, c.frozen)
+ }
+ if v.keep("other.go.example.com") || v.frozen("other.go.example.com") {
+ t.Error("a host with no move is kept or frozen")
+ }
+ })
+ }
+ never := newMovesHoldView("ie-box", "ie", mhClaims(hostclaim.KindServing, nil), nil)
+ if !never.keep("any.go.example.com") || !never.frozen("any.go.example.com") {
+ t.Fatal("an unbuilt view does not fail closed")
+ }
+ // A later build that cannot list keeps the last answer: refresh is only
+ // called with a list, so a failed list is no refresh at all.
+ built := mhView("ie-box", "ie", hostclaim.KindMoving, nil, mhRecord("ie", provision.MoveCutting, provision.CutStopping))
+ if !built.keep(mhHost) {
+ t.Fatal("the participant is not kept")
+ }
+ built.refresh(context.Background(), nil, nil)
+ if built.keep(mhHost) || built.frozen(mhHost) {
+ t.Fatal("a host whose move ended is still kept or frozen")
+ }
+}
+
+// The view against a real claim in a store, through hostclaim.Read: the
+// door's moving claim names ie-box and uk-box.
+func TestMovesHoldViewReadsTheClaim(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ created := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+ inc, err := hostclaim.Incarnation("bab", "app", mhHost, created)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs, err := hostclaim.Update(ctx, s, hostclaim.Observed{Host: mhHost, Absent: true}, func(c *hostclaim.Claim) error {
+ c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "app", mhHost), Account: "bab", App: "app",
+ Incarnation: inc, CreatedAt: created, Location: "instances/" + mhHost + "/" + inc, Generation: "cur", Stamp: 1}
+ c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "ie-box"}
+ c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostclaim.Update(ctx, s, obs, hostclaim.BeginMove(mhID, "op", "ie-box", "enr-ie", "uk-box", "uk", created)); err != nil {
+ t.Fatal(err)
+ }
+ rec := mhRecord("ie", provision.MoveCutting, provision.CutStopping)
+ ie := newMovesHoldView("ie-box", "ie", hostclaim.Read, nil)
+ ie.refresh(ctx, s, []provision.Record{rec})
+ if !ie.keep(mhHost) || !ie.frozen(mhHost) {
+ t.Fatal("the source is not kept and frozen under the door's moving claim")
+ }
+ if status, op, ok := ie.hold(rec); status != "moving" || op != mhID || !ok {
+ t.Fatalf("hold = (%q, %q, %v), want moving by the move", status, op, ok)
+ }
+ other := newMovesHoldView("ie-box-2", "ie", hostclaim.Read, nil)
+ other.refresh(ctx, s, []provision.Record{rec})
+ if other.keep(mhHost) {
+ t.Fatal("a box the claim does not name is kept")
+ }
+}
+
+// The drain reaches the schedule wiring's gate once armScheduleHolds has
+// run, and is a no-op before (no scheduled work armed).
+func TestMovesHoldViewDrainsTheScheduleGate(t *testing.T) {
+ v := newMovesHoldView("ie-box", "ie", mhClaims(hostclaim.KindMoving, nil), nil)
+ if err := v.drainTimers(context.Background(), mhHost); err != nil {
+ t.Fatalf("drain with no schedule armed = %v", err)
+ }
+ sd := &scheduleDeps{runner: &schedule.Runner{}, conv: &schedule.Converger{}}
+ armScheduleHolds(sd, v)
+ if sd.runner.Gate == nil || sd.conv.Gate != sd.runner.Gate || sd.conv.Frozen == nil {
+ t.Fatalf("the schedule halves do not share one gate and the freeze: %+v %+v", sd.runner, sd.conv)
+ }
+ if v.gate.Load() != sd.runner.Gate {
+ t.Fatal("the view does not drain the runner's gate")
+ }
+ if err := v.drainTimers(context.Background(), mhHost); err != nil {
+ t.Fatalf("drain = %v", err)
+ }
+}
+
+// The switch off (moves.Enabled false) is main: no hold view, so reconcile
+// gets no Hold, KeepParticipant or HoldWritten, and the schedule halves no
+// gate and no freeze.
+func TestMovesHoldsAreMainWithTheSwitchOff(t *testing.T) {
+ prev := armedMovesWorker.Load()
+ t.Cleanup(func() { armedMovesWorker.Store(prev) })
+ off := &movesWorker{cfg: moveCfg{Enabled: false}, holds: newMovesHoldView("ie-box", "ie", hostclaim.Read, nil)}
+ armedMovesWorker.Store(off)
+ if armedHoldView() != nil {
+ t.Fatal("a hold view with the switch off")
+ }
+ cfg := withMovesHolds(provision.ReconcileConfig{})
+ 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{}}
+ armScheduleHolds(sd, armedHoldView())
+ if sd.runner.Gate != nil || sd.conv.Gate != nil || sd.conv.Frozen != nil {
+ t.Fatal("schedule hooks with the switch off")
+ }
+ armedMovesWorker.Store(nil)
+ if cfg := withMovesHolds(provision.ReconcileConfig{}); cfg.Hold != nil {
+ t.Fatal("reconcile hooks with no worker armed")
+ }
+
+ on := &movesWorker{cfg: moveCfg{Enabled: true}, holds: newMovesHoldView("ie-box", "ie", hostclaim.Read, func(string) {})}
+ armedMovesWorker.Store(on)
+ cfg = withMovesHolds(provision.ReconcileConfig{})
+ if cfg.Hold == nil || cfg.KeepParticipant == nil || cfg.HoldWritten == nil {
+ t.Fatal("no reconcile hooks with the switch on")
+ }
+}
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 045b90c84..2fb108a30 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -17,6 +17,7 @@ import (
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/layout"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
@@ -84,6 +85,10 @@ type movesWorker struct {
measured moves.Measurements
notices sync.Map // key → time.Time of the last line
strict atomic.Pointer[map[string]bool]
+ // holds is what reconcile and the schedule halves ask about this
+ // box's running moves (moveshold.go), rebuilt every pass. Nil with the
+ // switch off.
+ holds *movesHoldView
}
// staging is the memoized staging writer for a target bucket region.
@@ -221,6 +226,7 @@ func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry,
w := &movesWorker{mirrors: map[string]lease.MirrorStore{}}
if cfg.Enabled {
armPhase1(&cfg, w, hib, dataDir, reg, svc, eb, os.Getenv)
+ w.holds = newMovesHoldView(cfg.Box, cfg.Region, hostclaim.Read, holdStageRequester(dataDir))
}
w.cfg = cfg
armedMovesWorker.Store(w)
@@ -466,6 +472,9 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error {
}
if cfg.Enabled {
w.setStrict(recs)
+ if w.holds != nil {
+ w.holds.refresh(ctx, s, recs)
+ }
}
for _, rec := range recs {
if rec.Move != nil && !rec.Move.Legacy() {
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 75d64b1f5..96300a8ca 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -99,6 +99,27 @@ type ReconcileConfig struct {
// wake-time gate (activator.ExecBackend.ConfigGate) is what stops one
// starting. Nil: no gate, every record mints as before.
ConfigGate func(ctx context.Context, rt registry.Route) error
+ // Hold answers whether a record's row on this box belongs to a running
+ // move (moves spec §5.2, §9.6, §19 K9) and, if so, the hold status and
+ // 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
+ // 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)
+ // KeepParticipant answers whether a host's claim names this box as a
+ // participant of a running operation (claim spec §8, moves §9.6): the
+ // removal phase never removes such a row, whatever the record's region
+ // 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.
+ HoldWritten func(host string)
}
// InboundAny and InboundNone are the closed vocabulary ReconcileConfig.Inbound
@@ -1048,6 +1069,18 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// clobber, and a later pass converges it once the manifest reads.
seed, seedIsKnown := seedFor(rec.Account, rec.App)
existing, ok := byHost[rec.Host]
+ if cfg.Hold != nil {
+ if status, op, held := cfg.Hold(rec); held {
+ if !ok {
+ mintHeld(ctx, reg, cfg, rec, seed, status, op)
+ continue
+ }
+ // An existing row is held in place, then converged as
+ // usual: the owned branch's narrow setters never touch
+ // status or hold_op.
+ holdExisting(ctx, s, reg, cfg, rec, existing, status, op)
+ }
+ }
switch {
case !ok:
// A box that declared CARLOS_INBOUND=none (design §8) has told
@@ -1330,6 +1363,14 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
log.Printf("provision: not removing %s: its record exists but could not be read cleanly this pass", rt.Host)
continue
}
+ // A participant of a running operation keeps its row whatever
+ // the record's region says (moves spec §9.6, claim spec §8): a
+ // move's source after the flip, its target during a rollback.
+ // The operation deletes the row at its own finalization.
+ if cfg.KeepParticipant != nil && cfg.KeepParticipant(rt.Host) {
+ log.Printf("provision: not removing %s (%s): participant of a running operation", rt.Host, why)
+ continue
+ }
if cfg.DryRun {
log.Printf("provision: dry-run: would remove retired %s route %s (%s)", rt.Kind, rt.Host, why)
continue
@@ -1344,6 +1385,101 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
return nil
}
+// mintHeld mints a move participant's row held (moves spec §9.6 "mint"): the
+// record's own route at flat paths whatever the box's layout intent (Phase 1
+// lands flat, G11), with status and hold_op set by the INSERT, so it is never
+// ready for even one pass. The refusals a ready mint has (no-inbound box,
+// the config gate, a dry run) apply unchanged. A status that is not a hold,
+// or an empty op, mints nothing: a participant's row is never minted ready.
+func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record, seed, status, op string) {
+ if !registry.IsHold(status) || op == "" {
+ log.Printf("provision: %s: not minting: the move's hold %q by %q is not a hold", rec.Host, status, op)
+ return
+ }
+ if cfg.Inbound == InboundNone {
+ log.Printf("provision: refusing to mint held route %s (app %q acct %q): this box declares "+
+ "CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic", rec.Host, rec.App, rec.Account)
+ return
+ }
+ nr := routeFor(rec, cfg.DataDir, seed, false)
+ nr.Status, nr.HoldOp = status, op
+ if cfg.ConfigGate != nil {
+ if err := cfg.ConfigGate(ctx, nr); err != nil {
+ return
+ }
+ }
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would mint %s route %s held %s by %s (app %q acct %q channel %q)",
+ nr.Kind, rec.Host, status, op, rec.App, rec.Account, rec.Channel)
+ return
+ }
+ if err := reg.Upsert(nr); err != nil {
+ log.Printf("provision: mint held %s: %v", rec.Host, err)
+ return
+ }
+ log.Printf("provision: minted %s route %s held %s by %s (app %q acct %q)", nr.Kind, rec.Host, status, op, rec.App, rec.Account)
+ if cfg.HoldWritten != nil {
+ cfg.HoldWritten(rec.Host)
+ }
+}
+
+// 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
+// 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:
+//
+// - a recovery hold (restoring, releasing) is never written over an
+// unheld row. The recovering operation holds its own row as it enters
+// the recovery, and opens it while the record still names the
+// 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.
+//
+// SetHold 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 == "":
+ log.Printf("provision: %s: not holding: the move's hold %q by %q is not a hold", rec.Host, status, op)
+ return
+ case existing.HoldOp == op:
+ return
+ case existing.HoldOp != "":
+ log.Printf("provision: %s: held %s by %s, not by %s; leaving it", rec.Host, existing.Status, existing.HoldOp, op)
+ return
+ case status == "restoring" || status == "releasing":
+ return
+ case !existing.Provisioned || existing.App != rec.App || existing.Account != rec.Account:
+ log.Printf("provision: %s: the row is not this pass's (app %q acct %q); not holding it for %s", rec.Host, existing.App, existing.Account, op)
+ return
+ case cfg.DryRun:
+ 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)
+ }
+}
+
// gateWord renders a gate column for a log line: "open" or "set", never the
// hash — it has no business in a journal.
func gateWord(hash string) string {
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index 624517255..6a4ff8e4d 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -3150,3 +3150,206 @@ func TestReconcileAdoptsAHandAddedNestedRoute(t *testing.T) {
t.Errorf("adoption rewrote the nested paths: %+v", got)
}
}
+
+// ---- move holds (moves spec §5.2, §9.6, §19 K9) ----
+
+// movingRecord is acme with a Phase 1 move from ie to uk after the flip: the
+// record's region is already the target's.
+func movingRecord(sqid string) Record {
+ rec := acmeRecord(sqid)
+ rec.Region = "uk"
+ rec.Move = &MoveState{ID: "mv_acme", From: "ie", To: "uk", State: MoveLanding, LandingPhase: LandChecking,
+ RequestedAt: time.Now(), UpdatedAt: time.Now()}
+ return rec
+}
+
+// holdLog records the hold writes a pass reports through HoldWritten.
+type holdLog struct{ hosts []string }
+
+func (h *holdLog) written(host string) { h.hosts = append(h.hosts, host) }
+
+// Review Focus 5: on the target the landing row is minted held and never
+// ready, at flat paths whatever the box's layout intent says (G11); on the
+// source, a participant's moving row survives the record naming another
+// region, and without the participant answer it is removed as today.
+func TestReconcileHoldsParticipantRows(t *testing.T) {
+ s, sqid := setup(t)
+ rec := movingRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ hold := func(r Record) (string, string, bool) {
+ if r.Host == rec.Host && r.Move != nil {
+ return "landing", r.Move.ID, true
+ }
+ return "", "", false
+ }
+
+ // The uk box: minted held, twice over, never ready; flat paths.
+ uk := regWith(t)
+ var wrote holdLog
+ ukCfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", Hold: hold, HoldWritten: wrote.written,
+ Layout: func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil }}
+ for pass := 1; pass <= 2; pass++ {
+ if err := Reconcile(ctx, s, uk, ukCfg); err != nil {
+ t.Fatal(err)
+ }
+ got := mustGet(t, uk, rec.Host)
+ if got.Status != "landing" || got.HoldOp != "mv_acme" {
+ t.Fatalf("pass %d: the uk row is %q held by %q, want landing held by mv_acme", pass, got.Status, got.HoldOp)
+ }
+ if got.DBPath != DefaultDBPath("/data", rec.Host) || got.Socket != DefaultSocket(rec.Host) {
+ t.Fatalf("pass %d: the held row is not flat: db %q socket %q", pass, got.DBPath, got.Socket)
+ }
+ }
+ if len(wrote.hosts) != 1 || wrote.hosts[0] != rec.Host {
+ t.Fatalf("hold writes reported = %v, want the one mint", wrote.hosts)
+ }
+
+ // The ie box: the source's moving row, held by the move.
+ ieRow := routeFor(acmeRecord(sqid), "/data", `["seed"]`, false)
+ ieRow.Status, ieRow.HoldOp = "moving", "mv_acme"
+ ie := regWith(t, ieRow)
+ keep := true
+ ieCfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data",
+ KeepParticipant: func(host string) bool { return keep && host == rec.Host }}
+ if err := Reconcile(ctx, s, ie, ieCfg); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustGet(t, ie, rec.Host); got.Status != "moving" || got.HoldOp != "mv_acme" {
+ t.Fatalf("the participant's row changed: %+v", got)
+ }
+ keep = false
+ if err := Reconcile(ctx, s, ie, ieCfg); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := ie.Get(rec.Host); err != nil || rt != nil {
+ t.Fatalf("without the participant answer the row was not removed: %+v %v", rt, err)
+ }
+}
+
+// G10: Upsert cannot hold an existing row, so Hold on an unheld row of this
+// pass's own is SetHold, and reported; a row already held by the same op is
+// left as is, unreported; a row held by another op is left alone; a row this
+// pass does not own is not taken into a move.
+func TestReconcileHoldsAnExistingRow(t *testing.T) {
+ s, sqid := setup(t)
+ rec := movingRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ owned := routeFor(rec, "/data", `["seed"]`, false)
+ cases := []struct {
+ name string
+ row registry.Route
+ wantStatus, wantOp string
+ reported bool
+ }{
+ {"unheld, owned", owned, "landing", "mv_acme", true},
+ {"held by this op", withHold(owned, "landing", "mv_acme"), "landing", "mv_acme", false},
+ {"held by another op", withHold(owned, "restoring", "rb_other"), "restoring", "rb_other", false},
+ {"not this pass's row", registry.Route{Host: rec.Host, Kind: "instance", App: "other", Status: "ready", Addr: "127.0.0.1:9"}, "ready", "", false},
+ // A recovery hold is written by the operation itself, which opens its
+ // own row while the record still names the recovery (§7.4 reverting
+ // probed, §7.8 opened): an unheld row there is one it opened.
+ {"unheld, a recovery hold", owned, "ready", "", false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ reg := regWith(t, c.row)
+ var wrote holdLog
+ status := "landing"
+ if strings.Contains(c.name, "recovery") {
+ status = "restoring"
+ }
+ cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written,
+ Hold: func(r Record) (string, string, bool) { return status, r.Move.ID, r.Move.State == MoveLanding }}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ got := mustGet(t, reg, rec.Host)
+ if got.Status != c.wantStatus || got.HoldOp != c.wantOp {
+ t.Fatalf("row is %q held by %q, want %q held by %q", got.Status, got.HoldOp, c.wantStatus, c.wantOp)
+ }
+ if reported := len(wrote.hosts) > 0; reported != c.reported {
+ t.Fatalf("hold write reported = %v, want %v", wrote.hosts, c.reported)
+ }
+ })
+ }
+}
+
+func withHold(rt registry.Route, status, op string) registry.Route {
+ rt.Status, rt.HoldOp = status, op
+ return rt
+}
+
+// A dry run reports a held mint and writes nothing.
+func TestReconcileDryRunWritesNoHold(t *testing.T) {
+ s, sqid := setup(t)
+ rec := movingRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ var wrote holdLog
+ cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", DryRun: true, HoldWritten: wrote.written,
+ Hold: func(Record) (string, string, bool) { return "landing", "mv_acme", true }}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if rt, _ := reg.Get(rec.Host); rt != nil || len(wrote.hosts) != 0 {
+ t.Fatalf("a dry run wrote: %+v %v", rt, wrote.hosts)
+ }
+}
+
+// changingStore answers the record's key with first, then with every later
+// read with later: the record moved on between the pass's listing and its
+// hold write.
+type changingStore struct {
+ release.Store
+ key string
+ reads int
+ first, later []byte
+}
+
+func (c *changingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == c.key {
+ c.reads++
+ body := c.later
+ if c.reads == 1 {
+ body = c.first
+ }
+ return body, fmt.Sprintf("etag-%d", c.reads), nil
+ }
+ return c.Store.Get(ctx, key)
+}
+
+// A hold write on an existing row re-reads the record first: the target
+// opens its row right after its commit, and the pass's listing (read before
+// the routes) can still say landing. A record that has moved on is not held
+// again.
+func TestReconcileHoldRereadsTheRecordBeforeHoldingARow(t *testing.T) {
+ s, sqid := setup(t)
+ rec := movingRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ committed := rec
+ m := *rec.Move
+ m.State = MoveCommitted
+ committed.Move = &m
+ cs := &changingStore{Store: s, key: RecordKey(rec.Account, rec.App, rec.Host), first: mustJSON(t, rec), later: mustJSON(t, committed)}
+ reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) // the target's row, opened
+ 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 }}
+ if err := Reconcile(ctx, cs, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if cs.reads < 2 {
+ t.Fatalf("the record was read %d time(s); the hold write did not re-read it", cs.reads)
+ }
+ if got := mustGet(t, reg, rec.Host); got.Status != "ready" || got.HoldOp != "" || len(wrote.hosts) != 0 {
+ t.Fatalf("an opened row was held again from a stale listing: %+v %v", got, wrote.hosts)
+ }
+}
diff --git a/internal/schedule/converge.go b/internal/schedule/converge.go
index 687eaf201..534f1da0e 100644
--- a/internal/schedule/converge.go
+++ b/internal/schedule/converge.go
@@ -37,6 +37,18 @@ type Converger struct {
Report func(rt registry.Route, st Status) // called for every host whose rows changed or that is unsupported; nil = no reports
Idle time.Duration // the activator's idle window; zero when no hibernation is armed on this box
HostWrites hostwrite.Config
+ // Frozen answers whether a move holds host's claim (moves spec §9.4,
+ // §19 K9): its timers are neither upserted nor pruned. Nil: none is.
+ Frozen func(host string) bool
+ // Gate is the per-host gate shared with the runner and a move's drain;
+ // convergeHost holds it across its Frozen check and its writes. Nil:
+ // no gate, main's converger.
+ Gate *HostGate
+
+ // afterFrozenCheck is the tests' pause inside the gate, after the
+ // Frozen check (TestConvergerCannotWriteAfterTheBarrier). Nil in the
+ // agent.
+ afterFrozenCheck func(host string)
declEtag map[[2]string]string // (acct, app) → last etag seen
declDoc map[[2]string]Declaration
@@ -94,10 +106,20 @@ func (c *Converger) Pass(ctx context.Context) error {
return err
}
now := c.Now()
+ // alive is every row, held ones included, so PruneTimers never treats a
+ // frozen host's rows as orphans. frozenAny holds back the table-wide
+ // prune itself while a move has frozen any host here: its retention leg
+ // would otherwise delete a frozen host's finished once-rows. The
+ // runner's daily sweep still runs (it cannot be frozen; the timers
+ // digest leaves that history out, joint N14), so nothing accumulates.
alive := map[string]bool{}
+ frozenAny := false
byApp := map[[2]string][]registry.Route{}
for _, rt := range routes {
alive[rt.Host] = true
+ if c.Frozen != nil && !frozenAny && c.Frozen(rt.Host) {
+ frozenAny = true
+ }
if rt.Kind != "instance" {
continue
}
@@ -113,7 +135,7 @@ func (c *Converger) Pass(ctx context.Context) error {
errs = append(errs, fmt.Errorf("%s/%s: %w", k[0], k[1], err))
}
}
- if !c.HostWrites.Enabled {
+ if !c.HostWrites.Enabled && !frozenAny {
if n, err := c.Reg.PruneTimers(alive, now.Add(-onceRetention).Unix()); err != nil {
errs = append(errs, err)
} else if n > 0 {
@@ -209,7 +231,24 @@ func (c *Converger) convergeApp(ctx context.Context, acct, app string, rts []reg
// convergeHost returns whether anything about the host's rows changed (or
// the host is unsupported and should be reported so).
+//
+// A host a move has frozen (Frozen, moves spec §9.4 and §19 K9) is left
+// exactly as it is: nothing upserted, nothing pruned, a run signal not
+// fired. The check and every write below happen inside the host's gate, so
+// the cut's drain (HostGate.Drain after the hold) waits for a convergence
+// that checked before the freeze, and one that starts after the drain sees
+// it. A host the gate refuses is being drained: skipped the same way.
func (c *Converger) convergeHost(ctx context.Context, rt registry.Route, decl Declaration, fireName string, now time.Time) (bool, error) {
+ if !c.Gate.tryEnter(rt.Host) {
+ return false, nil
+ }
+ defer c.Gate.leave(rt.Host)
+ if c.Frozen != nil && c.Frozen(rt.Host) {
+ return false, nil
+ }
+ if c.afterFrozenCheck != nil {
+ c.afterFrozenCheck(rt.Host)
+ }
var before []registry.Timer
if c.HostWrites.Enabled {
err := withTimerWork(ctx, func() error {
diff --git a/internal/schedule/converge_test.go b/internal/schedule/converge_test.go
index 95603d9e4..6c8bb2694 100644
--- a/internal/schedule/converge_test.go
+++ b/internal/schedule/converge_test.go
@@ -606,3 +606,109 @@ func scheduleTimerRegistration(t *testing.T, reg *registry.Registry, host, name
}
return row.Registration
}
+
+// The converger and the cut's barrier: a convergeHost paused after its
+// Frozen check, inside the gate, holds the drain until it has finished
+// writing; a converger that starts after the drain sees Frozen and writes
+// nothing, so the timers snapshot taken after the drain equals the table.
+func TestConvergerCannotWriteAfterTheBarrier(t *testing.T) {
+ reg, st := convergeFixture(t)
+ ctx := context.Background()
+ now := at("2026-08-23T13:07:00Z")
+ const host = "a.bab.oncarlos.com"
+ UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now)
+ gate := &HostGate{}
+ var frozen sync.Map
+ c := NewConverger(reg, st, "box1", nil)
+ c.Now = func() time.Time { return now }
+ c.Gate = gate
+ c.Frozen = func(h string) bool { _, ok := frozen.Load(h); return ok }
+ reached, resume := make(chan struct{}), make(chan struct{})
+ var once sync.Once
+ c.afterFrozenCheck = func(h string) {
+ if h == host {
+ once.Do(func() { close(reached); <-resume })
+ }
+ }
+ passed := make(chan error, 1)
+ go func() { passed <- c.Pass(ctx) }()
+ <-reached
+
+ // The cut: hold, freeze, drain. The paused convergence holds the drain.
+ if err := reg.SetHold(host, "moving", "mv_a"); err != nil {
+ t.Fatal(err)
+ }
+ frozen.Store(host, true)
+ drained := make(chan error, 1)
+ go func() { drained <- gate.Drain(ctx, host) }()
+ select {
+ case err := <-drained:
+ t.Fatalf("the drain returned (%v) while a convergence was inside the gate", err)
+ case <-time.After(50 * time.Millisecond):
+ }
+ close(resume)
+ if err := <-drained; err != nil {
+ t.Fatal(err)
+ }
+ snap, err := reg.TimersFor(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := <-passed; err != nil {
+ t.Fatal(err)
+ }
+ if len(snap) != 1 || snap[0].Name != "sync" {
+ t.Fatalf("the paused convergence's write is not in the snapshot: %+v", snap)
+ }
+
+ // A later pass, with the declaration changed: frozen, it writes nothing.
+ UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "nightly", Every: "24h", Path: "/jobs/nightly"}, now)
+ if _, _, err := RemoveSchedule(ctx, st, "bab", "app", "sync", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.TimersFor(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != len(snap) || got[0] != snap[0] {
+ t.Fatalf("the table moved after the barrier:\n got %+v\nwant %+v", got, snap)
+ }
+ // The other host of the app, not frozen, converged as usual.
+ if _, err := reg.GetTimer("b.bab.oncarlos.com", "nightly"); err != nil {
+ t.Fatalf("an unfrozen host stopped converging: %v", err)
+ }
+}
+
+// While any routed host is frozen, Pass leaves the table-wide prune alone,
+// so a frozen host's finished once-rows stay as the snapshot saw them; the
+// runner's own daily sweep is the one prune that is not frozen (joint N14).
+func TestConvergerSkipsTheTableWidePruneWhileAHostIsFrozen(t *testing.T) {
+ reg, st := convergeFixture(t)
+ ctx := context.Background()
+ now := at("2026-08-23T13:07:00Z")
+ const host = "a.bab.oncarlos.com"
+ 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)
+ }
+ c := NewConverger(reg, st, "box1", nil)
+ c.Now = func() time.Time { return now }
+ c.Frozen = func(h string) bool { return h == host }
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if tm, _ := reg.GetTimer(host, "done"); tm == nil {
+ t.Fatal("the frozen host's finished once-row was pruned")
+ }
+ c.Frozen = func(string) bool { return false }
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if tm, _ := reg.GetTimer(host, "done"); tm != nil {
+ t.Fatalf("the prune did not resume after the freeze: %+v", tm)
+ }
+}
diff --git a/internal/schedule/hostgate.go b/internal/schedule/hostgate.go
new file mode 100644
index 000000000..9ed8cea0d
--- /dev/null
+++ b/internal/schedule/hostgate.go
@@ -0,0 +1,86 @@
+package schedule
+
+import (
+ "context"
+ "sync"
+)
+
+// HostGate is the one per-host gate shared by the three writers of a host's
+// timer rows (moves spec §9.4, round-2 finding 21): the runner holds a host's
+// read side for the whole of a delivery, the converger holds it across its
+// Frozen check and its writes, and a move's drain takes the write side once
+// the row is held. A drain therefore returns only when no delivery or
+// convergence that could have missed the hold is still running, and from
+// then on the hold check inside the gate refuses both.
+//
+// Entry is TryRLock, never RLock: Go's RWMutex blocks new readers once a
+// writer waits, and a drain can wait for a delivery of up to DeliveryTimeout.
+// A blocking entry would park the tick loop, and with it every other host's
+// timers, or the converger's pass, behind one held host. A refused entry is
+// a host being drained, which is a host about to be frozen: it is skipped
+// this tick or pass, with no bookkeeping.
+//
+// A nil *HostGate is inert (every entry succeeds, a drain returns at once):
+// the runner and converger with moves off are main's.
+type HostGate struct {
+ mu sync.Mutex
+ hosts map[string]*sync.RWMutex
+}
+
+func (g *HostGate) lock(host string) *sync.RWMutex {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.hosts == nil {
+ g.hosts = map[string]*sync.RWMutex{}
+ }
+ l := g.hosts[host]
+ if l == nil {
+ l = &sync.RWMutex{}
+ g.hosts[host] = l
+ }
+ return l
+}
+
+// tryEnter takes host's read side without waiting. false: a drain holds the
+// host or waits for it.
+func (g *HostGate) tryEnter(host string) bool {
+ if g == nil {
+ return true
+ }
+ return g.lock(host).TryRLock()
+}
+
+// leave ends an entry tryEnter granted.
+func (g *HostGate) leave(host string) {
+ if g == nil {
+ return
+ }
+ g.lock(host).RUnlock()
+}
+
+// Drain waits until every delivery and convergence in flight for host has
+// finished, then lets the host go: the cut's drain, called after SetHold
+// (moves spec §9.4 "drained"). Work that starts afterwards re-reads the row
+// or the freeze inside the gate and stops. ctx bounds the wait; an ended
+// context returns its error, and the pending drain still completes in the
+// background (until then the host's new work is refused, which is what a
+// held host wants anyway).
+func (g *HostGate) Drain(ctx context.Context, host string) error {
+ if g == nil {
+ return nil
+ }
+ l := g.lock(host)
+ done := make(chan struct{})
+ go func() {
+ // The barrier is the acquisition itself: nothing runs inside it.
+ l.Lock()
+ l.Unlock()
+ close(done)
+ }()
+ select {
+ case <-done:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
diff --git a/internal/schedule/hostgate_test.go b/internal/schedule/hostgate_test.go
new file mode 100644
index 000000000..58e141783
--- /dev/null
+++ b/internal/schedule/hostgate_test.go
@@ -0,0 +1,74 @@
+package schedule
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+// A nil gate is main's runner and converger: every host enters, and a drain
+// has nothing to wait for.
+func TestNilHostGateIsInert(t *testing.T) {
+ var g *HostGate
+ if !g.tryEnter("a") {
+ t.Fatal("a nil gate refused a host")
+ }
+ g.leave("a")
+ if err := g.Drain(context.Background(), "a"); err != nil {
+ t.Fatalf("a nil gate's drain = %v", err)
+ }
+}
+
+// The drain waits for the host's in-flight work, refuses new work while it
+// waits, and leaves the host open once it returns. Other hosts are never
+// held up.
+func TestHostGateDrainWaitsForInFlightWork(t *testing.T) {
+ g := &HostGate{}
+ if !g.tryEnter("a") {
+ t.Fatal("first entry refused")
+ }
+ done := make(chan error, 1)
+ go func() { done <- g.Drain(context.Background(), "a") }()
+ select {
+ case err := <-done:
+ t.Fatalf("the drain returned (%v) while work on the host was in flight", err)
+ case <-time.After(50 * time.Millisecond):
+ }
+ // A drain is waiting: new work on the host is refused, other hosts enter.
+ deadline := time.Now().Add(time.Second)
+ for g.tryEnter("a") {
+ g.leave("a")
+ if time.Now().After(deadline) {
+ t.Fatal("work entered the host while a drain was waiting")
+ }
+ time.Sleep(time.Millisecond)
+ }
+ if !g.tryEnter("b") {
+ t.Fatal("a drain on one host refused another host")
+ }
+ g.leave("b")
+ g.leave("a")
+ if err := <-done; err != nil {
+ t.Fatalf("drain = %v", err)
+ }
+ if !g.tryEnter("a") {
+ t.Fatal("the host stayed closed after the drain")
+ }
+ g.leave("a")
+}
+
+// A drain whose context ends returns the context's error; the host's work
+// is unaffected.
+func TestHostGateDrainHonoursItsContext(t *testing.T) {
+ g := &HostGate{}
+ if !g.tryEnter("a") {
+ t.Fatal("entry refused")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
+ defer cancel()
+ if err := g.Drain(ctx, "a"); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("drain = %v, want the deadline", err)
+ }
+ g.leave("a")
+}
diff --git a/internal/schedule/runner.go b/internal/schedule/runner.go
index 23b3594d1..e6631c955 100644
--- a/internal/schedule/runner.go
+++ b/internal/schedule/runner.go
@@ -62,6 +62,14 @@ type Runner struct {
Build string
Idle time.Duration // the activator's idle window; zero when no hibernation is armed on this box
HostWrites hostwrite.Config
+ // Gate is the per-host gate shared with the converger and a move's
+ // drain (HostGate): claim enters it for the whole delivery. Nil: no
+ // gate, main's runner.
+ Gate *HostGate
+
+ // beforeClaim is the tests' pause between a tick's route read and its
+ // claim (TestDrainRaceCannotAdvanceBookkeeping). Nil in the agent.
+ beforeClaim func(host string)
// Per-host work is DETACHED from the tick: Tick launches one goroutine
// per host with due rows and returns without waiting for any of them.
@@ -164,8 +172,37 @@ func (r *Runner) Tick(ctx context.Context) {
// Converge prunes these; until it does, they are not ours to fire.
continue
}
+ // A held row (moves spec §5.2, §9.4 "frozen") is skipped with no
+ // bookkeeping at all: before this, its delivery was refused by the
+ // 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) {
+ continue
+ }
+ if r.beforeClaim != nil {
+ r.beforeClaim(host)
+ }
if !r.claim(host) {
- continue // a previous tick's delivery to this host is still running
+ 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)
+ }
+ 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
@@ -182,7 +219,13 @@ func (r *Runner) Tick(ctx context.Context) {
defer r.inflight.Done()
defer func() { <-r.slots() }()
defer r.release(host)
- for _, t := range timers {
+ for i, t := range timers {
+ // 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) {
+ break
+ }
r.fire(ctx, rt, t)
}
if r.Report != nil {
@@ -336,13 +379,17 @@ type SocketRetainer interface {
RetainSockets(live map[string]bool)
}
-// claim marks host in flight; false if it already was.
+// 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 {
r.mu.Lock()
defer r.mu.Unlock()
if r.busy[host] {
return false
}
+ if !r.Gate.tryEnter(host) {
+ return false
+ }
if r.busy == nil {
r.busy = map[string]bool{}
}
@@ -350,12 +397,34 @@ func (r *Runner) claim(host string) bool {
return true
}
+// Busy reports whether a delivery to host is in flight: the per-host claim
+// (moves spec §9.4 "drained"). A snapshot: the cut's drain is
+// Gate.Drain, which also waits for a claim taken after this answer.
+func (r *Runner) Busy(host string) bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.busy[host]
+}
+
func (r *Runner) release(host string) {
r.mu.Lock()
delete(r.busy, host)
+ r.Gate.leave(host)
r.mu.Unlock()
}
+// held reports whether host's row is held by an operation now, or cannot be
+// read: either way the rest of the host's timers wait for the next tick.
+func (r *Runner) held(ctx context.Context, host string) bool {
+ var rt *registry.Route
+ err := withTimerWork(ctx, func() error {
+ var readErr error
+ rt, readErr = r.Reg.Get(host)
+ return readErr
+ })
+ return err != nil || rt == nil || registry.IsHold(rt.Status)
+}
+
// slots is the cross-tick semaphore, made on first use so a zero Runner
// literal (every test, and the agent's) needs no constructor.
func (r *Runner) slots() chan struct{} {
diff --git a/internal/schedule/runner_test.go b/internal/schedule/runner_test.go
index 3602c996f..e4cfcac63 100644
--- a/internal/schedule/runner_test.go
+++ b/internal/schedule/runner_test.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
)
type fakeDeliverer struct {
@@ -976,3 +977,213 @@ func TestNonpositiveOccurrenceDeliveredAndRetriedWithOriginalTimestamp(t *testin
})
}
}
+
+// ---- move holds (moves spec §9.4) ----
+
+// holdHost is the runner fixture's host, held by a move in these tests.
+const holdHost = "a.bab.oncarlos.com"
+
+// timersOf is the host's whole timers table, for before/after comparisons.
+func timersOf(t *testing.T, reg *registry.Registry, host string) []registry.Timer {
+ t.Helper()
+ rows, err := reg.TimersFor(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rows
+}
+
+func sameTimers(a, b []registry.Timer) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// Witness wTimerFreeze: a held row's due timers are not delivered and their
+// bookkeeping does not move (before this, a refused delivery advanced
+// attempts); a frozen host's declared schedule is neither upserted nor
+// pruned by Converger.Pass; once the hold lifts, both resume.
+func TestHeldRowsFreezeTimers(t *testing.T) {
+ d := &fakeDeliverer{status: 503} // a delivery would retry and advance attempts
+ reg, r := runnerFixture(t, d)
+ now := at("2026-08-23T13:07:00Z")
+ r.Now = func() time.Time { return now }
+ r.Gate = &HostGate{}
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1, DueAt: 1})
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "stale", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/s", NextAt: now.Unix() - 5})
+ if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil {
+ t.Fatal(err)
+ }
+ before := timersOf(t, reg, holdHost)
+ r.tick()
+ if len(d.calls) != 0 {
+ t.Fatalf("a held row's timers were delivered %d time(s)", len(d.calls))
+ }
+ if got := timersOf(t, reg, holdHost); !sameTimers(got, before) {
+ t.Fatalf("a held row's bookkeeping moved:\n got %+v\nwant %+v", got, before)
+ }
+ if r.Busy(holdHost) {
+ t.Fatal("the runner kept a claim on a held host")
+ }
+
+ // The converger, with the host frozen: the declaration is not upserted
+ // and the undeclared "stale" schedule is not pruned.
+ st := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now)
+ frozen := true
+ c := NewConverger(reg, st, "box1", nil)
+ c.Now = func() time.Time { return now }
+ c.Gate = r.Gate
+ c.Frozen = func(host string) bool { return host == holdHost && frozen }
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if got := timersOf(t, reg, holdHost); !sameTimers(got, before) {
+ t.Fatalf("Converger.Pass wrote a frozen host's timers:\n got %+v\nwant %+v", got, before)
+ }
+
+ // The hold lifts: the runner delivers and the converger converges.
+ if ok, err := reg.ReleaseHold(holdHost, "mv_a"); err != nil || !ok {
+ t.Fatalf("release hold = %v %v", ok, err)
+ }
+ frozen = false
+ r.tick()
+ if len(d.calls) == 0 {
+ t.Fatal("nothing was delivered after the hold lifted")
+ }
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := reg.GetTimer(holdHost, "sync"); err != nil {
+ t.Fatalf("the declaration did not converge after the freeze: %v", err)
+ }
+ if tm, _ := reg.GetTimer(holdHost, "stale"); tm != nil {
+ t.Fatalf("the undeclared schedule survived the converge after the freeze: %+v", tm)
+ }
+}
+
+// A hold written while a host's goroutine is delivering stops the rest of
+// that host's due timers: the next timer re-reads the row first.
+func TestHoldMidDeliveryStopsTheRest(t *testing.T) {
+ reg, r := runnerFixture(t, nil)
+ now := at("2026-08-23T13:07:00Z")
+ r.Now = func() time.Time { return now }
+ var delivered []string
+ r.Deliver = deliverFunc(func(rt registry.Route, req *http.Request) (int, error) {
+ delivered = append(delivered, req.Header.Get("X-Carlos-Schedule"))
+ if err := reg.SetHold(rt.Host, "moving", "mv_a"); err != nil {
+ t.Error(err)
+ }
+ return 204, nil
+ })
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "first", Kind: KindOnce, Spec: "at 1", Path: "/1", NextAt: 1})
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "second", Kind: KindOnce, Spec: "at 2", Path: "/2", NextAt: 2})
+ r.tick()
+ if len(delivered) != 1 || delivered[0] != "first" {
+ t.Fatalf("delivered %v, want only the first", delivered)
+ }
+ second, _ := reg.GetTimer(holdHost, "second")
+ if second.NextAt != 2 || second.Attempts != 0 || second.LastAt != 0 {
+ t.Fatalf("the second timer's bookkeeping moved under the hold: %+v", second)
+ }
+}
+
+type deliverFunc func(rt registry.Route, req *http.Request) (int, error)
+
+func (f deliverFunc) Deliver(_ context.Context, rt registry.Route, req *http.Request) (int, error) {
+ return f(rt, req)
+}
+
+// The cut's drain race: a tick reads the route while it is ready and pauses
+// before its claim; the cut sets the hold and finds the host not busy, and
+// its drain returns; the tick resumes, claims, re-reads the row inside the
+// claim, sees the hold and lets go. Nothing is delivered and attempts does
+// not move.
+func TestDrainRaceCannotAdvanceBookkeeping(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 }
+ r.Gate = &HostGate{}
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1})
+ before := timersOf(t, reg, holdHost)
+ reached, resume := make(chan struct{}), make(chan struct{})
+ r.beforeClaim = func(host string) {
+ if host == holdHost {
+ close(reached)
+ <-resume
+ }
+ }
+ ticked := make(chan struct{})
+ go func() { r.Tick(context.Background()); close(ticked) }()
+ <-reached
+ // The cut: hold, then drain.
+ if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil {
+ t.Fatal(err)
+ }
+ if r.Busy(holdHost) {
+ t.Fatal("the paused tick already held the claim")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := r.Gate.Drain(ctx, holdHost); err != nil {
+ t.Fatalf("drain = %v", err)
+ }
+ close(resume)
+ <-ticked
+ r.wait()
+ if len(d.calls) != 0 {
+ t.Fatalf("the paused tick delivered %d time(s) after the drain", len(d.calls))
+ }
+ if got := timersOf(t, reg, holdHost); !sameTimers(got, before) {
+ t.Fatalf("the paused tick moved bookkeeping after the drain:\n got %+v\nwant %+v", got, before)
+ }
+ if r.Busy(holdHost) {
+ t.Fatal("the tick kept its claim after seeing the hold")
+ }
+}
+
+// A delivery in flight when the hold is written holds up the drain until it
+// has recorded its outcome: the snapshot after the drain sees the
+// delivery's bookkeeping, never a write that lands after it.
+func TestDrainWaitsForTheDeliveryInFlight(t *testing.T) {
+ d := &blockingDeliverer{fakeDeliverer: fakeDeliverer{status: 204}, slow: holdHost, gate: make(chan struct{})}
+ reg, r := runnerFixture(t, d)
+ now := at("2026-08-23T13:07:00Z")
+ r.Now = func() time.Time { return now }
+ r.Gate = &HostGate{}
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1})
+ r.Tick(context.Background())
+ if !r.Busy(holdHost) {
+ t.Fatal("the delivery is not in flight")
+ }
+ if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil {
+ t.Fatal(err)
+ }
+ drained := make(chan error, 1)
+ go func() { drained <- r.Gate.Drain(context.Background(), holdHost) }()
+ select {
+ case err := <-drained:
+ t.Fatalf("the drain returned (%v) with a delivery in flight", err)
+ case <-time.After(50 * time.Millisecond):
+ }
+ close(d.gate)
+ if err := <-drained; err != nil {
+ t.Fatal(err)
+ }
+ snap := timersOf(t, reg, holdHost)
+ r.wait()
+ if snap[0].LastStatus != "204" || snap[0].NextAt != 0 {
+ t.Fatalf("the drain returned before the delivery recorded: %+v", snap[0])
+ }
+ if got := timersOf(t, reg, holdHost); !sameTimers(got, snap) {
+ t.Fatalf("the table changed after the drain:\n got %+v\nwant %+v", got, snap)
+ }
+}