carlos / platform Public

host: the rollback decision and the target's release, return lane and cleaning (§7.8)

The decision runs at the top of the landing's pass, before its arming
gate (J10): a recorded mark or landingDeadlineOf at any phase (J11)
becomes MoveRollBack, and the claim follows (RollBackFlipped). A commit
that won makes the CAS refuse, and nothing else is written.

The target's half is routed by movesParticipantRecord and runs
releasing (handHold, fence, drain, stop evidence, final checkpoint,
digests, stopped-target.json, TargetReleased), returning (a staging
lane under the source's staging lease: a snapshot of the stopped
database or the staged seal as given, the return seal and baseline in
the claim) and cleaning once the source has recovered (both parks, the
installation retired, leftovers renamed in the guard, the row deleted,
target_parked_at). Unarmed in production until Plan 5 (J18).

The landing tests that kept a mark across passes now expect the
rollback. movesrollback.go and returnlane.go join movesScanFiles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 395b4796c1ffb5d1661a3c0c0f49b7adce8c7fc2 parent 6cb56fb
8 files changed, +2258 −27
  • approved_moves_copy_test.go +2 −0
  • internal/host/movesfinal.go +7 −3
  • internal/host/movesland.go +7 −1
  • internal/host/movesland_finalfix_test.go +3 −7
  • internal/host/movesland_test.go +11 −16
  • internal/host/movespass.go +14 −0
  • internal/host/movesrollback.go +1277 −0
  • internal/host/movesrollback_test.go +937 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index ca561578e..44086b642 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -590,6 +590,7 @@ var movesScanFiles = []string{
"internal/host/movesprobe.go",
"internal/host/movesreport.go",
"internal/host/movesretire.go",
+ "internal/host/movesrollback.go",
"internal/host/movesstage.go",
"internal/host/movesstore.go",
"internal/host/refusalwatch.go",
@@ -628,6 +629,7 @@ var movesScanFiles = []string{
"internal/moves/ltxbuild.go",
"internal/moves/probe.go",
"internal/moves/readiness.go",
+ "internal/moves/returnlane.go",
"internal/moves/seal.go",
"internal/moves/sealedclient.go",
"internal/moves/source.go",
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go
index c4492f175..99c979804 100644
--- a/internal/host/movesfinal.go
+++ b/internal/host/movesfinal.go
@@ -132,11 +132,15 @@ func moveJournalPresent(dataDir, host, op string) bool {
}
// participantRound is a post-flip participant's step for a record the
-// region rule did not admit (movesPass). Only the source's finalization of a
-// committed move is built; the rest is Plan 4's.
+// region rule did not admit (movesPass): the source's finalization of a
+// committed move, and the target's half of a rollback (movesrollback.go,
+// J10). The rest is Plan 4's later tasks'.
func (w *movesWorker) participantRound(ctx context.Context, s release.Store, rec provision.Record, role string) error {
- if role == roleSource && rec.Move.State == provision.MoveCommitted {
+ switch {
+ case role == roleSource && rec.Move.State == provision.MoveCommitted:
return w.finalizeSource(ctx, s, rec)
+ case role == roleTarget && rec.Move.State == provision.MoveRollingBack:
+ return w.rollbackTarget(ctx, s, rec)
}
return nil
}
diff --git a/internal/host/movesland.go b/internal/host/movesland.go
index f7ed7e7ee..5657dfe77 100644
--- a/internal/host/movesland.go
+++ b/internal/host/movesland.go
@@ -140,6 +140,11 @@ func landingArmed(cfg moveCfg) bool {
// landingPass is the MoveLanding arm of phase1MoveRound.
func (w *movesWorker) landingPass(ctx context.Context, s release.Store, rec provision.Record) error {
cfg := w.cfg
+ // The rollback decision (movesrollback.go, J10, J11): a recorded mark
+ // or the landing's deadline, at any phase, armed or not.
+ if done, err := w.rollBackLanding(ctx, s, rec); done || err != nil {
+ return err
+ }
if !landingArmed(cfg) {
w.notice(rec.Host+"#landing", "moves: %s: the landing of %s waits: this box's landing is not armed (claim Plan 6b's regional runner and Plan 5's park view are needed)", rec.Host, rec.Move.ID)
return nil
@@ -185,7 +190,8 @@ func (l *landingRun) step(ctx context.Context) (bool, error) {
return false, nil
}
if m.Reason != "" {
- l.w.notice(l.rec.Host+"#rollback", "moves: %s: landing %s is marked for rollback (%s); the rollback is Plan 4's", l.rec.Host, l.g.ID, m.Reason)
+ // Marked: the next pass's rollback decision (rollBackLanding) rolls
+ // it back; markRollback nudged the serial for it.
return false, nil
}
if landingBefore(m.LandingPhase, provision.LandRestored) && l.pastDeadline() {
diff --git a/internal/host/movesland_finalfix_test.go b/internal/host/movesland_finalfix_test.go
index 5e85ba952..d7d00364b 100644
--- a/internal/host/movesland_finalfix_test.go
+++ b/internal/host/movesland_finalfix_test.go
@@ -376,9 +376,8 @@ func TestLandingCutDeadlineMarksRollback(t *testing.T) {
d := setCutDeadline(t, h)
pastCut(h, d)
h.land(t)
- if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackLandingDeadline || m.LandingPhase != provision.LandChecking {
- t.Fatalf("at checking past the cut deadline: %+v", m)
- }
+ // The rollback decision reads the earlier deadline too (J11).
+ rolledBack(t, h, provision.RollbackLandingDeadline, provision.LandChecking)
})
t.Run("at configured while the release waits", func(t *testing.T) {
h := newLandingHarness(t)
@@ -392,10 +391,7 @@ func TestLandingCutDeadlineMarksRollback(t *testing.T) {
pastCut(h, d)
h.release = moves.Check{OK: true}
h.land(t)
- m := h.read(t, p1Host).Move
- if m.Reason != provision.RollbackLandingDeadline || m.LandingPhase != provision.LandConfigured {
- t.Fatalf("at configured past the cut deadline: %+v", m)
- }
+ rolledBack(t, h, provision.RollbackLandingDeadline, provision.LandConfigured)
if _, err := os.Stat(provision.DefaultDBPath(h.tdir, p1Host)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("installed past the cut deadline: %v", err)
}
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go
index 60373a7d3..58a5137c6 100644
--- a/internal/host/movesland_test.go
+++ b/internal/host/movesland_test.go
@@ -1060,11 +1060,12 @@ func TestLandingConfiguredPinsInputs(t *testing.T) {
if _, err := os.Stat(provision.DefaultDBPath(h.tdir, p1Host)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the database was installed under a mismatch: %v", err)
}
- // A marked record is left alone.
- before := m.UpdatedAt
+ // The next pass rolls the marked landing back (Plan 4's decision, J10),
+ // installing nothing.
h.land(t)
- if after := h.read(t, p1Host).Move; !after.UpdatedAt.Equal(before) || after.Reason != provision.RollbackConfigChanged {
- t.Fatalf("a marked record was written: %+v", after)
+ rolledBack(t, h, provision.RollbackConfigChanged, provision.LandConfigured)
+ if _, err := os.Stat(provision.DefaultDBPath(h.tdir, p1Host)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the rollback installed the database: %v", err)
}
h2 := newLandingHarness(t)
@@ -1079,16 +1080,14 @@ func TestLandingConfiguredPinsInputs(t *testing.T) {
h2.installed(t)
}
-// landing_deadline passed before restored marks the rollback, at whatever
-// phase the landing is; at restored it is the probe's.
-func TestLandingDeadlineMarksRollback(t *testing.T) {
+// landing_deadline passed rolls the landing back (Plan 4's decision, J11),
+// at whatever phase the landing is, restored included.
+func TestLandingDeadlineRollsBack(t *testing.T) {
h := newLandingHarness(t)
h.flip(t)
h.clock.Advance(cutLandingWindow + time.Minute)
h.land(t)
- if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackLandingDeadline || m.LandingPhase != provision.LandChecking {
- t.Fatalf("at checking: %+v", m)
- }
+ rolledBack(t, h, provision.RollbackLandingDeadline, provision.LandChecking)
h2 := newLandingHarness(t)
h2.flip(t)
@@ -1099,9 +1098,7 @@ func TestLandingDeadlineMarksRollback(t *testing.T) {
}
h2.clock.Advance(cutLandingWindow + time.Minute)
h2.land(t)
- if m := h2.read(t, p1Host).Move; m.Reason != provision.RollbackLandingDeadline || m.LandingPhase != provision.LandConfigured {
- t.Fatalf("at configured past the deadline: %+v", m)
- }
+ rolledBack(t, h2, provision.RollbackLandingDeadline, provision.LandConfigured)
h3 := newLandingHarness(t)
h3.flip(t)
@@ -1109,9 +1106,7 @@ func TestLandingDeadlineMarksRollback(t *testing.T) {
h3.installed(t)
h3.clock.Advance(cutLandingWindow + time.Minute)
h3.land(t)
- if m := h3.read(t, p1Host).Move; m.Reason != "" || m.LandingPhase != provision.LandRestored {
- t.Fatalf("at restored past the deadline: %+v", m)
- }
+ rolledBack(t, h3, provision.RollbackLandingDeadline, provision.LandRestored)
}
// Every phase is replay-safe: a crash after each durable side effect, then
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 12f4ba48b..30b8b5c2e 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -319,6 +319,9 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin
// under the host's hold lock, not a claim-selected write (ruling
// H5(d)). Unarmed with ParkLeases, as the landing is.
cfg.DeleteHeld = reg.DeleteIfHoldWithTimers
+ // The rollback's release hands the row's hold from the move to the
+ // rollback, touching the stage-requested marker (ruling J3).
+ cfg.HandHold = handHold(reg, holdStageRequester(dataDir))
}
if eb != nil {
// A box with no delegated cgroup has no manager, and Reconstruct
@@ -560,6 +563,17 @@ type moveCfg struct {
// its test seam, nil in production.
DeleteHeld func(host, op string) (bool, error)
finalFault func(point string) error
+
+ // The rollback on the target (§7.8, movesrollback.go). HandHold hands
+ // the row's hold from the move to its rollback (handHold, ruling J3).
+ // LogEvent records a move event (moves.LogEvent, Plan 6): nil in
+ // production, where the event is a log line (ruling J18). The target's
+ // half also uses the cut's stop hooks, the landing's park hooks and
+ // DeleteHeld above; ParkLeases nil leaves it unarmed in production
+ // (rollbackTargetArmed). rbFault is its test seam, nil in production.
+ HandHold func(host, status, toOp, fromOp string) (bool, error)
+ LogEvent func(ctx context.Context, rec provision.Record, event string)
+ rbFault func(point string) error
}
// defaultMoveReadyDelta is the readiness bar when
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go
new file mode 100644
index 000000000..a374a4b2f
--- /dev/null
+++ b/internal/host/movesrollback.go
@@ -0,0 +1,1277 @@
+package host
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/superfly/ltx"
+)
+
+// The rollback on the target (moves spec §7.8; Plan 4 Task 4), behind
+// CARLOS_MOVES_PHASE1: the decision that ends a landing, then the target
+// box's half of the rollback: its release, its return lane and its cleaning.
+//
+// The decision (rollBackLanding). A landing the target can no longer finish
+// is rolled back by one record CAS, MoveRollBack, which moves the region back
+// to From; the claim follows (RollBack, or RollBackFlipped over a claim the
+// flip's half never reached, J16), journalling the target installation's
+// stamp F (J15). It runs at the top of the landing's pass, before its arming
+// gate (J10), and is the one place a landing's recorded mark (every
+// markRollback site, Plans 2 and 3) becomes a rollback. The deadline that
+// triggers it is landingDeadlineOf, the earlier of the cut's and the
+// landing's, at every landing phase, probing and probed included (J11). The
+// commit races it on the record: a commit that won makes MoveRollBack refuse
+// with ErrMoveConflict, and the decision stops there with no trace, leaving
+// the record to the commit and the open (Plan 3 Task 7).
+//
+// The target's half (rollbackTarget). Once the record is rolling_back its
+// region is the source's, so the target reaches it through the post-flip
+// participant rule (movesParticipantRecord, J10). One pass drives it as far
+// as it can, each phase from durable state, under the record's
+// target_phase (§7.8 target table; Plan 2's phase-name convention, G8: a
+// record at X means X's work may have begun):
+//
+// - releasing: the row's hold handed from the move to the rollback
+// (handHold, J3), the wake fence, the timer drain and the stop evidence
+// (stopevidence.Reconstruct); a target not yet proven stopped retries,
+// and the release deadline is the console's to page on. Then, if the
+// landing reached probing and the database is here, a final checkpoint
+// under the writer's lease (finalCheckpoint, writerLease), N' from the
+// local position, and the digests: the database, the files plain digest
+// and the timers. The release is clean when the landing never reached
+// probing or all three equal what was installed (installed_db_sha256,
+// source.files.plain, source.timers_sha256), and the staged seal covers
+// the source (src_covered). stopped-target.json is written create-only,
+// the record goes to released with Dirty and Evidence, and the claim
+// follows (TargetReleased: execution to none, the target region's
+// location park, its park-local only when the inventory lists F, the
+// target region's predecessors covered);
+// - returning (dirty only): the return lane, moves/<host>/<rb id>/ in the
+// source's bucket, written as a staging lane under the staging-work lease
+// for (source region, host), every object through the guarded staging
+// writer, one presigned request per call. A target that served and
+// changed sends a snapshot [1, N'] of its stopped database
+// (moves.EncodeFull, held in memory: Phase 1's size limit), its files
+// archive (bound to the move id, which the source's installer checks)
+// and its timers; one whose state is still the staged seal's (dirty only
+// for want of src_covered) sends the staged seal's versions as given.
+// The return seal (moves.ReturnSeal, bound = the lease's SafeUntil after
+// the last write) and the return baseline go into the claim's journal in
+// one write, then the record says returned;
+// - cleaning, only once the source has recovered (RecoveredAt, Review
+// Focus 4): the target region's location park and the move's lane park
+// (queued here) run through the park engine until their archives are
+// complete (D6: this region is no longer authoritative, so the engine
+// selects everything); the registered installation is retired through
+// park.Local, which completes its own park-local (J15), and everything
+// else at the flat paths is parked by the moves-owned rename (H5(b))
+// inside the host guard; the row and its timers rows go in one registry
+// transaction under the host's hold lock (J12); the record says cleaned
+// with target_parked_at, and the op's journal is parked last.
+//
+// Arming (ruling J18). Like the landing, the rollback's target half is
+// unarmed in production until Plan 5: it needs the park engine's leases
+// (ParkLeases), which production does not wire, so rollbackTargetArmed
+// answers false there and the pass logs a standing notice. The decision is
+// not gated (J10). The move's event log (moves.LogEvent, Plan 6) is the
+// LogEvent hook: nil in production, where the event is a log line. Nothing
+// here pages: the release deadline is the console's.
+
+// rollbackStepsPerPass bounds the phases one pass runs.
+const rollbackStepsPerPass = 8
+
+// The target's rollback steps in its op journal (the landing's journal of
+// the move id, moves.Journal). Each step is skipped on a replay once done.
+const (
+ rbStepTargetStamp = "rb-target-stamp" // F, noted from the claim before anything is retired
+ rbStepLaneQueued = "rb-lane-queued" // the move's lane park is queued in this region
+ rbStepRemote = "rb-target-park-remote" // both parks' archives are complete, or acknowledged
+ rbStepInstallation = "rb-target-park-installation" // retired, completed or unregistered
+ rbStepLocal = "rb-target-park-local" // parkLocalArtifacts' step: the retirement id
+ rbStepRowDeleted = "rb-target-row-deleted" // "pending" before the delete, "done" after it
+)
+
+// stoppedTargetRole is the target's StoppedReport role.
+const stoppedTargetRole = "target"
+
+// now is the worker's clock.
+func (w *movesWorker) now() time.Time {
+ if n := w.cfg.Now; n != nil {
+ return n()
+ }
+ return time.Now()
+}
+
+// logEvent records a move event: the LogEvent hook when wired (Plan 6's
+// moves.LogEvent), else a log line.
+func (w *movesWorker) logEvent(ctx context.Context, rec provision.Record, event string) {
+ if f := w.cfg.LogEvent; f != nil {
+ f(ctx, rec, event)
+ return
+ }
+ id := ""
+ if rec.Move != nil {
+ id = rec.Move.ID
+ }
+ log.Printf("moves: %s: %s %s (the move log is Plan 6's)", rec.Host, id, event)
+}
+
+// rollBackLanding is the rollback decision on the target (§7.8; J10, J11):
+// a landing marked for a rollback, or past landingDeadlineOf at any phase,
+// is rolled back by MoveRollBack, and the claim follows. done is true when
+// the record was this decision's to take (written, or refused because the
+// record moved on); the landing's pass then ends.
+func (w *movesWorker) rollBackLanding(ctx context.Context, s release.Store, rec provision.Record) (done bool, err error) {
+ m := rec.Move
+ if m == nil || m.Legacy() || m.State != provision.MoveLanding {
+ return false, nil
+ }
+ reason := m.Reason
+ if reason == "" {
+ d, which := landingDeadlineOf(m)
+ if d == nil || !w.now().After(*d) {
+ return false, nil
+ }
+ log.Printf("moves: %s: landing %s is past its %s (%s)", rec.Host, m.ID, which, d.UTC().Format(time.RFC3339))
+ reason = provision.RollbackLandingDeadline
+ }
+ rbID, err := moves.NewRollbackID(rand.Reader)
+ if err != nil {
+ return true, err
+ }
+ g := provision.MoveGuard{ID: m.ID, Incarnation: m.Incarnation}
+ fresh, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MoveRollBack(g, rbID, reason, w.now()))
+ if errors.Is(err, provision.ErrMoveConflict) {
+ // A commit that won (the open is the commit's path), or another
+ // writer's rollback (the participant pass follows it).
+ log.Printf("moves: %s: the rollback of landing %s is refused (%v); the record moved on", rec.Host, m.ID, err)
+ return true, nil
+ }
+ if err != nil {
+ return true, fmt.Errorf("roll the landing back: %w", err)
+ }
+ log.Printf("moves: %s: landing %s rolls back as %s: %s", rec.Host, m.ID, rbID, fresh.Move.Rollback.Reason)
+ w.logEvent(ctx, *fresh, "rollback")
+ if _, err := w.rollbackClaim(ctx, s, *fresh); err != nil {
+ log.Printf("moves: %s: the claim half of rollback %s waits for the next pass: %v", rec.Host, rbID, err)
+ }
+ nudgeSerial(ctx, s)
+ return true, nil
+}
+
+// rollbackClaim is the claim half of the record's rollback: the op of this
+// move, for the record's incarnation, goes to restoring/releasing under the
+// rollback id (RollBackFlipped writes the flip first over a claim still at
+// moving/sealed). A claim already restoring for this rollback is done.
+func (w *movesWorker) rollbackClaim(ctx context.Context, s release.Store, rec provision.Record) (hostclaim.Observed, error) {
+ m := rec.Move
+ obs, err := hostclaim.Read(ctx, s, rec.Host)
+ if err != nil {
+ return obs, fmt.Errorf("read the host claim: %w", err)
+ }
+ if obs.Absent {
+ return obs, fmt.Errorf("move %s has no host claim", m.ID)
+ }
+ if err := hostclaim.RequireOp(m.ID)(obs.Claim); err != nil {
+ return obs, fmt.Errorf("move %s: %w", m.ID, err)
+ }
+ h := obs.Claim.Holder
+ if h == nil || h.Incarnation != m.Incarnation {
+ return obs, fmt.Errorf("move %s: the host claim names another incarnation", m.ID)
+ }
+ rb := m.Rollback.ID
+ if op := obs.Claim.Op; op.Kind == hostclaim.KindRestoring && op.Intent["rollback"] == rb {
+ return obs, nil
+ }
+ if obs, err = hostclaim.Update(ctx, s, obs, hostclaim.RollBackFlipped(m.ID, rb, w.cfg.Box, h.Stamp, h.Generation)); err != nil {
+ return obs, fmt.Errorf("the claim half of the rollback: %w", err)
+ }
+ log.Printf("moves: %s: the claim follows rollback %s", rec.Host, rb)
+ return obs, nil
+}
+
+// rollbackTargetArmed reports whether this box can run the target's half of
+// a rollback: every hook it calls is wired, the park engine's leases
+// included. In production ParkLeases is not (ruling G17), so this answers
+// false there (J18).
+func rollbackTargetArmed(cfg moveCfg) bool {
+ return cfg.Box != "" && cfg.DataDir != "" && cfg.Guard.DataDir != "" && cfg.Local.DataDir != "" && cfg.ParkLeases != nil &&
+ cfg.HandHold != nil && cfg.Fence != nil && cfg.TimersDrained != nil && cfg.Evidence != nil && cfg.FinalCheckpoint != nil &&
+ cfg.LocalPosition != nil && cfg.Timers != nil && cfg.FilesDir != nil && cfg.Route != nil && cfg.Versioned != nil &&
+ cfg.StagingLease != nil && cfg.Staging != nil && cfg.DeleteHeld != nil && cfg.SecretsPub != nil
+}
+
+// rollbackRun is one pass's state for one rolling_back record on its target.
+type rollbackRun struct {
+ w *movesWorker
+ s release.Store
+ rec provision.Record
+ g provision.MoveGuard
+ rb string
+}
+
+// rollbackTarget is the participant step for a rolling_back record on its
+// target (movesPass, J10).
+func (w *movesWorker) rollbackTarget(ctx context.Context, s release.Store, rec provision.Record) error {
+ cfg := w.cfg
+ m := rec.Move
+ if m == nil || m.Legacy() || m.State != provision.MoveRollingBack || m.Rollback == nil || m.To != cfg.Region || m.From == cfg.Region {
+ return nil
+ }
+ if !rollbackTargetArmed(cfg) {
+ w.notice(rec.Host+"#rollback", "moves: %s: the target's half of rollback %s waits: this box's rollback is not armed (Plan 5's park view and the park engine's leases are needed)", rec.Host, m.Rollback.ID)
+ return nil
+ }
+ r := &rollbackRun{w: w, s: s, rec: rec, g: provision.MoveGuard{ID: m.ID, Incarnation: m.Incarnation}, rb: m.Rollback.ID}
+ for i := 0; i < rollbackStepsPerPass; i++ {
+ advanced, err := r.step(ctx)
+ if err != nil || !advanced {
+ return err
+ }
+ fresh, err := provision.GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
+ if err != nil {
+ return fmt.Errorf("re-read the record: %w", err)
+ }
+ if fm := fresh.Move; fm == nil || fm.ID != r.g.ID || fm.Incarnation != r.g.Incarnation || fm.State != provision.MoveRollingBack || fm.Rollback == nil || fm.Rollback.ID != r.rb {
+ return nil
+ }
+ r.rec = *fresh
+ }
+ return nil
+}
+
+func (r *rollbackRun) cfg() moveCfg { return r.w.cfg }
+func (r *rollbackRun) now() time.Time { return r.w.now() }
+func (r *rollbackRun) rollback() *provision.MoveRollback { return r.rec.Move.Rollback }
+func (r *rollbackRun) dbPath() string { return provision.DefaultDBPath(r.cfg().DataDir, r.rec.Host) }
+
+// fault is the test seam after a side effect.
+func (r *rollbackRun) fault(point string) error {
+ if f := r.cfg().rbFault; f != nil {
+ return f(point)
+ }
+ return nil
+}
+
+// step runs the target's current rollback phase once. advanced is true when
+// it wrote the record.
+func (r *rollbackRun) step(ctx context.Context) (bool, error) {
+ switch r.rollback().TargetPhase {
+ case provision.RbTargetReleasing:
+ return r.releasing(ctx)
+ case provision.RbTargetReleased:
+ return r.released(ctx)
+ case provision.RbTargetReturning:
+ return r.returning(ctx)
+ case provision.RbTargetReturned:
+ return r.toCleaning(ctx, provision.RbTargetReturned)
+ case provision.RbTargetCleaning:
+ return r.cleaning(ctx)
+ case provision.RbTargetCleaned:
+ return false, r.cleaned()
+ }
+ return false, nil // lost: the declared loss's (Task 10)
+}
+
+// phase writes one target phase step, then logs the event, if any, and
+// passes the step's fault point. A conflict is someone else's decision.
+func (r *rollbackRun) phase(ctx context.Context, from, to string, stamp func(*provision.MoveRollback), event string) (bool, error) {
+ host := r.rec.Host
+ if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveRollbackTargetPhase(r.g, from, to, stamp)); err != nil {
+ if errors.Is(err, provision.ErrMoveConflict) {
+ log.Printf("moves: %s: the target's rollback %s to %s refused (%v); the record moved on", host, from, to, err)
+ return false, nil
+ }
+ return false, fmt.Errorf("the target's rollback %s to %s: %w", from, to, err)
+ }
+ log.Printf("moves: %s: the target of rollback %s is %s", host, r.rb, to)
+ if event != "" {
+ r.w.logEvent(ctx, r.rec, event)
+ }
+ if err := r.fault("after-record-" + to); err != nil {
+ return true, err
+ }
+ return true, nil
+}
+
+// claim reads the host claim and requires this rollback's restoring op,
+// with this box the move's target, for the record's incarnation.
+func (r *rollbackRun) claim(ctx context.Context) (hostclaim.Observed, error) {
+ obs, err := hostclaim.Read(ctx, r.s, r.rec.Host)
+ if err != nil {
+ return obs, fmt.Errorf("read the host claim: %w", err)
+ }
+ if obs.Absent {
+ return obs, fmt.Errorf("move %s has no host claim", r.g.ID)
+ }
+ if err := r.ours(obs.Claim); err != nil {
+ return obs, fmt.Errorf("move %s: %w", r.g.ID, err)
+ }
+ return obs, nil
+}
+
+// ours is the claim check of every target phase: this rollback's restoring
+// op, this box the move's target, the record's incarnation.
+func (r *rollbackRun) ours(c *hostclaim.Claim) error {
+ box := r.cfg().Box
+ if c == nil || c.Op.ID != r.g.ID || c.Op.Kind != hostclaim.KindRestoring || c.Op.Intent["rollback"] != r.rb {
+ return fmt.Errorf("%w: the claim is not rollback %s", hostclaim.ErrWrongState, r.rb)
+ }
+ if c.Holder == nil || c.Holder.Incarnation != r.g.Incarnation {
+ return fmt.Errorf("%w: the claim names another incarnation", hostclaim.ErrWrongState)
+ }
+ if len(c.Op.Participants) != 2 || c.Op.Participants[1] != box || c.Op.Intent["target"] != box {
+ return fmt.Errorf("%w: this box is not the move's target in the claim", hostclaim.ErrWrongState)
+ }
+ return nil
+}
+
+// stillReleasing is the final checkpoint's authority: every replica request
+// re-reads the claim, which must still name this rollback releasing with this
+// box executing.
+func (r *rollbackRun) stillReleasing(ctx context.Context) error {
+ obs, err := r.claim(ctx)
+ if err != nil {
+ return err
+ }
+ if op := obs.Claim.Op; op.Phase != hostclaim.PhaseReleasing || op.Exec != r.cfg().Box {
+ return fmt.Errorf("move %s: this box no longer executes the host", r.g.ID)
+ }
+ return nil
+}
+
+// reachedProbing reports whether the landing reached probing: a writer may
+// have run on this box.
+func (r *rollbackRun) reachedProbing() bool {
+ order := provision.LandingOrder()
+ return landingIndex(order, r.rec.Move.LandingPhase) >= landingIndex(order, provision.LandProbing)
+}
+
+// unchanged reports whether the stopped target's digests are what the
+// landing installed: the database, the files and the timers.
+func (r *rollbackRun) unchanged(report moves.StoppedReport) bool {
+ m := r.rec.Move
+ return m.Landing != nil && m.Landing.InstalledDBSHA256 != "" && report.DBSHA256 == m.Landing.InstalledDBSHA256 &&
+ m.Source != nil && m.Source.Files != nil && report.FilesPlain == m.Source.Files.Plain && report.TimersSHA256 == m.Source.TimersSHA256
+}
+
+// releasing (§7.8 target releasing and released): the hold, the fence, the
+// drain and the stop evidence; the final checkpoint and the digests; the
+// stopped report; the record's released with Dirty and Evidence; the claim's
+// release. A replay runs it all again: the hand-over, the fence and the drain
+// are idempotent, the evidence reads durable state, the checkpoint is
+// idempotent on a stopped database, and the report is create-only and read
+// back, so the decision is the published report's.
+func (r *rollbackRun) releasing(ctx context.Context) (bool, error) {
+ cfg := r.cfg()
+ host, id := r.rec.Host, r.g.ID
+ if _, err := r.w.rollbackClaim(ctx, r.s, r.rec); err != nil {
+ return false, err
+ }
+ obs, err := r.claim(ctx)
+ if err != nil {
+ return false, err
+ }
+ if op := obs.Claim.Op; op.Phase != hostclaim.PhaseReleasing || op.Exec != cfg.Box {
+ return false, fmt.Errorf("move %s: the claim is restoring/%q with executor %q, not releasing on this box", id, op.Phase, op.Exec)
+ }
+ holder := *obs.Claim.Holder
+ if _, err := writerLease(cfg.OwnPrefix, host, holder); err != nil {
+ return false, fmt.Errorf("move %s: %w", id, err)
+ }
+ seal, err := journalledSeal(obs.Claim)
+ if err != nil {
+ return false, err
+ }
+ handed, err := cfg.HandHold(host, "releasing", r.rb, id)
+ if err != nil {
+ return false, fmt.Errorf("hand the row's hold to the rollback: %w", err)
+ }
+ if !handed {
+ return false, fmt.Errorf("move %s: this box's row is held by neither the move nor rollback %s", id, r.rb)
+ }
+ gen := moveFenceGeneration(r.rec.Move)
+ if err := cfg.Fence(ctx, host, gen); err != nil {
+ return false, fmt.Errorf("fence against wakes: %w", err)
+ }
+ r.w.fenced.Store(host, gen)
+ if err := cfg.TimersDrained(ctx, host); err != nil {
+ return false, fmt.Errorf("drain the timers: %w", err)
+ }
+ dbPath := r.dbPath()
+ ev, err := cfg.Evidence(ctx, host, holder.Location, []string{dbPath})
+ if err != nil {
+ return false, fmt.Errorf("stop evidence: %w", err)
+ }
+ if !ev.TreeStopped() {
+ r.w.notice(host+"#rb-evidence", "moves: %s: the target is not proven stopped yet (%s); the release retries until its deadline", host, failedSteps(ev))
+ return false, nil
+ }
+ reached := r.reachedProbing()
+ fromDB, err := regularFile(dbPath)
+ if err != nil {
+ return false, err
+ }
+ served := reached && fromDB
+ if served {
+ if err := cfg.FinalCheckpoint(ctx, host, dbPath, holder, r.stillReleasing); err != nil {
+ return false, fmt.Errorf("final checkpoint: %w", err)
+ }
+ }
+ if err := r.fault("after-checkpoint"); err != nil {
+ return false, err
+ }
+ n := seal.N
+ if served {
+ if n, err = cfg.LocalPosition(dbPath); err != nil {
+ return false, fmt.Errorf("read the stopped database's position: %w", err)
+ }
+ }
+ var dbSHA string
+ if fromDB {
+ if dbSHA, err = fileSHA256(dbPath); err != nil {
+ return false, fmt.Errorf("digest the stopped database: %w", err)
+ }
+ }
+ dir, envPath := cfg.FilesDir(host)
+ plain, _, _, err := archivePlainDigest(dir, envPath)
+ if err != nil {
+ if reached {
+ return false, fmt.Errorf("digest the stopped files: %w", err)
+ }
+ // No writer ran here: the files are not compared, and may not
+ // have been extracted yet.
+ plain = ""
+ }
+ rows, err := cfg.Timers(host)
+ if err != nil {
+ return false, fmt.Errorf("read the timers: %w", err)
+ }
+ report := ev
+ report.V, report.Role, report.Box, report.Incarnation, report.ClaimGen = 1, stoppedTargetRole, cfg.Box, holder.Incarnation, obs.Claim.Gen
+ report.Host, report.OpID = host, id
+ report.Steps = map[string]moves.Check{}
+ for k, v := range ev.Steps {
+ report.Steps[k] = v
+ }
+ report.Steps[moves.StepReplica] = moves.Check{OK: true}
+ report.FinalTXID, report.DBSHA256, report.FilesPlain, report.TimersSHA256 = n, dbSHA, plain, moves.TimersDigest(rows)
+ // The report's time is after the checkpoint, the target's last write to
+ // its location: it is the horizon of that location's park.
+ report.CheckedAt = r.now().UTC()
+ if !report.Complete() {
+ return false, fmt.Errorf("the target's stopped report is not complete (%s)", failedSteps(report))
+ }
+ key := moves.StoppedKey(moves.ReportPrefix(r.rec.Account, r.rec.App, host, id), stoppedTargetRole)
+ if report, err = publishStoppedReport(ctx, r.s, key, report); err != nil {
+ return false, err
+ }
+ if err := r.fault("after-report"); err != nil {
+ return false, err
+ }
+ clean := seal.SrcCovered && (!reached || r.unchanged(report))
+ log.Printf("moves: %s: the target of rollback %s stopped at TXID %d (clean %t, src_covered %t)", host, r.rb, report.FinalTXID, clean, seal.SrcCovered)
+ ok, err := r.phase(ctx, provision.RbTargetReleasing, provision.RbTargetReleased, func(rb *provision.MoveRollback) {
+ rb.Dirty, rb.Evidence = !clean, key
+ }, "target-released")
+ if !ok || err != nil {
+ return ok, err
+ }
+ if err := r.claimReleased(ctx, key); err != nil {
+ log.Printf("moves: %s: the claim half of the target's release waits for the next pass: %v", host, err)
+ }
+ return true, nil
+}
+
+// claimReleased is the claim half of released (TargetReleased): execution to
+// none citing the target's evidence, the target region's location park whose
+// horizon is the report's time, the target's park-local when its inventory
+// lists F, and the region's predecessors covered. A claim already released
+// with this evidence is done.
+func (r *rollbackRun) claimReleased(ctx context.Context, evidence string) error {
+ cfg := r.cfg()
+ obs, err := r.claim(ctx)
+ if err != nil {
+ return err
+ }
+ op := obs.Claim.Op
+ if op.Phase != hostclaim.PhaseReleasing {
+ if op.Journal[hostclaim.JournalEvidenceTarget] == evidence {
+ return nil
+ }
+ return fmt.Errorf("move %s: the claim is restoring/%q and cites %q, not %s", r.g.ID, op.Phase, op.Journal[hostclaim.JournalEvidenceTarget], evidence)
+ }
+ report, err := readStoppedReport(ctx, r.s, evidence)
+ if err != nil {
+ return err
+ }
+ holder := obs.Claim.Holder
+ scope := hostclaim.ParkScope{Location: strings.TrimSuffix(holder.Location, "/"), Incarnation: holder.Incarnation,
+ EvidenceAt: report.CheckedAt.UTC(), RequestLimit: cutRequestLimit}
+ if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.TargetReleased(r.g.ID, cfg.Box, cfg.Region, evidence, scope, obligationID)); err != nil {
+ return fmt.Errorf("the claim half of the release: %w", err)
+ }
+ log.Printf("moves: %s: the target gave execution up for rollback %s, citing %s", r.rec.Host, r.rb, evidence)
+ return nil
+}
+
+// released: the claim half, if a crash left it, then returning (dirty) or,
+// once the source has recovered, cleaning (clean).
+func (r *rollbackRun) released(ctx context.Context) (bool, error) {
+ if err := r.claimReleased(ctx, r.rollback().Evidence); err != nil {
+ return false, err
+ }
+ if r.rollback().Dirty {
+ return r.phase(ctx, provision.RbTargetReleased, provision.RbTargetReturning, nil, "")
+ }
+ return r.toCleaning(ctx, provision.RbTargetReleased)
+}
+
+// toCleaning waits for the source's recovery (Review Focus 4): until then the
+// target keeps everything it holds.
+func (r *rollbackRun) toCleaning(ctx context.Context, from string) (bool, error) {
+ if r.rollback().RecoveredAt == nil {
+ r.w.notice(r.rec.Host+"#rb-clean", "moves: %s: the target keeps what it holds for rollback %s until the source has recovered", r.rec.Host, r.rb)
+ return false, nil
+ }
+ return r.phase(ctx, from, provision.RbTargetCleaning, nil, "")
+}
+
+// returnedLane is what a return lane holds and restores to.
+type returnedLane struct {
+ n uint64
+ replica, files, timers []moves.SealedObject
+ baseline provision.MoveSource
+}
+
+// returning (§7.8 target returning): the return lane under the source's
+// staging-work lease, the return seal and baseline in the claim's journal,
+// then the record's returned. A replay that finds the seal journalled only
+// writes the record; one that does not writes the lane again (new versions;
+// the seal names the ones it wrote, and the lane's park removes the rest).
+func (r *rollbackRun) returning(ctx context.Context) (bool, error) {
+ cfg := r.cfg()
+ host, id, m := r.rec.Host, r.g.ID, r.rec.Move
+ rb := r.rollback()
+ obs, err := r.claim(ctx)
+ if err != nil {
+ return false, err
+ }
+ if op := obs.Claim.Op; op.Phase != hostclaim.PhaseReleased || op.Exec != "" || op.Journal[hostclaim.JournalEvidenceTarget] != rb.Evidence {
+ return false, fmt.Errorf("move %s: the claim is restoring/%q with executor %q, not released by this target", id, op.Phase, op.Exec)
+ }
+ if _, ok := obs.Claim.Op.Journal[moves.JournalReturnSeal]; ok {
+ return r.phase(ctx, provision.RbTargetReturning, provision.RbTargetReturned, nil, "target-returned")
+ }
+ report, err := readStoppedReport(ctx, r.s, rb.Evidence)
+ if err != nil {
+ return false, err
+ }
+ staged, err := journalledSeal(obs.Claim)
+ if err != nil {
+ return false, err
+ }
+ fleet, err := loadMoveFleet(ctx, r.s)
+ if err != nil {
+ return false, err
+ }
+ src, ok := fleet.targets[m.From]
+ if !ok {
+ return false, fmt.Errorf("source region %s publishes no data bucket. waiting", m.From)
+ }
+ held, release, err := cfg.StagingLease(ctx, m.From, host)
+ if err != nil {
+ var he *lease.HeldError
+ if errors.As(err, &he) {
+ r.w.notice(host+"#rb-lease", "moves: %s: another worker holds the staging lease; the return of %s waits", host, r.rb)
+ return false, nil
+ }
+ return false, fmt.Errorf("staging lease: %w", err)
+ }
+ defer func() {
+ rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := release(rctx); err != nil {
+ log.Printf("moves: %s: release the staging lease: %v", host, err)
+ }
+ }()
+ stg, err := r.w.staging(ctx, src.DataBucketRegion)
+ if err != nil {
+ return false, err
+ }
+ own, err := r.w.mirror(ctx, cfg.OwnBucketRegion)
+ if err != nil {
+ return false, err
+ }
+ gm := &guardedMirror{w: stg, g: moves.Guarded{Lease: held, Now: r.now}, dst: src.DataBucket, own: own}
+ if err := r.laneMoveJSON(ctx, gm); err != nil {
+ return false, err
+ }
+ var lane returnedLane
+ if !r.reachedProbing() || r.unchanged(report) {
+ lane, err = r.copyAsGiven(ctx, gm, staged)
+ } else {
+ lane, err = r.snapshotStopped(ctx, gm, report)
+ }
+ if err != nil {
+ return false, err
+ }
+ // The bound is read after the last lane write: every request the guard
+ // let a writer sign expires before it.
+ bound, err := held.SafeUntil()
+ if err != nil {
+ return false, fmt.Errorf("the staging lease's bound: %w", err)
+ }
+ seal := moves.ReturnSeal(lane.n, lane.replica, lane.files, lane.timers, r.now(), bound)
+ if err := seal.Validate(); err != nil {
+ return false, err
+ }
+ if !seal.ReachesN() {
+ return false, fmt.Errorf("the return lane does not end at TXID %d", seal.N)
+ }
+ base, err := moves.EncodeReturnBaseline(lane.baseline)
+ if err != nil {
+ return false, err
+ }
+ if obs, err = r.claim(ctx); err != nil {
+ return false, err
+ }
+ if _, err := hostclaim.Update(ctx, r.s, obs, journalReturn(id, r.rb, rb.Evidence, seal.Encode(), base)); err != nil {
+ return false, fmt.Errorf("journal the return seal: %w", err)
+ }
+ log.Printf("moves: %s: the return lane of %s is sealed: %d replica objects at TXID %d", host, r.rb, len(seal.Replica), seal.N)
+ if err := r.fault("after-return-seal"); err != nil {
+ return false, err
+ }
+ return r.phase(ctx, provision.RbTargetReturning, provision.RbTargetReturned, nil, "target-returned")
+}
+
+// journalReturn is the return seal's claim write: the seal and the baseline,
+// in one write, on this rollback's op released by this target.
+func journalReturn(opID, rbID, evidence, seal, baseline string) func(*hostclaim.Claim) error {
+ return func(c *hostclaim.Claim) error {
+ if err := hostclaim.RequireOp(opID)(c); err != nil {
+ return err
+ }
+ op := c.Op
+ if op.Kind != hostclaim.KindRestoring || op.Intent["rollback"] != rbID || op.Phase != hostclaim.PhaseReleased || op.Exec != "" ||
+ op.Journal[hostclaim.JournalEvidenceTarget] != evidence {
+ return fmt.Errorf("%w: the return seal needs this rollback released by its target", hostclaim.ErrWrongState)
+ }
+ return hostclaim.SetJournal(opID, map[string]string{moves.JournalReturnSeal: seal, moves.JournalReturnBaseline: baseline})(c)
+ }
+}
+
+// laneMoveJSON writes the lane's move.json create-only (§7.3), naming the
+// rollback; one already there must name it and this incarnation.
+func (r *rollbackRun) laneMoveJSON(ctx context.Context, gm *guardedMirror) error {
+ host, m := r.rec.Host, r.rec.Move
+ body, err := json.Marshal(moves.MoveJSON{MoveID: r.rb, Incarnation: m.Incarnation, Host: host, From: m.To, To: m.From})
+ if err != nil {
+ return err
+ }
+ key := moves.MoveJSONKey(host, r.rb)
+ if _, err := gm.PutIfAbsentVersioned(ctx, gm.dst, key, body); errors.Is(err, lease.ErrExists) {
+ prev, err := gm.Get(ctx, gm.dst, key)
+ if err != nil {
+ return fmt.Errorf("read the lane's move.json: %w", err)
+ }
+ var mj moves.MoveJSON
+ if err := json.Unmarshal(prev, &mj); err != nil || mj.MoveID != r.rb || mj.Incarnation != m.Incarnation {
+ return fmt.Errorf("the lane's move.json names %q/%q, not rollback %s", mj.MoveID, mj.Incarnation, r.rb)
+ }
+ } else if err != nil {
+ return fmt.Errorf("write the lane's move.json: %w", err)
+ }
+ return nil
+}
+
+// copyAsGiven is the return of a target whose state is the staged seal's
+// (dirty only for want of src_covered): every sealed version is read from
+// this box's bucket and written into the lane under the same key relative to
+// the lane, through the guarded staging writer (J22: PutReaderVersionedAt
+// under Guarded.Before). The baseline is the source's own, cited under the
+// target's evidence.
+func (r *rollbackRun) copyAsGiven(ctx context.Context, gm *guardedMirror, staged moves.Seal) (returnedLane, error) {
+ cfg := r.cfg()
+ host, id, m := r.rec.Host, r.g.ID, r.rec.Move
+ if m.Source == nil || m.Source.Files == nil || staged.N != m.Source.FinalTXID {
+ return returnedLane{}, fmt.Errorf("move %s: the record's source baselines are not the staged seal's", id)
+ }
+ vs, err := cfg.Versioned(ctx, cfg.OwnBucket, cfg.OwnBucketRegion)
+ if err != nil {
+ return returnedLane{}, err
+ }
+ tmp := filepath.Join(moves.JournalDir(cfg.DataDir, host, id), "return")
+ if err := os.MkdirAll(tmp, 0o700); err != nil {
+ return returnedLane{}, err
+ }
+ defer os.RemoveAll(tmp)
+ from, lane := moves.StagingPrefix(host, id), moves.ReturnLane(host, r.rb)
+ copyClass := func(objs []moves.SealedObject) ([]moves.SealedObject, error) {
+ var out []moves.SealedObject
+ for _, o := range objs {
+ rel, ok := strings.CutPrefix(o.Key, from)
+ if !ok || rel == "" {
+ return nil, fmt.Errorf("%w: %s is outside the move's lane", moves.ErrBadSeal, o.Key)
+ }
+ path := filepath.Join(tmp, "object")
+ if err := downloadExact(ctx, vs, cfg.OwnBucket, o.Key, o.VersionID, o.Size, path); err != nil {
+ return nil, fmt.Errorf("read %s: %w", o.Key, err)
+ }
+ sum, err := fileSHA256(path)
+ if err != nil {
+ return nil, err
+ }
+ ver, err := gm.PutFileVersioned(ctx, gm.dst, lane+rel, path, o.Size, digestHex(sum))
+ os.Remove(path)
+ if err != nil {
+ return nil, fmt.Errorf("return %s: %w", o.Key, err)
+ }
+ if !lease.UsableVersionID(ver) {
+ return nil, fmt.Errorf("the returned %s has no usable version id %q", rel, ver)
+ }
+ out = append(out, moves.SealedObject{Key: lane + rel, VersionID: ver, Size: o.Size})
+ }
+ return out, nil
+ }
+ var out returnedLane
+ if out.replica, err = copyClass(staged.Replica); err != nil {
+ return out, err
+ }
+ if out.files, err = copyClass(staged.Files); err != nil {
+ return out, err
+ }
+ if out.timers, err = copyClass(staged.Timers); err != nil {
+ return out, err
+ }
+ files := *m.Source.Files
+ out.n, out.baseline = staged.N, *m.Source
+ out.baseline.Files, out.baseline.Evidence = &files, r.rollback().Evidence
+ return out, nil
+}
+
+// downloadExact reads one exact version of size bytes into path.
+func downloadExact(ctx context.Context, vs lease.VersionedStore, bucket, key, version string, size int64, path string) error {
+ rc, err := vs.OpenVersion(ctx, bucket, key, version)
+ if err != nil {
+ return err
+ }
+ defer rc.Close()
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ n, err := io.Copy(f, io.LimitReader(rc, size+1))
+ if cerr := f.Close(); err == nil {
+ err = cerr
+ }
+ if err != nil {
+ return err
+ }
+ if n != size {
+ return fmt.Errorf("the version is %d bytes, sealed as %d", n, size)
+ }
+ return nil
+}
+
+// snapshotStopped is the return of a target that served and changed: one
+// snapshot [1, N'] of the stopped database (moves.EncodeFull, in memory, the
+// Phase 1 size limit), the target's files archive (manifest bound to the move
+// id) and its timers. Each must still be what the stopped report recorded.
+func (r *rollbackRun) snapshotStopped(ctx context.Context, gm *guardedMirror, report moves.StoppedReport) (returnedLane, error) {
+ cfg := r.cfg()
+ host, id := r.rec.Host, r.g.ID
+ dbPath := r.dbPath()
+ sha, err := fileSHA256(dbPath)
+ if err != nil {
+ return returnedLane{}, fmt.Errorf("the target's stopped database: %w", err)
+ }
+ if sha != report.DBSHA256 {
+ return returnedLane{}, fmt.Errorf("move %s: the target's database changed since its release", id)
+ }
+ n, err := cfg.LocalPosition(dbPath)
+ if err != nil {
+ return returnedLane{}, fmt.Errorf("read the stopped database's position: %w", err)
+ }
+ if n != report.FinalTXID || n == 0 {
+ return returnedLane{}, fmt.Errorf("move %s: the stopped database is at TXID %d, the report at %d", id, n, report.FinalTXID)
+ }
+ pageSize, pageCount, err := moves.ReadDBHeader(dbPath)
+ if err != nil {
+ return returnedLane{}, err
+ }
+ f, err := os.Open(dbPath)
+ if err != nil {
+ return returnedLane{}, err
+ }
+ data, _, err := moves.EncodeFull(f, pageSize, pageCount, 1, ltx.TXID(n), report.CheckedAt.UnixMilli(), 0)
+ f.Close()
+ if err != nil {
+ return returnedLane{}, fmt.Errorf("snapshot the stopped database: %w", err)
+ }
+ var out returnedLane
+ key := moves.SnapshotKey(moves.ReplicaPrefix(host, r.rb), n)
+ ver, err := gm.PutVersioned(ctx, gm.dst, key, data)
+ if err != nil {
+ return out, fmt.Errorf("return the snapshot: %w", err)
+ }
+ if !lease.UsableVersionID(ver) {
+ return out, fmt.Errorf("the returned snapshot has no usable version id %q", ver)
+ }
+ out.replica = []moves.SealedObject{{Key: key, VersionID: ver, Size: int64(len(data))}}
+ a, why, retry, err := r.w.buildArchive(r.rec)
+ switch {
+ case err != nil:
+ return out, fmt.Errorf("archive the target's files: %w", err)
+ case why != "":
+ return out, fmt.Errorf("move %s: the target's files cannot be archived (%s)", id, why)
+ case retry:
+ return out, errors.New("a stopped file changed while it was archived")
+ case a.Plain != report.FilesPlain:
+ return out, fmt.Errorf("move %s: the target's files are %s, the report %s", id, a.Plain, report.FilesPlain)
+ }
+ ref, err := a.stage(ctx, gm, gm.dst, host, r.rb)
+ if err != nil {
+ return out, err
+ }
+ mst, err := os.Stat(a.manifestPath())
+ if err != nil {
+ return out, err
+ }
+ if !lease.UsableVersionID(ref.SealedVersion) || !lease.UsableVersionID(ref.ManifestVersion) {
+ return out, fmt.Errorf("the returned archive has no usable version ids (%q, %q)", ref.SealedVersion, ref.ManifestVersion)
+ }
+ out.files = []moves.SealedObject{{Key: ref.SealedObjectKey, VersionID: ref.SealedVersion, Size: a.SealedSize},
+ {Key: ref.ManifestKey, VersionID: ref.ManifestVersion, Size: mst.Size()}}
+ rows, err := cfg.Timers(host)
+ if err != nil {
+ return out, fmt.Errorf("read the timers: %w", err)
+ }
+ if moves.TimersDigest(rows) != report.TimersSHA256 {
+ return out, fmt.Errorf("move %s: the target's timers changed since its release", id)
+ }
+ body, err := moves.TimersSnapshot{V: 1, Host: host, Rows: rows, TakenAt: report.CheckedAt}.Encode()
+ if err != nil {
+ return out, err
+ }
+ tkey := moves.TimersPrefix(host, r.rb) + "timers.json"
+ tver, err := gm.PutVersioned(ctx, gm.dst, tkey, body)
+ if err != nil {
+ return out, fmt.Errorf("return the timers: %w", err)
+ }
+ if !lease.UsableVersionID(tver) {
+ return out, fmt.Errorf("the returned timers have no usable version id %q", tver)
+ }
+ out.timers = []moves.SealedObject{{Key: tkey, VersionID: tver, Size: int64(len(body))}}
+ rt, err := cfg.Route(host)
+ if err != nil {
+ return out, fmt.Errorf("read the registry row: %w", err)
+ }
+ if rt == nil {
+ return out, fmt.Errorf("%s has no registry row on this box", host)
+ }
+ out.n = n
+ out.baseline = provision.MoveSource{Replication: rt.Replication, NextDue: rt.NextDue, FinalTXID: n, DBSHA256: sha,
+ Files: &provision.MoveFilesDigest{Plain: a.Plain, Sealed: a.Sealed, Bytes: a.Bytes, Entries: a.Entries},
+ TimersSHA256: report.TimersSHA256, Evidence: r.rollback().Evidence}
+ return out, nil
+}
+
+// journal is the target's op journal of the move (the landing's).
+func (r *rollbackRun) journal() (*moves.Journal, error) {
+ return moves.ReadJournal(r.cfg().DataDir, r.rec.Host, r.g.ID)
+}
+
+// cleaning (§7.8 target cleaning, INV-10), each step journalled and skipped
+// on a replay once done: the remote parks; the registered installation; the
+// moves-owned rename of everything else at the flat paths; the row; then the
+// record's cleaned with target_parked_at. The source has recovered (the
+// record says so), so nothing here is still needed by the move.
+func (r *rollbackRun) cleaning(ctx context.Context) (bool, error) {
+ cfg := r.cfg()
+ host, id := r.rec.Host, r.g.ID
+ j, err := r.journal()
+ if err != nil {
+ return false, err
+ }
+ mark := func(step, value string) error {
+ j.Mark(step, value)
+ if err := j.Write(cfg.DataDir); err != nil {
+ return err
+ }
+ return r.fault("after-" + step)
+ }
+ obs, err := hostclaim.Read(ctx, r.s, host)
+ if err != nil {
+ return false, fmt.Errorf("read the host claim: %w", err)
+ }
+ if obs.Absent || obs.Claim.Holder == nil || obs.Claim.Holder.Incarnation != r.g.Incarnation {
+ return false, fmt.Errorf("move %s: the host claim names no holder of this incarnation", id)
+ }
+ ours := r.ours(obs.Claim) == nil
+ if ours && !j.Done(rbStepTargetStamp) {
+ if err := mark(rbStepTargetStamp, obs.Claim.Op.Journal[hostclaim.JournalTargetStamp]); err != nil {
+ return false, err
+ }
+ }
+ if !j.Done(rbStepRemote) {
+ // An op that has moved on had its chores acknowledged with it.
+ if ours {
+ done, err := r.remote(ctx, obs, j, mark)
+ if err != nil || !done {
+ return false, err
+ }
+ }
+ if err := mark(rbStepRemote, ""); err != nil {
+ return false, err
+ }
+ }
+ if !j.Done(rbStepInstallation) {
+ how, err := r.installation(ctx, ours)
+ if err != nil {
+ return false, err
+ }
+ if err := mark(rbStepInstallation, how); err != nil {
+ return false, err
+ }
+ }
+ if err := r.parkLeftovers(ctx, j, mark); err != nil {
+ return false, fmt.Errorf("park the target's local artifacts: %w", err)
+ }
+ if err := r.deleteRow(ctx, j, mark); err != nil {
+ return false, err
+ }
+ return r.phase(ctx, provision.RbTargetCleaning, provision.RbTargetCleaned, func(rb *provision.MoveRollback) {
+ t := r.now().UTC()
+ rb.TargetParkedAt = &t
+ }, "target-cleaned")
+}
+
+// remote parks the target region's location and the move's lane in it: the
+// lane's park is queued once (its horizon the staged seal's bound), then
+// every park-replica of this op in this region runs through the park engine
+// until its archive is complete (park.Engine.Parked). The acknowledgement,
+// after the horizon, is the engine's or the regional runner's (C4), never a
+// reason to wait here; a chore no longer in the claim was acknowledged.
+func (r *rollbackRun) remote(ctx context.Context, obs hostclaim.Observed, j *moves.Journal, mark func(step, value string) error) (bool, error) {
+ cfg := r.cfg()
+ host, id := r.rec.Host, r.g.ID
+ if !j.Done(rbStepLaneQueued) {
+ staged, err := journalledSeal(obs.Claim)
+ if err != nil {
+ return false, err
+ }
+ scope := hostclaim.ParkScope{Location: moves.ScopeLocation(host, id), Incarnation: obs.Claim.Holder.Incarnation,
+ EvidenceAt: staged.Bound().UTC(), RequestLimit: cutRequestLimit}
+ if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.QueueStagingPark(id, cfg.Region, scope, obligationID)); err != nil {
+ return false, fmt.Errorf("queue the move's lane park: %w", err)
+ }
+ if err := mark(rbStepLaneQueued, ""); err != nil {
+ return false, err
+ }
+ }
+ obs, err := r.claim(ctx)
+ if err != nil {
+ return false, err
+ }
+ vs, err := cfg.Versioned(ctx, cfg.OwnBucket, cfg.OwnBucketRegion)
+ if err != nil {
+ return false, err
+ }
+ e := r.engine(vs)
+ waiting := 0
+ for _, o := range obs.Claim.Obligations {
+ if o.Op != id || o.Kind != "park-replica" || o.Region != cfg.Region || o.Park == nil {
+ continue
+ }
+ work := park.Work{Host: host, ID: o.ID, Op: id}
+ if err := e.Run(ctx, work); err != nil && !errors.Is(err, park.ErrPending) && !errors.Is(err, park.ErrChanged) {
+ return false, fmt.Errorf("park %s: %w", o.Park.Location, err)
+ }
+ parked, err := e.Parked(ctx, work)
+ if errors.Is(err, park.ErrChanged) {
+ continue
+ }
+ if err != nil {
+ return false, fmt.Errorf("the park of %s: %w", o.Park.Location, err)
+ }
+ if !parked {
+ waiting++
+ }
+ }
+ if waiting > 0 {
+ r.w.notice(host+"#rb-remote", "moves: %s: the target's cleaning of %s waits for %d park archive(s)", host, r.rb, waiting)
+ return false, nil
+ }
+ log.Printf("moves: %s: the target region's copies of %s are parked", host, id)
+ return true, nil
+}
+
+// engine is the park engine over this box's own bucket: the wired leases, and
+// the view and authorizer Plan 5 supplies, or the stand-ins (the view with
+// the source's region authoritative, which it is again after the rollback).
+func (r *rollbackRun) engine(vs lease.VersionedStore) park.Engine {
+ cfg := r.cfg()
+ view, auth := cfg.ParkView, cfg.ParkAuthorize
+ if view == nil {
+ view = standInView(cfg.Region, roleTarget, r.rec.Move)
+ }
+ if auth == nil {
+ auth = func(_ context.Context, obs hostclaim.Observed, o hostclaim.Obligation) error {
+ if obs.Absent {
+ return park.ErrUnsafe
+ }
+ return r.authorizeChore(obs.Claim, o)
+ }
+ }
+ return park.Engine{Store: vs, Claims: r.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases,
+ Now: r.now, NewID: obligationID, View: view, Authorize: auth, After: cfg.parkFault}
+}
+
+// authorizeChore is the stand-in rule until Plan 5 (ruling G17): a chore is
+// run only when it is this op's, the claim is still this rollback's with this
+// box no longer executing, and the chore is this region's or this box's.
+func (r *rollbackRun) authorizeChore(c *hostclaim.Claim, o hostclaim.Obligation) error {
+ cfg := r.cfg()
+ if r.ours(c) != nil || o.Op != r.g.ID || c.Op.Exec == cfg.Box {
+ return fmt.Errorf("%w: not this rollback's chore", park.ErrUnsafe)
+ }
+ switch {
+ case o.Kind == "park-replica" && o.Region == cfg.Region:
+ case o.Kind == "park-local" && o.Box == cfg.Box:
+ default:
+ return fmt.Errorf("%w: not this target's chore", park.ErrUnsafe)
+ }
+ return nil
+}
+
+// targetStamp is F: the claim's journalled target stamp, as noted in this
+// box's journal when cleaning began.
+func targetStamp(j *moves.Journal) uint64 {
+ f, err := strconv.ParseUint(j.Steps[rbStepTargetStamp], 10, 64)
+ if err != nil {
+ return 0
+ }
+ return f
+}
+
+// guard is the host guard over this box's data directory, reading the claim
+// from the pass's store.
+func (r *rollbackRun) guard() hostguard.Guard {
+ g := r.cfg().Guard
+ if g.Claims == nil {
+ g.Claims = hostguard.StoreReader{Store: r.s}
+ }
+ return g
+}
+
+// installation is the registered half of the local retirement (J15): with the
+// target's park-local and inventory paths of F, the installation is retired
+// through park.Local under the host guard, which completes the chore itself;
+// a park-local with nothing of F listed is completed; neither is an
+// unregistered target, whose artifacts are all the moves-owned rename's.
+func (r *rollbackRun) installation(ctx context.Context, ours bool) (string, error) {
+ cfg := r.cfg()
+ host, id := r.rec.Host, r.g.ID
+ if !ours {
+ return "moved-on", nil
+ }
+ obs, err := r.claim(ctx)
+ if err != nil {
+ return "", err
+ }
+ c := obs.Claim
+ f, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalTargetStamp], 10, 64)
+ if err != nil || f == 0 {
+ return "", fmt.Errorf("move %s: the claim journals no target stamp", id)
+ }
+ var chore *hostclaim.Obligation
+ for i := range c.Obligations {
+ if o := &c.Obligations[i]; o.Op == id && o.Kind == "park-local" && o.Box == cfg.Box {
+ chore = o
+ }
+ }
+ registered := hostclaim.InventoryHasStamp(c, cfg.Box, f)
+ switch {
+ case chore != nil && registered:
+ captured, err := hostguard.Capture(cfg.DataDir, host)
+ if err != nil {
+ return "", fmt.Errorf("the target's installation record: %w", err)
+ }
+ if captured.Incarnation != c.Holder.Incarnation || (captured.Stamp != 0 && captured.Stamp != f) {
+ return "", fmt.Errorf("move %s: the target's installation record is at %s/%d, the claim's is %s/%d", id, captured.Incarnation, captured.Stamp, c.Holder.Incarnation, f)
+ }
+ local := cfg.Local
+ local.Guard, local.Claims = r.guard(), r.s
+ if local.Box == "" {
+ local.Box = cfg.Box
+ }
+ if local.Authorize == nil {
+ local.Authorize = func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error {
+ if auth := cfg.ParkAuthorize; auth != nil {
+ return auth(ctx, hostclaim.Observed{Host: host, Claim: c}, o)
+ }
+ return r.authorizeChore(c, o)
+ }
+ }
+ if err := local.RetireInstallation(ctx, host, hostguard.Captured{Incarnation: captured.Incarnation, Stamp: f}, chore.ID, id); err != nil {
+ return "", fmt.Errorf("retire the target's installation: %w", err)
+ }
+ log.Printf("moves: %s: the target's registered installation (stamp %d) is retired", host, f)
+ return "retired", nil
+ case chore != nil:
+ if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.CompleteLocalPark(id, cfg.Box, f)); err != nil {
+ return "", fmt.Errorf("complete the target's local park: %w", err)
+ }
+ return "completed", nil
+ case registered:
+ return "", fmt.Errorf("move %s: this box's inventory lists the target's paths, and the release queued no park-local for them", id)
+ }
+ return "unregistered", nil
+}
+
+// parkLeftovers is the moves-owned rename (H5(b), parkLocalArtifacts) of
+// every artifact the target still has at the flat paths (the installation's,
+// the rendered config and the sidecar's env), its plan and its renames inside
+// the host guard, which retires the installation record at F if it is still
+// active (an unregistered target), with the claim read fresh there: this box
+// no longer executes and no longer takes part in the claim's op, and no
+// inventory entry claims a path the rename would move.
+func (r *rollbackRun) parkLeftovers(ctx context.Context, j *moves.Journal, mark func(step, value string) error) error {
+ if j.Done(rbStepLocal) {
+ return nil
+ }
+ cfg := r.cfg()
+ host, id := r.rec.Host, r.g.ID
+ paths := sourceArtifactPaths(host)
+ var incarnation string
+ check := func(c *hostclaim.Claim) error {
+ if c.Op.Exec == cfg.Box {
+ return fmt.Errorf("%w: this box executes the host again", hostclaim.ErrWrongState)
+ }
+ if c.Op.ID == id {
+ if err := r.ours(c); err != nil {
+ return err
+ }
+ } else if containsString(c.Op.Participants, cfg.Box) {
+ return fmt.Errorf("%w: this box takes part in the claim's op %s", hostclaim.ErrWrongState, c.Op.ID)
+ }
+ for _, p := range paths {
+ if _, ok := c.Inventory[cfg.Box].Paths[p]; ok {
+ return fmt.Errorf("%w: the inventory claims %s", hostclaim.ErrWrongState, p)
+ }
+ }
+ incarnation = c.Holder.Incarnation
+ return nil
+ }
+ work := func() error {
+ if incarnation != r.g.Incarnation {
+ return fmt.Errorf("move %s: the claim names another incarnation", id)
+ }
+ _, err := parkLocalArtifacts(ctx, j, mark, localArtifactPark{DataDir: cfg.DataDir, Incarnation: incarnation, Host: host,
+ Op: id, Step: rbStepLocal, Paths: paths, NewID: obligationID, Fault: r.fault})
+ return err
+ }
+ g := r.guard()
+ captured, err := hostguard.Capture(cfg.DataDir, host)
+ switch {
+ case errors.Is(err, hostguard.ErrNoRecord):
+ return g.WithNoInstallation(ctx, host, r.g.Incarnation, check, work)
+ case err != nil:
+ return fmt.Errorf("the target's installation record: %w", err)
+ }
+ f := captured.Stamp
+ if f == 0 {
+ f = targetStamp(j)
+ }
+ if f == 0 {
+ return fmt.Errorf("move %s: the target's retired installation stamp is not known", id)
+ }
+ return g.RetireInstallationWith(ctx, host, hostguard.Captured{Incarnation: r.g.Incarnation, Stamp: f}, check, work)
+}
+
+// deleteRow deletes the row and its timers rows in one registry transaction
+// (registry.DeleteIfHoldWithTimers, J12), only while the rollback holds the
+// row, under the host's hold lock with the record re-read there; the journal
+// says "pending" before and "done" after, and a replay that finds "pending"
+// and no row of this rollback's treats the delete as done.
+func (r *rollbackRun) deleteRow(ctx context.Context, j *moves.Journal, mark func(step, value string) error) error {
+ if j.Steps[rbStepRowDeleted] == "done" {
+ return nil
+ }
+ cfg := r.cfg()
+ host, id := r.rec.Host, r.g.ID
+ var err error
+ provision.WithHostHoldLock(host, func() {
+ fresh, e := provision.GetRecord(ctx, r.s, r.rec.Account, r.rec.App, host)
+ if e != nil {
+ err = fmt.Errorf("re-read the record: %w", e)
+ return
+ }
+ if m := fresh.Move; m == nil || m.ID != id || m.Incarnation != r.g.Incarnation || m.State != provision.MoveRollingBack ||
+ m.Rollback == nil || m.Rollback.ID != r.rb || m.Rollback.TargetPhase != provision.RbTargetCleaning {
+ err = fmt.Errorf("move %s: the record is no longer this rollback's cleaning", id)
+ return
+ }
+ if j.Steps[rbStepRowDeleted] != "pending" {
+ if err = mark(rbStepRowDeleted, "pending"); err != nil {
+ return
+ }
+ }
+ deleted, e := cfg.DeleteHeld(host, r.rb)
+ if e != nil {
+ err = fmt.Errorf("delete the target's row: %w", e)
+ return
+ }
+ if deleted {
+ log.Printf("moves: %s: the target's row and its timers are deleted", host)
+ }
+ err = mark(rbStepRowDeleted, "done")
+ })
+ return err
+}
+
+// cleaned parks the op's journal into the local retirement, after the record
+// says cleaned: until then it is what routes a replay back here.
+func (r *rollbackRun) cleaned() error {
+ cfg := r.cfg()
+ host, id := r.rec.Host, r.g.ID
+ if !moveJournalPresent(cfg.DataDir, host, id) {
+ return nil
+ }
+ j, err := r.journal()
+ if err != nil {
+ return err
+ }
+ retirement := j.Steps[rbStepLocal]
+ if retirement == "" {
+ return fmt.Errorf("move %s: the journal records no local retirement for the target's park", id)
+ }
+ plan, err := journalledLocalPlan(j, rbStepLocal)
+ if err != nil {
+ return err
+ }
+ if err := parkOpJournal(cfg.DataDir, plan.Incarnation, host, id, retirement); err != nil {
+ return fmt.Errorf("park the op journal: %w", err)
+ }
+ log.Printf("moves: %s: the target's half of rollback %s is complete", host, r.rb)
+ return nil
+}
diff --git a/internal/host/movesrollback_test.go b/internal/host/movesrollback_test.go
new file mode 100644
index 000000000..3eecb7359
--- /dev/null
+++ b/internal/host/movesrollback_test.go
@@ -0,0 +1,937 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/moves/archive"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/superfly/ltx"
+)
+
+// The rollback on the target (moves spec §7.8; Plan 4 Task 4): the decision
+// that ends a landing, then the target's release, its return lane and its
+// cleaning. The fixture is the landing harness landed through restored and
+// stepped to landing/probing on both the record and the claim (the probe is
+// Plan 3's and not on this branch), with the target box's rollback hooks
+// faked where main has no production provider in a test: the hold hand-over,
+// the final checkpoint, the local position and the row delete.
+
+type rollbackHarness struct {
+ *landingHarness
+ rw *movesWorker
+ handed []string // status:to:from of each hand-over asked for
+ rowGone bool
+ deleted []string
+ tchecks int // the target's final checkpoints
+ tpos uint64 // the target's local position
+ events []string
+ rbAt string
+ rbDone bool
+ rbHits []string
+}
+
+func newRollbackHarness(t *testing.T) *rollbackHarness {
+ t.Helper()
+ h := &rollbackHarness{landingHarness: newLandingHarness(t)}
+ h.flip(t)
+ h.land(t)
+ h.installed(t)
+ h.step(t, provision.LandRestored, provision.LandProbing)
+ h.tpos = h.n
+ return h
+}
+
+// step moves the landing one phase on the record and the claim, as the
+// probe (Plan 3) would.
+func (h *rollbackHarness) step(t *testing.T, from, to string) {
+ t.Helper()
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveLandingPhase(g, from, to, nil)); err != nil {
+ t.Fatal(err)
+ }
+ obs, err := hostclaim.Read(context.Background(), h.s, p1Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostclaim.Update(context.Background(), h.s, obs, hostclaim.SetMovePhase(p1ID, hostclaim.KindLanding, from, to)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// rollbackCfg is the target box's configuration with the rollback armed.
+func (h *rollbackHarness) rollbackCfg(t *testing.T) moveCfg {
+ t.Helper()
+ c := h.landingCfg(t)
+ c.OwnsRoute = func(host string) bool { return host == p1Host && !h.rowGone }
+ c.Route = func(host string) (*registry.Route, error) {
+ if host != p1Host || h.rowGone {
+ return nil, nil
+ }
+ rt := h.route
+ return &rt, nil
+ }
+ c.HandHold = func(host, status, to, from string) (bool, error) {
+ h.handed = append(h.handed, status+":"+to+":"+from)
+ if h.rowGone {
+ return false, nil
+ }
+ if h.route.HoldOp == to && h.route.Status == status {
+ return true, nil
+ }
+ if h.route.HoldOp != from {
+ return false, nil
+ }
+ h.route.Status, h.route.HoldOp = status, to
+ return true, nil
+ }
+ c.FinalCheckpoint = func(ctx context.Context, host, _ string, holder hostclaim.Holder, check func(context.Context) error) error {
+ if err := check(ctx); err != nil {
+ return err
+ }
+ if host != p1Host || strings.TrimSuffix(holder.Location, "/") != cutRoot {
+ t.Errorf("checkpoint of %s at %s", host, holder.Location)
+ }
+ h.tchecks++
+ return nil
+ }
+ c.LocalPosition = func(string) (uint64, error) { return h.tpos, nil }
+ c.DeleteHeld = func(host, op string) (bool, error) {
+ h.deleted = append(h.deleted, host+"="+op)
+ if h.rowGone || h.route.HoldOp != op {
+ return false, nil
+ }
+ h.rowGone = true
+ return true, nil
+ }
+ c.LogEvent = func(_ context.Context, _ provision.Record, event string) { h.events = append(h.events, event) }
+ c.rbFault = func(point string) error {
+ h.rbHits = append(h.rbHits, point)
+ if point == h.rbAt && !h.rbDone {
+ h.rbDone = true
+ return errCrash
+ }
+ return nil
+ }
+ return c
+}
+
+// rollbackPass runs one pass as the target box, its worker kept across
+// passes.
+func (h *rollbackHarness) rollbackPass(t *testing.T) {
+ t.Helper()
+ if h.rw == nil {
+ h.rw = &movesWorker{mirrors: map[string]lease.MirrorStore{}}
+ }
+ h.rw.cfg = h.rollbackCfg(t)
+ if err := movesPass(context.Background(), h.s, h.rw); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// rollBack passes the landing's deadline and runs the target's pass that
+// decides the rollback.
+func (h *rollbackHarness) rollBack(t *testing.T) *provision.MoveRollback {
+ t.Helper()
+ h.clock.Advance(cutLandingWindow + time.Minute)
+ h.rollbackPass(t)
+ m := h.read(t, p1Host).Move
+ if m.State != provision.MoveRollingBack || m.Rollback == nil {
+ t.Fatalf("no rollback: %+v", m)
+ }
+ return m.Rollback
+}
+
+func (h *rollbackHarness) rb(t *testing.T) *provision.MoveRollback {
+ t.Helper()
+ m := h.read(t, p1Host).Move
+ if m == nil || m.Rollback == nil {
+ t.Fatalf("record: %+v", m)
+ }
+ return m.Rollback
+}
+
+// uncover marks the staged seal as not covering the source (§7.4 rev 7.11):
+// the source's own replica did not reach its stopped position.
+func (h *rollbackHarness) uncover(t *testing.T) {
+ t.Helper()
+ rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) {
+ s, err := journalledSeal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.SrcCovered = false
+ c.Op.Journal[hostclaim.JournalSeal] = s.Encode()
+ })
+}
+
+// recovered drives the source's half of the record to recovered, stamping
+// RecoveredAt, as Task 5's source would.
+func (h *rollbackHarness) recovered(t *testing.T) {
+ t.Helper()
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ path := []string{provision.RbSourceWaiting, provision.RbSourceClaimed, provision.RbSourceRestoring, provision.RbSourceVerified, provision.RbSourceProbing}
+ if h.rb(t).Dirty {
+ path = []string{provision.RbSourceWaiting, provision.RbSourceClaimed, provision.RbSourceRestoring, provision.RbSourceParking, provision.RbSourceBuilding,
+ provision.RbSourcePublished, provision.RbSourceLocal, provision.RbSourceConfigured, provision.RbSourceRestored, provision.RbSourceProbing}
+ }
+ path = append(path, provision.RbSourceProbed, provision.RbSourceRecovered)
+ for i := 0; i+1 < len(path); i++ {
+ var stamp func(*provision.MoveRollback)
+ if path[i+1] == provision.RbSourceRecovered {
+ now := h.clock.Now()
+ stamp = func(rb *provision.MoveRollback) { rb.RecoveredAt = &now }
+ }
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveRollbackSourcePhase(g, path[i], path[i+1], stamp)); err != nil {
+ t.Fatalf("%s to %s: %v", path[i], path[i+1], err)
+ }
+ }
+}
+
+// rolledBack asserts the rollback decision took the landing: the record
+// rolling_back in the source's region with reason, the landing phase kept,
+// the target releasing; the claim restoring/releasing under the rollback.
+func rolledBack(t *testing.T, h *landingHarness, reason, phase string) {
+ t.Helper()
+ rec := h.read(t, p1Host)
+ m := rec.Move
+ if rec.Region != "ie" || m.State != provision.MoveRollingBack || m.Rollback == nil || m.Rollback.Reason != reason ||
+ m.LandingPhase != phase || m.Rollback.TargetPhase != provision.RbTargetReleasing {
+ t.Fatalf("not rolled back for %q at %s: %s %+v %+v", reason, phase, rec.Region, m, m.Rollback)
+ }
+ if c := h.claim(t); c.Op.Kind != hostclaim.KindRestoring || c.Op.Phase != hostclaim.PhaseReleasing || c.Op.Intent["rollback"] != m.Rollback.ID {
+ t.Fatalf("the claim did not follow: %+v", c.Op)
+ }
+}
+
+func targetStoppedKey() string {
+ return moves.StoppedKey(moves.ReportPrefix("bab", "app", p1Host, p1ID), "target")
+}
+
+// laneCalls are the guarded staging requests into the source's bucket.
+func (h *rollbackHarness) laneCalls(rb string) []stagingCall {
+ var out []stagingCall
+ for _, c := range h.f.stagingCalls() {
+ if c.Bucket == "data-ie" && strings.HasPrefix(c.Key, "moves/"+p1Host+"/rb_") {
+ if !strings.HasPrefix(c.Key, moves.ReturnLane(p1Host, rb)) {
+ panic("a lane write outside the rollback's lane: " + c.Key)
+ }
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+// TestRollbackDecisionConvertsTheMark: a marked landing is rolled back with
+// the mark's reason, in the one place the mark is converted, before the
+// landing's arming gate (J10); the claim follows (RollBack, or
+// RollBackFlipped over a claim the flip's half never reached, J16), with the
+// target installation's stamp F journalled (J15).
+func TestRollbackDecisionConvertsTheMark(t *testing.T) {
+ t.Run("the claim at landing", func(t *testing.T) {
+ h := newRollbackHarness(t)
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveMarkRollback(g, provision.RollbackProbeAttempts)); err != nil {
+ t.Fatal(err)
+ }
+ f := h.claim(t).Holder.Stamp
+ // Unarmed: the decision runs all the same.
+ h.rw = &movesWorker{mirrors: map[string]lease.MirrorStore{}}
+ c := h.rollbackCfg(t)
+ c.ParkLeases = nil
+ h.rw.cfg = c
+ if err := movesPass(context.Background(), h.s, h.rw); err != nil {
+ t.Fatal(err)
+ }
+ rec := h.read(t, p1Host)
+ m := rec.Move
+ if m.State != provision.MoveRollingBack || rec.Region != "ie" || m.Rollback.Reason != provision.RollbackProbeAttempts ||
+ m.Rollback.TargetPhase != provision.RbTargetReleasing || !provision.ValidRollbackID(m.Rollback.ID) || m.LandingPhase != provision.LandProbing {
+ t.Fatalf("record: %s %+v %+v", rec.Region, m, m.Rollback)
+ }
+ cl := h.claim(t)
+ if cl.Op.Kind != hostclaim.KindRestoring || cl.Op.Phase != hostclaim.PhaseReleasing || cl.Op.Intent["rollback"] != m.Rollback.ID ||
+ cl.Op.Exec != "uk-box" || cl.Op.Journal[hostclaim.JournalTargetStamp] != strconv.FormatUint(f, 10) {
+ t.Fatalf("claim: %+v", cl.Op)
+ }
+ if len(h.events) != 1 || h.events[0] != "rollback" {
+ t.Fatalf("events %v", h.events)
+ }
+ })
+ t.Run("the claim still moving", func(t *testing.T) {
+ h := &rollbackHarness{landingHarness: newLandingHarness(t)}
+ h.ready(t)
+ h.crashAt = "after-record-flip"
+ h.cut(t)
+ h.crashPass(t)
+ if c := h.claim(t); c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseSealed {
+ t.Fatalf("fixture claim: %+v", c.Op)
+ }
+ h.rollBack(t)
+ m := h.read(t, p1Host).Move
+ if m.Rollback.Reason != provision.RollbackLandingDeadline || m.LandingPhase != provision.LandChecking {
+ t.Fatalf("record: %+v", m.Rollback)
+ }
+ if c := h.claim(t); c.Op.Kind != hostclaim.KindRestoring || c.Op.Phase != hostclaim.PhaseReleasing || c.Op.Exec != "uk-box" ||
+ c.Op.Journal[hostclaim.JournalSrcGen] != "cur" || c.Op.Intent["rollback"] != m.Rollback.ID {
+ t.Fatalf("claim: %+v", c.Op)
+ }
+ })
+}
+
+// TestRollbackCASLosesToCommit: the rollback and the commit race on the
+// record; once the commit has won, the rollback's CAS is refused and leaves
+// no trace: the record committed with no rollback, the claim and the row as
+// they were. The open is the commit's path (Plan 3 Task 7).
+func TestRollbackCASLosesToCommit(t *testing.T) {
+ ctx := context.Background()
+ h := newRollbackHarness(t)
+ h.step(t, provision.LandProbing, provision.LandProbed)
+ stale := h.read(t, p1Host)
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveCommit(g, h.clock.Now())); err != nil {
+ t.Fatal(err)
+ }
+ before, err := hostclaim.Read(ctx, h.s, p1Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ h.clock.Advance(cutLandingWindow + time.Minute)
+ w := &movesWorker{cfg: h.rollbackCfg(t), mirrors: map[string]lease.MirrorStore{}}
+ done, err := w.rollBackLanding(ctx, h.s, stale)
+ if err != nil || !done {
+ t.Fatalf("the decision over a stale landing: %v %v", done, err)
+ }
+ rec := h.read(t, p1Host)
+ if rec.Move.State != provision.MoveCommitted || rec.Move.Rollback != nil || rec.Region != "uk" {
+ t.Fatalf("record: %s %+v", rec.Region, rec.Move)
+ }
+ after, err := hostclaim.Read(ctx, h.s, p1Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after.ETag != before.ETag || after.Claim.Op.Kind != hostclaim.KindLanding {
+ t.Fatalf("the claim was written: %+v", after.Claim.Op)
+ }
+ if len(h.handed) != 0 || h.route.HoldOp != p1ID || len(h.events) != 0 {
+ t.Fatalf("the rollback left a trace: %v %+v %v", h.handed, h.route, h.events)
+ }
+ // A pass over the committed record leaves it too.
+ h.rollbackPass(t)
+ if rec := h.read(t, p1Host); rec.Move.State != provision.MoveCommitted || rec.Move.Rollback != nil {
+ t.Fatalf("after a pass: %+v", rec.Move)
+ }
+}
+
+// TestReleaseIsCleanWhenNothingChanged: the landing reached probing, but
+// nothing the target holds differs from what it installed (the database,
+// the files, the timers) and the staged seal covers the source: the release
+// is clean. Execution goes to none citing stopped-target.json, the target
+// region's whole location is queued for its park with the target's
+// park-local, and the target region's predecessors are covered.
+func TestReleaseIsCleanWhenNothingChanged(t *testing.T) {
+ ctx := context.Background()
+ h := newRollbackHarness(t)
+ rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) {
+ c.Holder.Admissions = append(c.Holder.Admissions, hostclaim.AdmissionRecord{Region: "uk", PredecessorRegion: "uk", Session: 3,
+ Box: "uk-box", Generation: "probe", Predecessor: "cur", Mode: "cold"})
+ })
+ f := h.claim(t).Holder.Stamp
+ rb := h.rollBack(t)
+ fences := h.fences
+ if h.route.HoldOp != p1ID || len(h.handed) != 0 {
+ t.Fatalf("the decision touched the row: %+v %v", h.route, h.handed)
+ }
+ h.rollbackPass(t)
+ rb = h.rb(t)
+ if rb.TargetPhase != provision.RbTargetReleased || rb.Dirty || rb.Evidence != targetStoppedKey() {
+ t.Fatalf("rollback: %+v", rb)
+ }
+ if h.route.Status != "releasing" || h.route.HoldOp != rb.ID || h.fences != fences+1 || h.tchecks != 1 {
+ t.Fatalf("row %+v fences %d checkpoints %d", h.route, h.fences, h.tchecks)
+ }
+ report, err := readStoppedReport(ctx, h.s, rb.Evidence)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := h.read(t, p1Host).Move
+ if report.Role != "target" || report.Box != "uk-box" || report.OpID != p1ID || report.FinalTXID != h.n ||
+ report.DBSHA256 != m.Landing.InstalledDBSHA256 || report.FilesPlain != m.Source.Files.Plain || report.TimersSHA256 != m.Source.TimersSHA256 {
+ t.Fatalf("report %+v", report)
+ }
+ c := h.claim(t)
+ if c.Op.Phase != hostclaim.PhaseReleased || c.Op.Exec != "" || c.Op.Journal[hostclaim.JournalEvidenceTarget] != rb.Evidence {
+ t.Fatalf("claim: %+v", c.Op)
+ }
+ var location, local int
+ for _, o := range c.Obligations {
+ switch {
+ case o.Kind == "park-replica" && o.Region == "uk" && o.Park.Location == cutRoot:
+ location++
+ if !o.Park.EvidenceAt.Equal(report.CheckedAt) || o.Park.RequestLimit != cutRequestLimit || o.Park.Incarnation != h.inc {
+ t.Fatalf("the location park's scope: %+v", o.Park)
+ }
+ case o.Kind == "park-local" && o.Box == "uk-box":
+ local++
+ default:
+ t.Fatalf("an obligation the release did not owe: %+v", o)
+ }
+ }
+ if location != 1 || local != 1 {
+ t.Fatalf("obligations: %+v", c.Obligations)
+ }
+ if !hostclaim.InventoryHasStamp(c, "uk-box", f) {
+ t.Fatal("the fixture's inventory does not list F")
+ }
+ for _, a := range c.Holder.Admissions {
+ if a.PredecessorRegion == "uk" && !a.Covered {
+ t.Fatalf("the target region's predecessor is not covered: %+v", a)
+ }
+ }
+ // Nothing goes to the lane, and the target waits for the source.
+ if calls := h.laneCalls(rb.ID); len(calls) != 0 {
+ t.Fatalf("a clean release wrote the lane: %+v", calls)
+ }
+ h.rollbackPass(t)
+ if got := h.rb(t); got.TargetPhase != provision.RbTargetReleased {
+ t.Fatalf("a clean release moved on before the source recovered: %+v", got)
+ }
+ if strings.Join(h.events, " ") != "rollback target-released" {
+ t.Fatalf("events %v", h.events)
+ }
+}
+
+// A landing rolled back before probing is clean when the seal covers the
+// source, whatever its digests: no writer ever ran on it. Nothing is
+// checkpointed.
+func TestReleaseBeforeProbingNeedsNoDigests(t *testing.T) {
+ h := &rollbackHarness{landingHarness: newLandingHarness(t)}
+ h.flip(t)
+ h.landAt = "after-build"
+ h.crashLand(t)
+ h.rollBack(t)
+ h.rollbackPass(t)
+ rb := h.rb(t)
+ if rb.TargetPhase != provision.RbTargetReleased || rb.Dirty || h.tchecks != 0 {
+ t.Fatalf("rollback %+v checkpoints %d", rb, h.tchecks)
+ }
+ if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandBuilding {
+ t.Fatalf("landing phase %s", m.LandingPhase)
+ }
+}
+
+// TestReleaseIsDirtyWithoutSrcCovered (Review Focus 1, witness wSealReturn):
+// the digests are equal but the staged seal does not cover the source, so
+// the source cannot trust its own copy: the release is dirty, and the
+// return lane gets the staged seal's exact versions, as given.
+func TestReleaseIsDirtyWithoutSrcCovered(t *testing.T) {
+ ctx := context.Background()
+ h := newRollbackHarness(t)
+ h.uncover(t)
+ staged := h.seal(t)
+ h.rollBack(t)
+ h.rollbackPass(t)
+ rb := h.rb(t)
+ if !rb.Dirty || rb.TargetPhase != provision.RbTargetReturned {
+ t.Fatalf("rollback %+v", rb)
+ }
+ c := h.claim(t)
+ seal, err := moves.DecodeSeal(c.Op.Journal[moves.JournalReturnSeal])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !seal.SrcCovered || !seal.FromDB || seal.N != staged.N || !seal.Bound().Equal(h.lease.until) {
+ t.Fatalf("return seal %+v (lease until %s)", seal, h.lease.until)
+ }
+ lane, from := moves.ReturnLane(p1Host, rb.ID), moves.StagingPrefix(p1Host, p1ID)
+ got := map[string]moves.SealedObject{}
+ for _, o := range append(append(append([]moves.SealedObject(nil), seal.Replica...), seal.Files...), seal.Timers...) {
+ got[o.Key] = o
+ }
+ for _, o := range append(append(append([]moves.SealedObject(nil), staged.Replica...), staged.Files...), staged.Timers...) {
+ key := lane + strings.TrimPrefix(o.Key, from)
+ r, ok := got[key]
+ if !ok {
+ t.Fatalf("the return seal does not name %s", key)
+ }
+ want, err := h.vs.GetVersion(ctx, "data-uk", o.Key, o.VersionID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, err := h.vs.GetVersion(ctx, "data-ie", key, r.VersionID)
+ if err != nil {
+ t.Fatalf("%s at %s: %v", key, r.VersionID, err)
+ }
+ if !bytes.Equal(body, want) || r.Size != o.Size {
+ t.Fatalf("%s is not the staged version of %s", key, o.Key)
+ }
+ delete(got, key)
+ }
+ if len(got) != 0 {
+ t.Fatalf("the return seal names more than the staged seal: %v", got)
+ }
+ // The baseline is the source's own, under the target's evidence.
+ base, err := moves.DecodeReturnBaseline(c.Op.Journal[moves.JournalReturnBaseline])
+ if err != nil {
+ t.Fatal(err)
+ }
+ src := h.read(t, p1Host).Move.Source
+ if base.FinalTXID != staged.N || *base.Files != *src.Files || base.TimersSHA256 != src.TimersSHA256 || base.Evidence != rb.Evidence {
+ t.Fatalf("baseline %+v, source %+v", base, src)
+ }
+ var mj moves.MoveJSON
+ if body := h.f.buckets["data-ie"][moves.MoveJSONKey(p1Host, rb.ID)].body; json.Unmarshal([]byte(body), &mj) != nil || mj.Incarnation != h.inc || mj.MoveID != rb.ID {
+ t.Fatalf("move.json %q", body)
+ }
+ // Every lane object went through the guard, under the source's lease.
+ if calls := h.laneCalls(rb.ID); len(calls) != 1+len(staged.Replica)+len(staged.Files)+len(staged.Timers) {
+ t.Fatalf("lane calls %+v", calls)
+ }
+ if h.lease.acquired == 0 || h.lease.acquired != h.lease.released {
+ t.Fatalf("the staging lease: acquired %d released %d", h.lease.acquired, h.lease.released)
+ }
+ if strings.Join(h.events, " ") != "rollback target-released target-returned" {
+ t.Fatalf("events %v", h.events)
+ }
+}
+
+// decodeLTXDatabase writes the pages of an LTX snapshot into a database file.
+func decodeLTXDatabase(t *testing.T, body []byte, path string) ltx.Header {
+ t.Helper()
+ dec := ltx.NewDecoder(bytes.NewReader(body))
+ if err := dec.DecodeHeader(); err != nil {
+ t.Fatal(err)
+ }
+ hdr := dec.Header()
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ page := make([]byte, hdr.PageSize)
+ for {
+ var ph ltx.PageHeader
+ if err := dec.DecodePage(&ph, page); errors.Is(err, io.EOF) {
+ break
+ } else if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.WriteAt(page, int64(ph.Pgno-1)*int64(hdr.PageSize)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := dec.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.Truncate(int64(hdr.Commit) * int64(hdr.PageSize)); err != nil {
+ t.Fatal(err)
+ }
+ return hdr
+}
+
+// TestDirtyReturnSnapshotsTheStoppedDatabase (witness wDirtyReturn): the
+// target served and wrote, so its state is not the staged seal's. The lane
+// carries one snapshot [1, N'] of the stopped database, its files archive
+// and its timers; the snapshot verifies and restores the target's rows, and
+// the return seal names exactly it.
+func TestDirtyReturnSnapshotsTheStoppedDatabase(t *testing.T) {
+ ctx := context.Background()
+ h := newRollbackHarness(t)
+ dbPath := provision.DefaultDBPath(h.tdir, p1Host)
+ d, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 3; i++ {
+ if _, err := d.Exec(`INSERT INTO t (body) VALUES ('written on the target')`); err != nil {
+ t.Fatal(err)
+ }
+ }
+ d.Close()
+ if err := os.WriteFile(filepath.Join(h.tdir, p1Host, "new.txt"), []byte("uploaded on the target"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ h.tpos = h.n + 3
+ h.rollBack(t)
+ h.rollbackPass(t)
+ rb := h.rb(t)
+ if !rb.Dirty || rb.TargetPhase != provision.RbTargetReturned {
+ t.Fatalf("rollback %+v", rb)
+ }
+ c := h.claim(t)
+ seal, err := moves.DecodeSeal(c.Op.Journal[moves.JournalReturnSeal])
+ if err != nil {
+ t.Fatal(err)
+ }
+ lane := moves.ReturnLane(p1Host, rb.ID)
+ want := moves.SnapshotKey(lane+"replica/", h.tpos)
+ if seal.N != h.tpos || len(seal.Replica) != 1 || seal.Replica[0].Key != want || !seal.ReachesN() || !seal.FromDB || !seal.SrcCovered {
+ t.Fatalf("return seal %+v, want the snapshot %s", seal, want)
+ }
+ body, err := h.vs.GetVersion(ctx, "data-ie", want, seal.Replica[0].VersionID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ltx.NewDecoder(bytes.NewReader(body)).Verify(); err != nil {
+ t.Fatalf("the snapshot does not verify: %v", err)
+ }
+ restored := filepath.Join(t.TempDir(), "restored.db")
+ hdr := decodeLTXDatabase(t, body, restored)
+ if hdr.MinTXID != 1 || uint64(hdr.MaxTXID) != h.tpos {
+ t.Fatalf("snapshot header %+v", hdr)
+ }
+ if got := countRowsForTest(t, restored); got != h.rows+3 {
+ t.Fatalf("the snapshot holds %d rows, the target %d", got, h.rows+3)
+ }
+ // The files archive is the target's, bound to the move id (the
+ // installer checks it), and the baseline is the target's state.
+ base, err := moves.DecodeReturnBaseline(c.Op.Journal[moves.JournalReturnBaseline])
+ if err != nil {
+ t.Fatal(err)
+ }
+ plain, _, _, err := archive.PlainDigest(filepath.Join(h.tdir, p1Host), filepath.Join(h.tdir, p1Host+".env"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ dbSHA, err := fileSHA256(dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if base.FinalTXID != h.tpos || base.DBSHA256 != dbSHA || base.Files.Plain != plain || base.Files.Sealed == "" || base.Evidence != rb.Evidence {
+ t.Fatalf("baseline %+v", base)
+ }
+ if !sealNamesArchive(seal, base.Files.Sealed) {
+ t.Fatalf("the seal's files are not the baseline's archive: %+v", seal.Files)
+ }
+ for _, o := range seal.Files {
+ if !strings.HasSuffix(o.Key, ".json") {
+ continue
+ }
+ raw, err := h.vs.GetVersion(ctx, "data-ie", o.Key, o.VersionID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var am archive.Manifest
+ if err := json.Unmarshal(raw, &am); err != nil || am.Op != p1ID || am.Incarnation != h.inc || am.Plain != plain {
+ t.Fatalf("archive manifest %+v %v", am, err)
+ }
+ }
+ if len(seal.Timers) != 1 || !strings.HasPrefix(seal.Timers[0].Key, lane+"timers/") {
+ t.Fatalf("timers %+v", seal.Timers)
+ }
+}
+
+// TestReturnLaneWriterIsGuarded: the return lane is a staging lane. A writer
+// whose lease is no longer safe for a signature's life sends nothing more; a
+// writer that finishes records its lease's bound as the seal's, after every
+// request it signed can still land, and the lane's park acknowledges at
+// LeaseUntil + H, not before.
+func TestReturnLaneWriterIsGuarded(t *testing.T) {
+ ctx := context.Background()
+ h := newRollbackHarness(t)
+ h.uncover(t)
+ h.rollBack(t)
+ // The lease goes unsafe after the first request is signed (a writer
+ // paused past its bound): nothing after it is sent, and no seal.
+ h.lease.mu.Lock()
+ h.lease.reads, h.lease.unsafeAfter = 0, 1
+ h.lease.mu.Unlock()
+ h.rollbackPass(t)
+ rb := h.rb(t)
+ if rb.TargetPhase != provision.RbTargetReturning {
+ t.Fatalf("rollback %+v", rb)
+ }
+ if calls := h.laneCalls(rb.ID); len(calls) != 1 {
+ t.Fatalf("an unsafe writer sent %d requests: %+v", len(calls), calls)
+ }
+ if _, ok := h.claim(t).Op.Journal[moves.JournalReturnSeal]; ok {
+ t.Fatal("a return seal without the lane")
+ }
+ if h.lease.acquired != h.lease.released {
+ t.Fatalf("the lease is still held: %d %d", h.lease.acquired, h.lease.released)
+ }
+ // A safe lease: the lane is written and sealed with the lease's bound.
+ h.lease.mu.Lock()
+ h.lease.unsafeAfter = 0
+ h.lease.mu.Unlock()
+ h.rollbackPass(t)
+ if rb = h.rb(t); rb.TargetPhase != provision.RbTargetReturned {
+ t.Fatalf("rollback %+v", rb)
+ }
+ seal, err := moves.DecodeSeal(h.claim(t).Op.Journal[moves.JournalReturnSeal])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !seal.Bound().Equal(h.lease.until) {
+ t.Fatalf("bound %s, lease until %s", seal.Bound(), h.lease.until)
+ }
+ for _, c := range h.laneCalls(rb.ID) {
+ if c.SignAt.Add(moves.SignatureLife + moves.DefaultSkew).After(seal.Bound()) {
+ t.Fatalf("%s signed at %s can land after the bound %s", c.Key, c.SignAt, seal.Bound())
+ }
+ }
+ // The lane's park (Plan 5): queued at the source's probed with the
+ // seal's bound as its horizon, acknowledged only after it.
+ for _, step := range []func(*hostclaim.Claim) error{
+ hostclaim.ReturnToSource(p1ID, "ie-box", false, hostclaim.ReturnFence{}),
+ hostclaim.SetRestoringPhase(p1ID, hostclaim.PhaseClaimed, hostclaim.PhaseProbing),
+ hostclaim.SetRestoringPhase(p1ID, hostclaim.PhaseProbing, hostclaim.PhaseProbed),
+ hostclaim.QueueReturnLanePark(p1ID, "ie", hostclaim.ParkScope{Location: moves.ScopeLocation(p1Host, rb.ID), Incarnation: h.inc,
+ EvidenceAt: seal.Bound(), RequestLimit: cutRequestLimit}, obligationID),
+ } {
+ obs, err := hostclaim.Read(ctx, h.s, p1Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostclaim.Update(ctx, h.s, obs, step); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var chore hostclaim.Obligation
+ for _, o := range h.claim(t).Obligations {
+ if o.Kind == "park-replica" && o.Region == "ie" {
+ chore = o
+ }
+ }
+ e := park.Engine{Store: h.vs, Claims: h.s, Bucket: "data-ie", Region: "ie", Leases: h.leases, Now: h.clock.Now, NewID: obligationID,
+ // Plan 5's view stands in here; a lane is never the holder's
+ // location, so nothing in it is protected either way.
+ View: func(_ context.Context, obs hostclaim.Observed) (park.View, error) {
+ return park.View{ClaimETag: obs.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true,
+ GenerationOf: func(park.Ref) (string, bool) { return "", true }}, nil
+ },
+ Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }}
+ work := park.Work{Host: p1Host, ID: chore.ID, Op: p1ID}
+ horizon := seal.Bound().Add(park.SignatureAge + cutRequestLimit)
+ h.clock.mu.Lock()
+ h.clock.now = horizon.Add(-time.Second)
+ h.clock.mu.Unlock()
+ if err := e.Run(ctx, work); !errors.Is(err, park.ErrPending) {
+ t.Fatalf("before LeaseUntil + H: %v", err)
+ }
+ if parked, err := e.Parked(ctx, work); err != nil || !parked {
+ t.Fatalf("the lane's archive: %v %v", parked, err)
+ }
+ h.clock.Advance(2 * time.Second)
+ if err := e.Run(ctx, work); err != nil {
+ t.Fatalf("after LeaseUntil + H: %v", err)
+ }
+ for _, o := range h.claim(t).Obligations {
+ if o.ID == chore.ID {
+ t.Fatalf("the lane's park was not acknowledged: %+v", o)
+ }
+ }
+}
+
+// TestTargetCleaningWaitsForRecoveredAt (Review Focus 4): the target cleans
+// only once the source has recovered; until then it keeps everything it has,
+// at released (clean) or returned (dirty).
+func TestTargetCleaningWaitsForRecoveredAt(t *testing.T) {
+ for _, dirty := range []bool{false, true} {
+ t.Run("dirty "+strconv.FormatBool(dirty), func(t *testing.T) {
+ h := newRollbackHarness(t)
+ if dirty {
+ h.uncover(t)
+ }
+ h.rollBack(t)
+ want := provision.RbTargetReleased
+ if dirty {
+ want = provision.RbTargetReturned
+ }
+ for i := 0; i < 3; i++ {
+ h.rollbackPass(t)
+ if rb := h.rb(t); rb.TargetPhase != want || rb.TargetParkedAt != nil {
+ t.Fatalf("pass %d: %+v", i, rb)
+ }
+ }
+ if h.rowGone || len(h.deleted) != 0 {
+ t.Fatal("the row went before the source recovered")
+ }
+ if _, err := os.Stat(provision.DefaultDBPath(h.tdir, p1Host)); err != nil {
+ t.Fatalf("the database went before the source recovered: %v", err)
+ }
+ h.recovered(t)
+ h.rollbackPass(t)
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetCleaned || rb.TargetParkedAt == nil {
+ t.Fatalf("after the recovery: %+v", rb)
+ }
+ })
+ }
+}
+
+// TestTargetCleaningLeavesNoLiveCopy (INV-10): after cleaned nothing of the
+// host is live on the target, in its bucket under instances/<host>/ or
+// moves/<host>/<mv>/, or on its disk; everything is archived under
+// parked/<inc>/<host>/<retirement>/ (and .parked/ locally), the row and its
+// timers are gone, and the park-local chore is complete.
+func TestTargetCleaningLeavesNoLiveCopy(t *testing.T) {
+ h := newRollbackHarness(t)
+ db := provision.DefaultDBPath(h.tdir, p1Host)
+ // A WAL the inventory does not list: the moves-owned rename parks it.
+ if err := os.WriteFile(db+"-wal", []byte("a wal"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ h.rollBack(t)
+ h.rollbackPass(t)
+ rb := h.rb(t)
+ h.recovered(t)
+ h.rollbackPass(t)
+ if rb = h.rb(t); rb.TargetPhase != provision.RbTargetCleaned || rb.TargetParkedAt == nil {
+ t.Fatalf("rollback %+v", rb)
+ }
+ var live, parked []string
+ for k := range h.f.buckets["data-uk"] {
+ switch {
+ case strings.HasPrefix(k, cutRoot+"/") && !strings.HasSuffix(k, "/lock.json"),
+ strings.HasPrefix(k, moves.StagingPrefix(p1Host, p1ID)):
+ live = append(live, k)
+ case strings.HasPrefix(k, "parked/"+h.inc+"/"+p1Host+"/"):
+ parked = append(parked, k)
+ }
+ }
+ if len(live) != 0 || len(parked) == 0 {
+ t.Fatalf("live %v parked %d", live, len(parked))
+ }
+ for _, p := range sourceArtifactPaths(p1Host) {
+ if _, err := os.Lstat(filepath.Join(h.tdir, p)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("%s is still live: %v", p, err)
+ }
+ }
+ if _, err := os.Lstat(moves.JournalDir(h.tdir, p1Host, p1ID)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the op journal is still live: %v", err)
+ }
+ var wal, dbs int
+ filepath.WalkDir(filepath.Join(h.tdir, ".parked"), func(p string, d os.DirEntry, err error) error {
+ if err == nil && !d.IsDir() {
+ switch filepath.Base(p) {
+ case p1Host + ".db-wal":
+ wal++
+ case p1Host + ".db":
+ dbs++
+ }
+ }
+ return nil
+ })
+ if wal != 1 || dbs != 1 {
+ t.Fatalf("parked locally: wal %d db %d", wal, dbs)
+ }
+ if !h.rowGone || len(h.deleted) != 1 || h.deleted[0] != p1Host+"="+rb.ID {
+ t.Fatalf("the row: %v", h.deleted)
+ }
+ c := h.claim(t)
+ for _, o := range c.Obligations {
+ if o.Kind == "park-local" {
+ t.Fatalf("the park-local is still owed: %+v", o)
+ }
+ }
+ if len(c.Inventory["uk-box"].Paths) != 0 {
+ t.Fatalf("inventory %+v", c.Inventory["uk-box"])
+ }
+ if captured, err := hostguard.Capture(h.tdir, p1Host); err != nil || captured.Stamp != 0 {
+ t.Fatalf("the installation record: %+v %v", captured, err)
+ }
+ // A later pass has nothing to route to.
+ if role := movesParticipantRecord(h.rollbackCfg(t), h.read(t, p1Host)); role != roleNone {
+ t.Fatalf("a cleaned target still routes as %s", role)
+ }
+ if !strings.HasSuffix(strings.Join(h.events, " "), "target-cleaned") {
+ t.Fatalf("events %v", h.events)
+ }
+}
+
+// Every release and return step is replay-safe: a crash after the record's
+// released leaves the claim at releasing, and the next pass writes it; a
+// crash after the return seal's claim write leaves the record at returning,
+// and the next pass finishes without writing the lane again.
+func TestTargetRollbackReplays(t *testing.T) {
+ t.Run("after-record-released", func(t *testing.T) {
+ h := newRollbackHarness(t)
+ h.rollBack(t)
+ h.rbAt = "after-record-released"
+ h.rollbackPass(t)
+ if !h.rbDone || h.claim(t).Op.Phase != hostclaim.PhaseReleasing {
+ t.Fatalf("the crash: %v %+v", h.rbHits, h.claim(t).Op)
+ }
+ h.rollbackPass(t)
+ if c := h.claim(t); c.Op.Phase != hostclaim.PhaseReleased || c.Op.Journal[hostclaim.JournalEvidenceTarget] != targetStoppedKey() {
+ t.Fatalf("claim %+v", c.Op)
+ }
+ })
+ t.Run("after-return-seal", func(t *testing.T) {
+ h := newRollbackHarness(t)
+ h.uncover(t)
+ h.rollBack(t)
+ h.rbAt = "after-return-seal"
+ h.rollbackPass(t)
+ rb := h.rb(t)
+ if !h.rbDone || rb.TargetPhase != provision.RbTargetReturning {
+ t.Fatalf("the crash: %v %+v", h.rbHits, rb)
+ }
+ calls := len(h.laneCalls(rb.ID))
+ h.rollbackPass(t)
+ if rb = h.rb(t); rb.TargetPhase != provision.RbTargetReturned || len(h.laneCalls(rb.ID)) != calls {
+ t.Fatalf("the replay: %+v, %d lane calls after %d", rb, len(h.laneCalls(rb.ID)), calls)
+ }
+ })
+}
+
+// The release waits while the target cannot prove its instance stopped,
+// and touches nothing the evidence would vouch for.
+func TestTargetReleaseWaitsForStopEvidence(t *testing.T) {
+ h := newRollbackHarness(t)
+ h.rollBack(t)
+ h.ev.stopped = false
+ h.rollbackPass(t)
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetReleasing || h.tchecks != 0 {
+ t.Fatalf("rollback %+v checkpoints %d", rb, h.tchecks)
+ }
+ if c := h.claim(t); c.Op.Exec != "uk-box" || c.Op.Phase != hostclaim.PhaseReleasing {
+ t.Fatalf("claim %+v", c.Op)
+ }
+ if _, _, err := h.s.Get(context.Background(), targetStoppedKey()); err == nil {
+ t.Fatal("a stopped report without stop evidence")
+ }
+ h.ev.stopped = true
+ h.rollbackPass(t)
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetReleased {
+ t.Fatalf("rollback %+v", rb)
+ }
+}
+
+// Production wires the hold hand-over and leaves the rollback unarmed with
+// the park engine's hooks (ruling J18), as the landing is.
+func TestTargetRollbackUnarmedInProduction(t *testing.T) {
+ h := newRollbackHarness(t)
+ c := h.rollbackCfg(t)
+ if !rollbackTargetArmed(c) {
+ t.Fatal("the test configuration is not armed")
+ }
+ c.ParkLeases = nil
+ if rollbackTargetArmed(c) {
+ t.Fatal("armed without park leases")
+ }
+ h.rollBack(t)
+ h.rw.cfg = c
+ if err := movesPass(context.Background(), h.s, h.rw); err != nil {
+ t.Fatal(err)
+ }
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetReleasing || len(h.handed) != 0 {
+ t.Fatalf("an unarmed rollback moved: %+v %v", rb, h.handed)
+ }
+}