moves-phase1-3
Squashed from moves-phase1-3 into main.
11 files changed,
+814
−4
approved_moves_copy_test.go+2 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+6 −1docs/superpowers/specs/2026-10-02-move-any-app-design.md+4 −1internal/activator/claimloop_linux_test.go+132 −0internal/activator/claimwindow_test.go+88 −0internal/hostclaim/move.go+55 −2internal/hostclaim/move_test.go+147 −0internal/moves/probe.go+38 −0internal/moves/probe_test.go+30 −0internal/provision/move_test.go+177 −0internal/provision/provision.go+135 −0
CI passed — run details
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 04db0fd77..ef0477bd0 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -70,6 +70,7 @@ var movesCopyIDs = []string{ |
| "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168", |
| "MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174", |
| "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181", |
| + "MV-N182", "MV-N183", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
| @@ -620,6 +621,7 @@ var movesScanFiles = []string{ |
| "internal/moves/journal.go", |
| "internal/moves/lease.go", |
| "internal/moves/ltxbuild.go", |
| + "internal/moves/probe.go", |
| "internal/moves/readiness.go", |
| "internal/moves/seal.go", |
| "internal/moves/sealedclient.go", |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 57c2347bf..40607c9e1 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -231,7 +231,12 @@ Columns: id, file, text, status. |
| ## Plan 3 |
| -(appended by Plan 3 Task 9) |
| +(Task 2's rows, then the rest appended by Plan 3 Task 9) |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N182 | internal/provision/provision.go | the probe ran out of attempts | pending | |
| +| MV-N183 | internal/provision/provision.go | the commit came after the deadline | pending | |
| ## Plan 4 |
diff --git a/docs/superpowers/specs/2026-10-02-move-any-app-design.md b/docs/superpowers/specs/2026-10-02-move-any-app-design.md| index c194bf885..36c92b479 100644 |
| --- a/docs/superpowers/specs/2026-10-02-move-any-app-design.md |
| +++ b/docs/superpowers/specs/2026-10-02-move-any-app-design.md |
| @@ -2885,7 +2885,10 @@ runs anywhere. It converges when the target returns (INV-3). |
| ### 7.9 Commit and finalization (codex 6) |
| 1. **Commit** (target): record CAS `probed` → `committed`, conditional on |
| - `now ≤ cut_deadline` (§7.4); then the claim follows (§7.5): phase |
| + `now ≤ cut_deadline` (§7.4). The commit is also refused once a rollback |
| + reason is recorded, and it is bounded by the earlier of `cut_deadline` |
| + and `landing_deadline` (§7.8 makes the second a rollback trigger; Plan 2 |
| + ruling P2-deadline). Then the claim follows (§7.5): phase |
| `committed`, plus the obligations in the same claim write, |
| `park-superseded` for the source's location in its region and box, |
| and `park-staging` for the target's staging (§19.3). |
diff --git a/internal/activator/claimloop_linux_test.go b/internal/activator/claimloop_linux_test.go| new file mode 100644 |
| index 000000000..159b1b862 |
| --- /dev/null |
| +++ b/internal/activator/claimloop_linux_test.go |
| @@ -0,0 +1,132 @@ |
| +//go:build linux |
| + |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os/exec" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// The start loop of startAdmitted, driven for real (ruling H17, fix round 1). |
| +// |
| +// What can and cannot be driven. RoleLaunch.Start (rolelaunch_linux.go) |
| +// returns ErrClaimIsolationUnavailable unconditionally, until claim Plan 9 |
| +// Group D replaces the by-name refusal with the isolated-root launcher. It |
| +// is a value method on a concrete type, with no seam, so nothing a test can |
| +// supply runs inside it. Two of the loop's hooks run only from inside Start |
| +// or after it: beforeStart (which journals the attempt's first start through |
| +// OpenProbe and so opens the 30 s window) and Ready. Hence the window can |
| +// not be opened, and readiness can not be offered, through this entry point |
| +// today. The loop itself, ahead of and around Start, can be driven: the |
| +// refusal is an ordinary failed start, and these tests use it as one. |
| +// OpenProbe and Ready are tripwires: the day Start stops refusing they fail, |
| +// and the skipped test below is to be written then. |
| + |
| +type loopRun struct { |
| + mu sync.Mutex |
| + reserved []time.Time |
| + failed int |
| +} |
| + |
| +func (r *loopRun) starts() []time.Time { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return append([]time.Time(nil), r.reserved...) |
| +} |
| + |
| +func driveLoop(t *testing.T, policy startPolicy) (*loopRun, error) { |
| + t.Helper() |
| + run := &loopRun{} |
| + obs := hostclaim.Observed{Host: "x.example", ETag: "one", Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Location: "instances/x.example/inc", Session: 1, Generation: "one"}, Op: hostclaim.Op{Kind: "landing", ID: "op", Phase: "probing", Exec: "box"}}} |
| + work, _ := (hostwrite.Config{}).Select(t.Context(), obs.Host, "spawn") |
| + a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil }, Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) { |
| + return ClaimStart{RoleLauncher: &RoleLaunch{}, CurrentAttempt: func() hostclaim.WakeRetryAttempt { return hostclaim.WakeRetryAttempt{} }, Host: obs.Host, Work: work, Identity: hostwrite.Identity{Config: "c", Release: "r"}, |
| + OpenProbe: func(context.Context, time.Time) (time.Time, error) { |
| + t.Fatal("OpenProbe ran: Start no longer refuses, replace the skipped window test") |
| + return time.Time{}, nil |
| + }, |
| + Restore: func(context.Context) error { return nil }, |
| + Materialize: func(context.Context) error { return nil }, |
| + Build: func() *exec.Cmd { return nil }, |
| + Ready: func(context.Context, *exec.Cmd) error { |
| + t.Fatal("Ready ran: Start no longer refuses, replace the skipped window test") |
| + return nil |
| + }, |
| + Reserve: func(context.Context, int) error { |
| + run.mu.Lock() |
| + run.reserved = append(run.reserved, time.Now()) |
| + run.mu.Unlock() |
| + return nil |
| + }, |
| + Failed: func(context.Context, int) error { run.mu.Lock(); run.failed++; run.mu.Unlock(); return nil }, |
| + Healthy: func(context.Context, int, *exec.Cmd) error { return nil }, |
| + Replicate: func(context.Context, string) (Rep, error) { return nil, nil }, |
| + Publish: func(context.Context, hostclaim.Observed) (hostclaim.Observed, error) { return obs, nil }, |
| + Retain: func(Proc, Rep, error) {}, |
| + }, nil |
| + }}}} |
| + return run, a.startAdmitted(t.Context(), &actEntry{host: obs.Host}, obs, policy) |
| +} |
| + |
| +// Five starts, then the loop gives up, with the delay between starts |
| +// doubling: 1, 2, 4 and 8 seconds. The loop's backoff is time.NewTimer |
| +// inline (claimwake.go), with no clock or sleeper to substitute, so this |
| +// reads real time and takes 15 s; -short skips it. |
| +func TestStartAdmittedLoopStopsAfterFiveStartsWithDoublingBackoff(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("takes 15 s of real backoff") |
| + } |
| + run, err := driveLoop(t, startPolicy{MaxStarts: 5, Window: 30 * time.Second, Deadline: time.Now().Add(time.Hour), Probe: true}) |
| + if !errors.Is(err, ErrClaimIsolationUnavailable) || errors.Is(err, ErrAttemptOver) { |
| + t.Fatalf("the last start's refusal should end the loop: %v", err) |
| + } |
| + at := run.starts() |
| + if len(at) != 5 || run.failed != 5 { |
| + t.Fatalf("starts=%d failed=%d, want 5 and 5", len(at), run.failed) |
| + } |
| + for i, want := range []time.Duration{time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second} { |
| + if gap := at[i+1].Sub(at[i]); gap < want || gap > want+400*time.Millisecond { |
| + t.Errorf("delay before start %d = %v, want %v", i+2, gap, want) |
| + } |
| + } |
| +} |
| + |
| +// The cut deadline ends the attempt while it waits between starts: one |
| +// start, then ErrAttemptOver at the deadline, not after the 1 s backoff. |
| +func TestStartAdmittedLoopCutDeadlineEndsTheBackoffWait(t *testing.T) { |
| + deadline := time.Now().Add(300 * time.Millisecond) |
| + run, err := driveLoop(t, startPolicy{MaxStarts: 5, Window: 30 * time.Second, Deadline: deadline, Probe: true}) |
| + if !errors.Is(err, ErrAttemptOver) { |
| + t.Fatalf("err=%v", err) |
| + } |
| + if n := len(run.starts()); n != 1 { |
| + t.Fatalf("starts=%d, want 1", n) |
| + } |
| + if late := time.Since(deadline); late > 500*time.Millisecond { |
| + t.Fatalf("the attempt ended %v after the deadline", late) |
| + } |
| +} |
| + |
| +// A deadline already passed ends the attempt before any start: Reserve is |
| +// never reached. |
| +func TestStartAdmittedLoopNoStartPastTheCutDeadline(t *testing.T) { |
| + run, err := driveLoop(t, startPolicy{MaxStarts: 5, Window: 30 * time.Second, Deadline: time.Now().Add(-time.Second), Probe: true}) |
| + if !errors.Is(err, ErrAttemptOver) || len(run.starts()) != 0 { |
| + t.Fatalf("err=%v starts=%d", err, len(run.starts())) |
| + } |
| +} |
| + |
| +func TestStartAdmittedLoopWindowAndReadinessAtTheBoundary(t *testing.T) { |
| + t.Skip("blocked on claim Plan 9 Group D: RoleLaunch.Start (rolelaunch_linux.go) returns ErrClaimIsolationUnavailable " + |
| + "unconditionally, so beforeStart/OpenProbe (which opens the 30 s window) and Ready never run through startAdmitted. " + |
| + "When Group D replaces that refusal, drive: window expiry before a start (OpenProbe returns a time older than the window), " + |
| + "readiness at opened+Window and at the cut deadline rejected with ErrAttemptOver, readiness at opened+Window-1s accepted.") |
| +} |
diff --git a/internal/activator/claimwindow_test.go b/internal/activator/claimwindow_test.go| index 62fdf0471..760ae6613 100644 |
| --- a/internal/activator/claimwindow_test.go |
| +++ b/internal/activator/claimwindow_test.go |
| @@ -88,3 +88,91 @@ func TestClaimProbeWindowJournalsFirstStartAndSurvivesRetry(t *testing.T) { |
| t.Fatalf("expired retry: %v", err) |
| } |
| } |
| + |
| +// Ported from the model's TestProbeWindow and wProbeWindowAt (R12-2, |
| +// INV-19, internal/sim/hostlife/witness_test.go): readiness k seconds after |
| +// the attempt's first start. Main's boundary is adopted (ruling H17): the |
| +// window and the cut deadline both fail at the instant, not after it |
| +// (claimwindow.go: !now.Before), so 29 is accepted, 30 and 31 are not. |
| +// |
| +// The loop around probeWindow is not reachable here. Its Ready hook runs |
| +// only after RoleLaunch.Start, which needs the isolated root, so the |
| +// readiness decision is tested where the loop reads it: probeWindow.start |
| +// before a start and probeWindow.result after readiness. |
| +func TestProbeWindowBoundaries(t *testing.T) { |
| + t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + far := t0.Add(time.Hour) |
| + newWindow := func(deadline time.Time, now *time.Time) (*probeWindow, func(context.Context, time.Time) (time.Time, error), *int) { |
| + var saved time.Time |
| + calls := 0 |
| + journal := func(_ context.Context, proposed time.Time) (time.Time, error) { |
| + calls++ |
| + if saved.IsZero() { |
| + saved = proposed |
| + } |
| + return saved, nil |
| + } |
| + return &probeWindow{policy: startPolicy{MaxStarts: 5, Probe: true, Window: 30 * time.Second, Deadline: deadline}, now: func() time.Time { return *now }}, journal, &calls |
| + } |
| + for k, want := range map[int]bool{29: true, 30: false, 31: false} { |
| + now := t0 |
| + b, journal, _ := newWindow(far, &now) |
| + if err := b.start(t.Context(), journal); err != nil { |
| + t.Fatalf("k=%d first start: %v", k, err) |
| + } |
| + now = t0.Add(time.Duration(k) * time.Second) |
| + err := b.result(nil) |
| + if (err == nil) != want || (err != nil && !errors.Is(err, ErrAttemptOver)) { |
| + t.Fatalf("k=%d: readiness result %v, accepted want %v", k, err, want) |
| + } |
| + } |
| + // The environment becomes ready only after the window: no further start |
| + // is journaled, and the first start's time is not reset. |
| + now := t0 |
| + b, journal, calls := newWindow(far, &now) |
| + if err := b.start(t.Context(), journal); err != nil || *calls != 1 { |
| + t.Fatalf("first start: %v (journal calls %d)", err, *calls) |
| + } |
| + now = t0.Add(30 * time.Second) |
| + if err := b.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) || *calls != 1 { |
| + t.Fatalf("start after the window: %v (journal calls %d)", err, *calls) |
| + } |
| + // The absolute cut deadline bounds the whole, inside the window. |
| + now = t0 |
| + c, journal, _ := newWindow(t0.Add(4*time.Second), &now) |
| + if err := c.start(t.Context(), journal); err != nil { |
| + t.Fatal(err) |
| + } |
| + now = t0.Add(3 * time.Second) |
| + if err := c.result(nil); err != nil { |
| + t.Fatalf("readiness before the cut deadline: %v", err) |
| + } |
| + now = t0.Add(4 * time.Second) |
| + if err := c.result(nil); !errors.Is(err, ErrAttemptOver) { |
| + t.Fatalf("readiness at the cut deadline: %v", err) |
| + } |
| + now = t0.Add(5 * time.Second) |
| + if err := c.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) { |
| + t.Fatalf("start past the cut deadline: %v", err) |
| + } |
| + // A readiness already accepted is not revisited by a later clock. |
| + now = t0 |
| + d, journal, _ := newWindow(far, &now) |
| + if err := d.start(t.Context(), journal); err != nil { |
| + t.Fatal(err) |
| + } |
| + d.ready = true |
| + now = t0.Add(time.Hour) |
| + if err := d.result(nil); err != nil { |
| + t.Fatalf("accepted readiness revisited: %v", err) |
| + } |
| + // Five starts at most, whatever the clock says. |
| + a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Read: func(context.Context, string) (hostclaim.Observed, error) { return hostclaim.Observed{}, nil }, Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) { |
| + t.Fatal("six starts were allowed to prepare") |
| + return ClaimStart{}, nil |
| + }}}} |
| + obs := hostclaim.Observed{Host: "x.example"} |
| + if err := a.startAdmitted(t.Context(), &actEntry{host: obs.Host}, obs, startPolicy{MaxStarts: 6, Window: 30 * time.Second, Probe: true}); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("a sixth start: %v", err) |
| + } |
| +} |
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go| index 761b82618..c13d31df1 100644 |
| --- a/internal/hostclaim/move.go |
| +++ b/internal/hostclaim/move.go |
| @@ -421,7 +421,7 @@ func queueLocalPark(c *Claim, opID, box string, newID func() string) { |
| // Claim states in which a region's whole location is retired (§6.4, §7.8): |
| // the landing after the commit, and a rollback from the target's release. |
| const ( |
| - phaseLandingCommitted = "committed" |
| + PhaseCommitted = "committed" |
| phaseRestoringReleased = "released" |
| phaseRestoringClaimed = "claimed" |
| ) |
| @@ -441,7 +441,7 @@ const ( |
| func requireRetiredRegion(c *Claim, region, box string) error { |
| to := c.Op.Intent["to"] |
| switch { |
| - case c.Op.Kind == KindLanding && c.Op.Phase == phaseLandingCommitted: |
| + case c.Op.Kind == KindLanding && c.Op.Phase == PhaseCommitted: |
| if to != "" && region != to && (box == "" || box == c.Op.Intent["source"]) { |
| return nil |
| } |
| @@ -561,6 +561,59 @@ func CompleteLocalPark(opID, box string, earlierStamp uint64) func(*Claim) error |
| } |
| } |
| +// Commit is the claim half of the record's commit (§7.5, §7.9 step 1): the |
| +// landing goes to committed and, in the same write, the parks the commit owes |
| +// are queued through the Plan 2 helpers: the source region's whole location |
| +// (QueueLocationPark, which holds the lifecycle and exact-location checks), |
| +// the source box's park-local, and the target region's staging lane |
| +// (QueueStagingPark). The source's park-local is queued only when the source |
| +// box lists paths of the journalled source stamp S (InventoryHasStamp, H5b); |
| +// otherwise none is. CoverRegionPredecessors then covers the source region's |
| +// admission records. holder.stamp, session and generation are not touched |
| +// (R10-3, H3). |
| +// |
| +// Idempotent under the op: a landing already committed returns nil and queues |
| +// nothing. A refused commit leaves the claim as it found it. |
| +func Commit(opID, sourceRegion, sourceBox, targetRegion string, sourceScope, stagingScope ParkScope, newID func() string) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if err := RequireOp(opID)(c); err != nil { |
| + return err |
| + } |
| + if c.Op.Kind != KindLanding { |
| + return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| + } |
| + if c.Op.Phase == PhaseCommitted { |
| + return nil |
| + } |
| + if c.Op.Phase != "probing" && c.Op.Phase != "probed" { |
| + return fmt.Errorf("%w: phase %q", ErrWrongState, c.Op.Phase) |
| + } |
| + if len(c.Op.Participants) != 2 || c.Op.Exec != c.Op.Participants[1] { |
| + return fmt.Errorf("%w: exec %q is not the target", ErrWrongState, c.Op.Exec) |
| + } |
| + if sourceBox != c.Op.Participants[0] { |
| + return fmt.Errorf("%w: %q is not the move's source", ErrWrongState, sourceBox) |
| + } |
| + parkBox := "" |
| + if stamp, err := strconv.ParseUint(c.Op.Journal[JournalSourceStamp], 10, 64); err == nil && InventoryHasStamp(c, sourceBox, stamp) { |
| + parkBox = sourceBox |
| + } |
| + phase, obligations := c.Op.Phase, len(c.Obligations) |
| + c.Op.Phase = PhaseCommitted |
| + for _, step := range []func(*Claim) error{ |
| + QueueLocationPark(opID, sourceRegion, parkBox, sourceScope, newID), |
| + QueueStagingPark(opID, targetRegion, stagingScope, newID), |
| + CoverRegionPredecessors(opID, PhaseCommitted, sourceRegion), |
| + } { |
| + if err := step(c); err != nil { |
| + c.Op.Phase, c.Obligations = phase, c.Obligations[:obligations] |
| + return err |
| + } |
| + } |
| + return nil |
| + } |
| +} |
| + |
| // QueueResidueParks queues one park-generation per listed generation for a |
| // landing's earlier-stay residue in the target region: RootGeneration for |
| // the origin, the generation name for the rest, all scoped to the same |
diff --git a/internal/hostclaim/move_test.go b/internal/hostclaim/move_test.go| index 19d375a39..20768fd83 100644 |
| --- a/internal/hostclaim/move_test.go |
| +++ b/internal/hostclaim/move_test.go |
| @@ -1155,3 +1155,150 @@ func TestParkQueueHelpersPreserveTheRestOfTheClaim(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// probedLanding drives the claim to landing/probed the way the landing does: |
| +// the flip, then every record phase mirrored through SetMovePhase. The |
| +// target's probe admission is on the holder, its predecessor in region ie. |
| +func probedLanding(t *testing.T) *Claim { |
| + t.Helper() |
| + c := movingToSealed(t) |
| + c.Gen = 7 |
| + if err := Flip(moveA, "uk-box", 3, "gen-7")(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, step := range [][2]string{{"checking", "parking"}, {"parking", "building"}, {"building", "published"}, {"published", "local"}, {"local", "configured"}, {"configured", "restored"}, {"restored", "probing"}, {"probing", "probed"}} { |
| + if err := SetMovePhase(moveA, KindLanding, step[0], step[1])(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + c.Holder.Session = 9 |
| + c.Holder.Admissions = []AdmissionRecord{{Region: "uk", PredecessorRegion: "ie", Session: 9, Box: "uk-box", Generation: moveA, Predecessor: "gen-7", Mode: "cold", Coverage: "sealed"}} |
| + return c |
| +} |
| + |
| +func commitScopes(c *Claim) (src, stg ParkScope) { |
| + at := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| + src = ParkScope{Location: c.Holder.Location, Incarnation: c.Holder.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second} |
| + stg = ParkScope{Location: "moves/" + c.Host + "/" + moveA, Incarnation: c.Holder.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second} |
| + return src, stg |
| +} |
| + |
| +func obligationKinds(c *Claim) map[string]int { |
| + kinds := map[string]int{} |
| + for _, o := range c.Obligations { |
| + kinds[o.Kind+"/"+o.Region+o.Box]++ |
| + } |
| + return kinds |
| +} |
| + |
| +// wCommitCoverage, wCommitReplayed, H3, H5(b). |
| +func TestCommitQueuesObligationsAndCoversTheSourceRegion(t *testing.T) { |
| + for _, from := range []string{"probing", "probed"} { |
| + c := probedLanding(t) |
| + c.Op.Phase = from |
| + // The source box holds paths of the stay the move leaves: stamp S = 3. |
| + c.Inventory = map[string]InventoryEntry{"ie-box": {Layout: "flat", Paths: map[string]uint64{"/data/a": 3}}} |
| + stamp, session, gen := c.Holder.Stamp, c.Holder.Session, c.Holder.Generation |
| + ids := 0 |
| + newID := func() string { ids++; return fmt.Sprintf("id%d", ids) } |
| + src, stg := commitScopes(c) |
| + if err := Commit(moveA, "ie", "ie-box", "uk", src, stg, newID)(c); err != nil { |
| + t.Fatalf("from %s: %v", from, err) |
| + } |
| + if c.Op.Kind != KindLanding || c.Op.Phase != PhaseCommitted || c.Op.Exec != "uk-box" { |
| + t.Fatalf("after commit: %+v", c.Op) |
| + } |
| + // H3: the holder's stamp, session and generation are the flip's, untouched. |
| + if c.Holder.Stamp != stamp || c.Holder.Session != session || c.Holder.Generation != gen { |
| + t.Fatalf("commit touched the holder: %+v", c.Holder) |
| + } |
| + k := obligationKinds(c) |
| + if k["park-replica/ie"] != 1 || k["park-local/ie-box"] != 1 || k["park-replica/uk"] != 1 || len(c.Obligations) != 3 { |
| + t.Fatalf("obligations: %+v", c.Obligations) |
| + } |
| + for _, o := range c.Obligations { |
| + if o.Kind == "park-replica" && o.Region == "ie" && (o.Park == nil || o.Park.Location != c.Holder.Location || o.Owner != "region:ie") { |
| + t.Fatalf("source park: %+v", o) |
| + } |
| + if o.Kind == "park-replica" && o.Region == "uk" && (o.Park == nil || o.Park.Location != stg.Location) { |
| + t.Fatalf("staging park: %+v", o) |
| + } |
| + } |
| + if !c.Holder.Admissions[0].Covered { |
| + t.Fatal("the source-region predecessor is not covered (wCommitCoverage)") |
| + } |
| + if err := c.Validate(); err != nil { |
| + t.Fatalf("the committed claim must validate: %v", err) |
| + } |
| + // Replay: nothing doubles and no ids are minted (wCommitReplayed). |
| + before, usedIDs := mustJSON(t, c), ids |
| + if err := Commit(moveA, "ie", "ie-box", "uk", src, stg, newID)(c); err != nil || ids != usedIDs || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("replay: %v ids %d->%d", err, usedIDs, ids) |
| + } |
| + } |
| +} |
| + |
| +// H5(b): the source's park-local is queued only when the source box lists |
| +// paths of the journalled source stamp S. |
| +func TestCommitQueuesTheSourceParkLocalOnlyForTheSourceStamp(t *testing.T) { |
| + for name, inv := range map[string]map[string]InventoryEntry{ |
| + "no inventory": nil, |
| + "other stamp only": {"ie-box": {Layout: "flat", Paths: map[string]uint64{"/data/a": 2}}}, |
| + "stamp on another box": {"uk-box": {Layout: "flat", Paths: map[string]uint64{"/data/a": 3}}}, |
| + } { |
| + c := probedLanding(t) |
| + c.Inventory = inv |
| + ids := 0 |
| + newID := func() string { ids++; return fmt.Sprintf("id%d", ids) } |
| + src, stg := commitScopes(c) |
| + if err := Commit(moveA, "ie", "ie-box", "uk", src, stg, newID)(c); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + k := obligationKinds(c) |
| + if k["park-replica/ie"] != 1 || k["park-replica/uk"] != 1 || len(c.Obligations) != 2 { |
| + t.Fatalf("%s: obligations %+v", name, c.Obligations) |
| + } |
| + if !c.Holder.Admissions[0].Covered { |
| + t.Fatalf("%s: the source region is not covered", name) |
| + } |
| + } |
| +} |
| + |
| +// wCommitInterrupted: a commit that is refused leaves the claim as it was. |
| +func TestCommitRefusesAnythingButAProbingTargetLanding(t *testing.T) { |
| + newID := func() string { return "x" } |
| + type commitFn = func(c *Claim, src, stg ParkScope) func(*Claim) error |
| + std := func(op, region, box string) commitFn { |
| + return func(c *Claim, src, stg ParkScope) func(*Claim) error { |
| + return Commit(op, region, box, "uk", src, stg, newID) |
| + } |
| + } |
| + for name, tc := range map[string]struct { |
| + mutate func(c *Claim, src, stg *ParkScope) |
| + fn commitFn |
| + want error |
| + }{ |
| + "serving claim": {func(c *Claim, _, _ *ParkScope) { c.Op = Op{Kind: KindServing, Exec: "ie-box"} }, std(moveA, "ie", "ie-box"), ErrStaleOp}, |
| + "another op": {func(*Claim, *ParkScope, *ParkScope) {}, std(moveB, "ie", "ie-box"), ErrStaleOp}, |
| + "moving": {func(c *Claim, _, _ *ParkScope) { c.Op.Kind, c.Op.Phase, c.Op.Exec = KindMoving, PhaseSealed, "" }, std(moveA, "ie", "ie-box"), ErrWrongState}, |
| + "landing before probing": {func(c *Claim, _, _ *ParkScope) { c.Op.Phase = "restored" }, std(moveA, "ie", "ie-box"), ErrWrongState}, |
| + "exec not the target": {func(c *Claim, _, _ *ParkScope) { c.Op.Exec = "ie-box" }, std(moveA, "ie", "ie-box"), ErrWrongState}, |
| + "exec none": {func(c *Claim, _, _ *ParkScope) { c.Op.Exec = "" }, std(moveA, "ie", "ie-box"), ErrWrongState}, |
| + "source box is not the source": {func(*Claim, *ParkScope, *ParkScope) {}, std(moveA, "ie", "uk-box"), ErrWrongState}, |
| + "source region is the target": {func(*Claim, *ParkScope, *ParkScope) {}, std(moveA, "uk", "ie-box"), ErrWrongState}, |
| + "source park is not the holder's location": {func(_ *Claim, s, _ *ParkScope) { s.Location = "instances/a.go.example.com/other" }, std(moveA, "ie", "ie-box"), ErrMalformed}, |
| + "staging park is another lane": {func(c *Claim, _, g *ParkScope) { g.Location = "moves/" + c.Host + "/" + moveB }, std(moveA, "ie", "ie-box"), ErrMalformed}, |
| + } { |
| + c := probedLanding(t) |
| + c.Inventory = map[string]InventoryEntry{"ie-box": {Layout: "flat", Paths: map[string]uint64{"/data/a": 3}}} |
| + src, stg := commitScopes(c) |
| + tc.mutate(c, &src, &stg) |
| + before := mustJSON(t, c) |
| + if err := tc.fn(c, src, stg)(c); !errors.Is(err, tc.want) { |
| + t.Fatalf("%s: got %v, want %v", name, err, tc.want) |
| + } |
| + if after := mustJSON(t, c); string(after) != string(before) { |
| + t.Fatalf("%s mutated the claim:\nbefore %s\nafter %s", name, before, after) |
| + } |
| + } |
| +} |
diff --git a/internal/moves/probe.go b/internal/moves/probe.go| new file mode 100644 |
| index 000000000..a4e9bd3d9 |
| --- /dev/null |
| +++ b/internal/moves/probe.go |
| @@ -0,0 +1,38 @@ |
| +package moves |
| + |
| +// The probe's attempt policy (moves spec §7.7, R12-2, INV-19). |
| +// |
| +// The window itself is not restated here. The activator's probeWindow and |
| +// start loop (internal/activator/claimwindow.go, claimwake.go) already |
| +// enforce the 30 second window counted from the attempt's first start, the |
| +// absolute cut deadline before every start and before readiness is |
| +// accepted, at most five starts and the doubling backoff between them. This |
| +// file holds the one piece of policy that lives with the moves: how many |
| +// attempts a probe is given. The per-attempt cost that prices them is |
| +// budget.go's, and ProbeAttemptsMax is its ceiling. |
| + |
| +// RecoveryAttempts is the number of probe attempts a recovery install is |
| +// given (§7.7): three rounds of the landing's three, because a recovery has |
| +// nowhere further to fall back to. |
| +const RecoveryAttempts = 9 |
| + |
| +// Probe kinds, as the operation kinds the claim records. |
| +const ( |
| + ProbeLanding = "landing" |
| + ProbeRecovery = "recovery" |
| +) |
| + |
| +// AttemptsFor is how many probe attempts a probe of the given kind may |
| +// make. A landing gets the attempts T11 admitted (budget.go |
| +// AttemptsAdmitted), at most ProbeAttemptsMax, and none when none were |
| +// admitted. A recovery gets RecoveryAttempts whatever was admitted. A kind |
| +// that is neither gets none. |
| +func AttemptsFor(kind string, admitted int) int { |
| + switch kind { |
| + case ProbeLanding: |
| + return max(0, min(ProbeAttemptsMax, admitted)) |
| + case ProbeRecovery: |
| + return RecoveryAttempts |
| + } |
| + return 0 |
| +} |
diff --git a/internal/moves/probe_test.go b/internal/moves/probe_test.go| new file mode 100644 |
| index 000000000..83c15b68e |
| --- /dev/null |
| +++ b/internal/moves/probe_test.go |
| @@ -0,0 +1,30 @@ |
| +package moves |
| + |
| +import "testing" |
| + |
| +func TestAttemptsFor(t *testing.T) { |
| + for _, c := range []struct { |
| + kind string |
| + admitted int |
| + want int |
| + }{ |
| + {"landing", 2, 2}, |
| + {"landing", 7, 3}, |
| + {"landing", 3, 3}, |
| + {"landing", 1, 1}, |
| + {"landing", 0, 0}, |
| + {"landing", -4, 0}, |
| + {"recovery", 0, 9}, |
| + {"recovery", 2, 9}, |
| + {"recovery", 7, 9}, |
| + {"serving", 3, 0}, |
| + {"", 3, 0}, |
| + } { |
| + if got := AttemptsFor(c.kind, c.admitted); got != c.want { |
| + t.Errorf("AttemptsFor(%q, %d) = %d, want %d", c.kind, c.admitted, got, c.want) |
| + } |
| + } |
| + if ProbeAttemptsMax != 3 || RecoveryAttempts != 9 { |
| + t.Fatal("the attempt ceilings are the spec's: 3 for a landing, 9 for a recovery") |
| + } |
| +} |
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go| index 529935a77..5030ab441 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -623,3 +623,180 @@ func TestMarkRollbackRecordsASentenceOnALandingRecord(t *testing.T) { |
| t.Fatal("LandingOrder hands out the package's slice") |
| } |
| } |
| + |
| +func commitTestRecord(g MoveGuard, dl time.Time) *Record { |
| + return &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveLanding, LandingPhase: LandProbed, CutDeadline: &dl}} |
| +} |
| + |
| +func TestCommitRefusedPastTheDeadline(t *testing.T) { |
| + g := MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("ab", 32)} |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + dl := now.Add(115 * time.Second) |
| + cut := now.Add(-10 * time.Second) |
| + rec := &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveLanding, LandingPhase: LandProbed, CutDeadline: &dl, CutAt: &cut}} |
| + if err := MoveCommit(g, dl.Add(time.Second))(rec); !errors.Is(err, ErrCutDeadline) || !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveLanding { |
| + t.Fatalf("late commit: %v %s", err, rec.Move.State) |
| + } |
| + if err := MoveCommit(g, dl)(rec); err != nil || rec.Move.State != MoveCommitted || rec.Move.CommittedAt == nil { |
| + t.Fatalf("commit at the deadline: %v", err) |
| + } |
| + if err := MoveCommit(g, dl)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("second commit") |
| + } |
| + if err := MoveClose(g, now)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("close before open and park") |
| + } |
| + if err := MoveOpened(g, now)(rec); err != nil || rec.Move.OpenedAt == nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveClose(g, now)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move == nil { |
| + t.Fatalf("close before the source park: %v", err) |
| + } |
| + if err := MoveSourceParked(g, now)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveClose(g, now)(rec); err != nil || rec.Move != nil || rec.Region != "uk" || rec.LastMove == nil || rec.LastMove.ID != g.ID || rec.LastMove.Outcome != "done" { |
| + t.Fatalf("close: %v %+v", err, rec) |
| + } |
| + if rec.LastMove.OfflineS != 10 || !rec.LastMove.At.Equal(now) { |
| + t.Fatalf("last move: %+v", rec.LastMove) |
| + } |
| +} |
| + |
| +// Ruling H8: a recorded rollback wins over a commit, and the earlier of the |
| +// cut and landing deadlines bounds it. |
| +func TestCommitRefusesARecordedRollbackAndTheEarlierDeadline(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + cutDL, landDL := now.Add(115*time.Second), now.Add(60*time.Second) |
| + rec := commitTestRecord(g, cutDL) |
| + rec.Move.LandingDeadline = &landDL |
| + if err := MoveCommit(g, landDL.Add(time.Second))(rec); !errors.Is(err, ErrCutDeadline) || rec.Move.State != MoveLanding { |
| + t.Fatalf("past the landing deadline, inside the cut's: %v", err) |
| + } |
| + if err := MoveCommit(g, landDL)(rec); err != nil || rec.Move.State != MoveCommitted { |
| + t.Fatalf("at the earlier deadline: %v", err) |
| + } |
| + // The landing deadline alone does not stand in for the cut's. |
| + rec = commitTestRecord(g, landDL) |
| + rec.Move.LandingDeadline = &cutDL |
| + if err := MoveCommit(g, landDL.Add(time.Second))(rec); !errors.Is(err, ErrCutDeadline) { |
| + t.Fatalf("past the cut deadline, inside the landing's: %v", err) |
| + } |
| + // A recorded rollback reason refuses the commit; nothing is written. |
| + rec = commitTestRecord(g, cutDL) |
| + if err := MoveMarkRollback(g, RollbackLandingDeadline)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveCommit(g, now)(rec); !errors.Is(err, ErrMoveConflict) || errors.Is(err, ErrCutDeadline) || rec.Move.State != MoveLanding || rec.Move.CommittedAt != nil { |
| + t.Fatalf("commit over a rollback marker: %v %+v", err, rec.Move) |
| + } |
| + // No cut deadline recorded: the commit cannot be shown to be in time. |
| + rec = commitTestRecord(g, cutDL) |
| + rec.Move.CutDeadline = nil |
| + if err := MoveCommit(g, now)(rec); !errors.Is(err, ErrCutDeadline) { |
| + t.Fatalf("no cut deadline: %v", err) |
| + } |
| + // Not probed yet, a replacement move, and a record with no move. |
| + for _, phase := range []string{LandChecking, LandRestored, LandProbing} { |
| + rec = commitTestRecord(g, cutDL) |
| + rec.Move.LandingPhase = phase |
| + if err := MoveCommit(g, now)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveLanding { |
| + t.Fatalf("commit from %s: %v", phase, err) |
| + } |
| + } |
| + other := MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)} |
| + if err := MoveCommit(other, now)(commitTestRecord(g, cutDL)); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("another incarnation: %v", err) |
| + } |
| + if err := MoveCommit(g, now)(&Record{}); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("no move: %v", err) |
| + } |
| +} |
| + |
| +func TestOpenedParkedAndCloseAreCommittedOnly(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + later := now.Add(time.Minute) |
| + for _, state := range []string{MoveLanding, MoveCutting, MoveReverting, MoveRollingBack} { |
| + for name, step := range map[string]func(*Record) error{"opened": MoveOpened(g, now), "parked": MoveSourceParked(g, now), "close": MoveClose(g, now)} { |
| + rec := commitTestRecord(g, now) |
| + rec.Move.State = state |
| + if err := step(rec); !errors.Is(err, ErrMoveConflict) || rec.Move == nil || rec.Move.OpenedAt != nil || rec.Move.SourceParkedAt != nil { |
| + t.Fatalf("%s from %s: %v", name, state, err) |
| + } |
| + } |
| + } |
| + // A replay keeps the first stamp. |
| + rec := commitTestRecord(g, now) |
| + rec.Move.State = MoveCommitted |
| + if err := MoveOpened(g, now)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveOpened(g, later)(rec); err != nil || !rec.Move.OpenedAt.Equal(now) { |
| + t.Fatalf("opened replay: %v %v", err, rec.Move.OpenedAt) |
| + } |
| + if err := MoveSourceParked(g, now)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveSourceParked(g, later)(rec); err != nil || !rec.Move.SourceParkedAt.Equal(now) { |
| + t.Fatalf("parked replay: %v %v", err, rec.Move.SourceParkedAt) |
| + } |
| + // Close with no cut_at recorded (a record from before it was stamped): |
| + // the outcome is written and the offline time is left at zero. |
| + if err := MoveClose(g, later)(rec); err != nil || rec.Move != nil || rec.LastMove == nil || rec.LastMove.OfflineS != 0 { |
| + t.Fatalf("close without cut_at: %v %+v", err, rec.LastMove) |
| + } |
| + // Close refuses a record whose region is not the target's. |
| + rec = commitTestRecord(g, now) |
| + rec.Move.State, rec.Move.OpenedAt, rec.Move.SourceParkedAt = MoveCommitted, &now, &now |
| + rec.Region = "ie" |
| + if err := MoveClose(g, later)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move == nil || rec.LastMove != nil { |
| + t.Fatalf("close with the region still the source's: %v", err) |
| + } |
| +} |
| + |
| +func TestProbeAttemptRecordsTheCountOnAProbingLanding(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + rec := commitTestRecord(g, now) |
| + rec.Move.LandingPhase = LandProbing |
| + for _, n := range []int{1, 1, 2, 3} { |
| + if err := MoveProbeAttempt(g, n)(rec); err != nil || rec.Move.Landing == nil || rec.Move.Landing.Attempts != n { |
| + t.Fatalf("attempt %d: %v %+v", n, err, rec.Move.Landing) |
| + } |
| + } |
| + rec.Move.Landing.InstalledDBSHA256 = "keep" |
| + if err := MoveProbeAttempt(g, 4)(rec); err != nil || rec.Move.Landing.InstalledDBSHA256 != "keep" { |
| + t.Fatalf("the install digest was lost: %v %+v", err, rec.Move.Landing) |
| + } |
| + for _, n := range []int{3, 0, -1} { |
| + if err := MoveProbeAttempt(g, n)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Landing.Attempts != 4 { |
| + t.Fatalf("attempt %d after 4: %v %+v", n, err, rec.Move.Landing) |
| + } |
| + } |
| + for _, phase := range []string{LandChecking, LandRestored, LandProbed} { |
| + rec := commitTestRecord(g, now) |
| + rec.Move.LandingPhase = phase |
| + if err := MoveProbeAttempt(g, 1)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Landing != nil { |
| + t.Fatalf("from %s: %v", phase, err) |
| + } |
| + } |
| + rec = commitTestRecord(g, now) |
| + rec.Move.LandingPhase = LandProbing |
| + if err := MoveMarkRollback(g, RollbackProbeAttempts)(rec); err != nil || rec.Move.Reason != RollbackProbeAttempts { |
| + t.Fatalf("out of attempts: %v %q", err, rec.Move.Reason) |
| + } |
| + if err := MoveProbeAttempt(g, 1)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Landing != nil { |
| + t.Fatalf("an attempt over a rollback marker: %v", err) |
| + } |
| + rec = commitTestRecord(g, now) |
| + if err := MoveMarkRollback(g, RollbackCommitDeadline)(rec); err != nil || rec.Move.Reason != RollbackCommitDeadline { |
| + t.Fatalf("commit past the deadline: %v %q", err, rec.Move.Reason) |
| + } |
| + rec = commitTestRecord(g, now) |
| + rec.Move.State = MoveCommitted |
| + if err := MoveProbeAttempt(g, 1)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("committed: %v", err) |
| + } |
| +} |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 1dba6ebb3..71345a85a 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1566,6 +1566,12 @@ const ( |
| // installation registered them, so nothing retired them). They are not |
| // the landing's to clear (final review, finding 1). |
| RollbackFilesOccupied = "the files directory already holds files this move did not write" |
| + // RollbackProbeAttempts: the probe used every attempt it was given |
| + // without the app answering (§7.7). |
| + RollbackProbeAttempts = "the probe ran out of attempts" |
| + // RollbackCommitDeadline: the probe passed, but the commit would have |
| + // come after the cut or landing deadline (§7.9). |
| + RollbackCommitDeadline = "the commit came after the deadline" |
| ) |
| // MoveMarkRollback records on a landing record why the landing stopped |
| @@ -1600,6 +1606,10 @@ func MoveMarkRollback(g MoveGuard, reason string) func(*Record) error { |
| rec.Move.Reason = RollbackInstallMismatch |
| case RollbackFilesOccupied: |
| rec.Move.Reason = RollbackFilesOccupied |
| + case RollbackProbeAttempts: |
| + rec.Move.Reason = RollbackProbeAttempts |
| + case RollbackCommitDeadline: |
| + rec.Move.Reason = RollbackCommitDeadline |
| default: |
| return fmt.Errorf("provision: rollback reason %q has no recorded sentence", reason) |
| } |
| @@ -1607,6 +1617,131 @@ func MoveMarkRollback(g MoveGuard, reason string) func(*Record) error { |
| } |
| } |
| +// MoveProbeAttempt records, on a landing in its probing phase, how many |
| +// probe attempts have begun (§7.7). The count only goes up: the same n again |
| +// is a replay and changes nothing, a smaller one is a late write from an |
| +// earlier attempt and is refused. It is refused too once a rollback reason |
| +// is recorded, because the probe is then over. A record at probing says |
| +// only that the probe is running and how many attempts it has made; whether |
| +// the attempt's first start has been journalled is the claim's to say |
| +// (hostclaim.WakeRetryAttempt), not the record's. |
| +func MoveProbeAttempt(g MoveGuard, n int) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + m := rec.Move |
| + if m.State != MoveLanding || m.LandingPhase != LandProbing || m.Reason != "" { |
| + return fmt.Errorf("%w: probe attempts are counted while a landing is probing", ErrMoveConflict) |
| + } |
| + have := 0 |
| + if m.Landing != nil { |
| + have = m.Landing.Attempts |
| + } |
| + if n < 1 || n < have { |
| + return fmt.Errorf("%w: the attempt count does not go down", ErrMoveConflict) |
| + } |
| + if m.Landing == nil { |
| + m.Landing = &MoveLandingJournal{} |
| + } |
| + m.Landing.Attempts = n |
| + return nil |
| + } |
| +} |
| + |
| +// ErrCutDeadline is a commit attempted after the deadline that bounds it |
| +// (§7.4, §7.9): the record CAS is the decision, so the deadline cannot be |
| +// passed between the record write and the claim write. |
| +var ErrCutDeadline = fmt.Errorf("%w: the commit deadline has passed", ErrMoveConflict) |
| + |
| +// MoveCommit is the commit (§7.9): landing/probed to committed, in one |
| +// record write. It is refused when a rollback reason is already recorded, so |
| +// a recorded rollback wins over a commit that raced it, and when now is |
| +// after the earlier of cut_deadline and landing_deadline (ruling H8: §7.9 |
| +// names the first and §7.8 makes the second a rollback trigger, so the |
| +// earlier bounds the commit). A record with no cut_deadline cannot be shown |
| +// to be in time and is refused. now equal to the deadline is in time. |
| +// |
| +// A replay finds the move already committed and is an ErrMoveConflict; the |
| +// caller re-reads the record and accepts committed for its own move (H22). |
| +func MoveCommit(g MoveGuard, now time.Time) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + m := rec.Move |
| + if m.State != MoveLanding || m.LandingPhase != LandProbed { |
| + return fmt.Errorf("%w: commit follows probed", ErrMoveConflict) |
| + } |
| + if m.Reason != "" { |
| + return fmt.Errorf("%w: a rollback is recorded for this landing", ErrMoveConflict) |
| + } |
| + if m.CutDeadline == nil || now.After(*m.CutDeadline) || (m.LandingDeadline != nil && now.After(*m.LandingDeadline)) { |
| + return ErrCutDeadline |
| + } |
| + t := now.UTC() |
| + m.State, m.CommittedAt = MoveCommitted, &t |
| + return nil |
| + } |
| +} |
| + |
| +// committedStamp stamps one of a committed move's obligations. The first |
| +// stamp stands: a replay changes nothing. |
| +func committedStamp(g MoveGuard, now time.Time, field func(*MoveState) **time.Time) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + if rec.Move.State != MoveCommitted { |
| + return fmt.Errorf("%w: this follows the commit", ErrMoveConflict) |
| + } |
| + if p := field(rec.Move); *p == nil { |
| + t := now.UTC() |
| + *p = &t |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// MoveOpened stamps opened_at on a committed move: the target is serving |
| +// (§7.9). |
| +func MoveOpened(g MoveGuard, now time.Time) func(*Record) error { |
| + return committedStamp(g, now, func(m *MoveState) **time.Time { return &m.OpenedAt }) |
| +} |
| + |
| +// MoveSourceParked stamps source_parked_at on a committed move: the source |
| +// has parked what it kept (§7.9). |
| +func MoveSourceParked(g MoveGuard, now time.Time) func(*Record) error { |
| + return committedStamp(g, now, func(m *MoveState) **time.Time { return &m.SourceParkedAt }) |
| +} |
| + |
| +// MoveClose is the close (§7.9 step 5, index D11): a committed move with |
| +// both obligations stamped clears Move and leaves the outcome in LastMove, |
| +// in the same write. The region is already the target's from the flip; a |
| +// record whose region is not is refused rather than closed on a lie. The |
| +// offline time is opened_at minus cut_at, zero when cut_at was never |
| +// stamped. Plan 5 calls it once no obligation under the move remains. |
| +func MoveClose(g MoveGuard, now time.Time) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + m := rec.Move |
| + if m.State != MoveCommitted || m.OpenedAt == nil || m.SourceParkedAt == nil { |
| + return fmt.Errorf("%w: close follows the open and the source park", ErrMoveConflict) |
| + } |
| + if rec.Region != m.To { |
| + return fmt.Errorf("%w: the record's region is not the target's", ErrMoveConflict) |
| + } |
| + out := &MoveOutcome{ID: g.ID, Outcome: "done", At: now.UTC()} |
| + if m.CutAt != nil { |
| + out.OfflineS = m.OpenedAt.Sub(*m.CutAt).Seconds() |
| + } |
| + rec.LastMove, rec.Move = out, nil |
| + return nil |
| + } |
| +} |
| + |
| // InstanceStoreEnv is the environment name an instance's private object |
| // store hangs off: the record's own Environment, or its Slug when it has |
| // none. Twin of the create door's inline rule (internal/console/ |