carlos / platform Public

host: the hand-over's record check, a stuck dirty return, the target journal sweep (Task 4 fix round 1)

handHold takes an optional validation that runs under the hold lock
after the row is re-read; the release re-reads its record there (J3).
A dirty return that cannot be built from what the target holds (data
gone, state changed, files not archivable) marks the record stuck and
keeps everything; storage errors still retry. The local journal sweep
parks a target's rollback journal once the journal says cleaned, the
record shows target_parked_at, or no record carries the op
(MoveRolledBackClose requires target_parked_at); cleaning parks it in
the same call. Fault points around the local retirement and the row
delete, with fresh-worker replays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b9bca4ac9277df2d57877dffe7a38b31dcd9106a parent 3baa8e7
6 files changed, +557 −27
  • internal/host/movesfinal.go +5 −2
  • internal/host/moveshold.go +13 −2
  • internal/host/moveshold_test.go +56 −4
  • internal/host/movespass.go +1 −1
  • internal/host/movesrollback.go +178 −17
  • internal/host/movesrollback_test.go +304 −1
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go
index 99c979804..10706c861 100644
--- a/internal/host/movesfinal.go
+++ b/internal/host/movesfinal.go
@@ -630,7 +630,8 @@ func parkFinalJournal(dataDir, host, op string, j *moves.Journal, mark func(step
}
// sweepFinalJournals is the local sweep of this box's op journals (fix round
-// 1, ruling 1), independent of any active move: a crash after the stamp and
+// 1, ruling 1; a rollback target's journals too, sweepRollbackJournal),
+// independent of any active move: a crash after the stamp and
// before the journal's park, then the close, leaves a journal no record
// routes back here (movesParticipantRecord answers nothing for a record with
// no move). Each journal under <DataDir>/.moves/<host>/<op>/ that has reached
@@ -696,7 +697,9 @@ func (w *movesWorker) sweepFinalJournal(dataDir, host, op string, recs []provisi
}
stamp, reached := j.Steps[finalStepParked]
if !reached {
- return nil
+ // Not a finalization's: a rollback's target journal, perhaps
+ // (movesrollback.go, Task 4 fix round 1).
+ return w.sweepRollbackJournal(dataDir, host, op, j, recs)
}
if stamp != "done" {
if len(recs) == 0 {
diff --git a/internal/host/moveshold.go b/internal/host/moveshold.go
index 3e791cf20..3b9680953 100644
--- a/internal/host/moveshold.go
+++ b/internal/host/moveshold.go
@@ -292,11 +292,17 @@ func holdStageRequester(dd string) func(host string) {
// nothing. Every caller hands a hold over through this wrapper, never through
// registry.HandHold directly, which cannot reach the marker.
//
+// validate, when not nil, is the caller's own fresh check (Task 4 fix round
+// 1, ruling 1; J3's record check): it runs under the hold lock, after the
+// row is re-read and before anything is decided, and an error from it moves
+// nothing and is returned. It runs inside WithHostHoldLock, so it must not
+// take that lock again; a provision record read is what it is for.
+//
// true means the row is held status under toOp: the hand-over landed, or a
// replay found it already done. false with no error means nothing matched:
// the row is held by some other op, unheld, or absent.
-func handHold(reg *registry.Registry, written func(host string)) func(host, status, toOp, fromOp string) (bool, error) {
- return func(host, status, toOp, fromOp string) (bool, error) {
+func handHold(reg *registry.Registry, written func(host string)) func(host, status, toOp, fromOp string, validate func() error) (bool, error) {
+ return func(host, status, toOp, fromOp string, validate func() error) (bool, error) {
var handed bool
var err error
provision.WithHostHoldLock(host, func() {
@@ -304,6 +310,11 @@ func handHold(reg *registry.Registry, written func(host string)) func(host, stat
if rt, err = reg.Get(host); err != nil || rt == nil {
return
}
+ if validate != nil {
+ if err = validate(); err != nil {
+ return
+ }
+ }
if rt.HoldOp == toOp && rt.Status == status && toOp != "" {
handed = true
return
diff --git a/internal/host/moveshold_test.go b/internal/host/moveshold_test.go
index 3ac4b21eb..0a1aaa213 100644
--- a/internal/host/moveshold_test.go
+++ b/internal/host/moveshold_test.go
@@ -6,6 +6,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -342,20 +343,20 @@ func TestHandHoldTouchesTheStageMarkerOnlyWhenItLands(t *testing.T) {
// A refusal: the row is held by p1ID, not p1ID2. Nothing moves, no marker.
refused := t.TempDir()
- if ok, err := handHold(reg, holdStageRequester(refused))(p1Host, "releasing", rb, p1ID2); err != nil || ok || marker(refused) {
+ if ok, err := handHold(reg, holdStageRequester(refused))(p1Host, "releasing", rb, p1ID2, nil); err != nil || ok || marker(refused) {
t.Fatalf("refused hand-over: %v %v, marker %v", ok, err, marker(refused))
}
if rt, _ := reg.Get(p1Host); rt.Status != "landing" || rt.HoldOp != p1ID {
t.Fatalf("row changed by a refusal: %+v", rt)
}
// An absent row: no marker.
- if ok, err := handHold(reg, holdStageRequester(refused))("absent.example.com", "releasing", rb, p1ID); err != nil || ok || marker(refused) {
+ if ok, err := handHold(reg, holdStageRequester(refused))("absent.example.com", "releasing", rb, p1ID, nil); err != nil || ok || marker(refused) {
t.Fatalf("absent row: %v %v, marker %v", ok, err, marker(refused))
}
// The hand-over: the row is the rollback's, and the marker is written.
landed := t.TempDir()
- if ok, err := handHold(reg, holdStageRequester(landed))(p1Host, "releasing", rb, p1ID); err != nil || !ok || !marker(landed) {
+ if ok, err := handHold(reg, holdStageRequester(landed))(p1Host, "releasing", rb, p1ID, nil); err != nil || !ok || !marker(landed) {
t.Fatalf("hand-over: %v %v, marker %v", ok, err, marker(landed))
}
if rt, _ := reg.Get(p1Host); rt.Status != "releasing" || rt.HoldOp != rb {
@@ -364,7 +365,58 @@ func TestHandHoldTouchesTheStageMarkerOnlyWhenItLands(t *testing.T) {
// A replay finds the row already the rollback's: done, nothing written.
var touched []string
- if ok, err := handHold(reg, func(h string) { touched = append(touched, h) })(p1Host, "releasing", rb, p1ID); err != nil || !ok || len(touched) != 0 {
+ if ok, err := handHold(reg, func(h string) { touched = append(touched, h) })(p1Host, "releasing", rb, p1ID, nil); err != nil || !ok || len(touched) != 0 {
t.Fatalf("replay: %v %v, marker touched %d times", ok, err, len(touched))
}
}
+
+// Task 4 fix round 1, ruling 1: the hand-over's caller validates its own
+// record under the hold lock, after the row is re-read and before the hold
+// moves. A validation that refuses moves nothing and writes no marker; one
+// that passes is asked once, and the hold moves. The lock is the wrapper's
+// alone: the callback runs inside it and must not take it again.
+func TestHandHoldValidatesUnderTheLock(t *testing.T) {
+ const rb = "rb_abcdefghijklmnopqrstuvwxyz"
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ if err := reg.Upsert(registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "ready", Provisioned: true}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetHold(p1Host, "landing", p1ID); err != nil {
+ t.Fatal(err)
+ }
+ changed := errors.New("the record is no longer this rollback's release")
+ var seen []string
+ refused := t.TempDir()
+ ok, err := handHold(reg, holdStageRequester(refused))(p1Host, "releasing", rb, p1ID, func() error {
+ rt, _ := reg.Get(p1Host)
+ seen = append(seen, rt.Status+"/"+rt.HoldOp)
+ return changed
+ })
+ if !errors.Is(err, changed) || ok {
+ t.Fatalf("a refused validation: %v %v", ok, err)
+ }
+ if _, err := os.Stat(filepath.Join(refused, stageRequestMarker)); err == nil {
+ t.Fatal("a refused validation wrote the marker")
+ }
+ if rt, _ := reg.Get(p1Host); rt.Status != "landing" || rt.HoldOp != p1ID {
+ t.Fatalf("the hold moved under a refused validation: %+v", rt)
+ }
+ landed := t.TempDir()
+ if ok, err := handHold(reg, holdStageRequester(landed))(p1Host, "releasing", rb, p1ID, func() error {
+ rt, _ := reg.Get(p1Host)
+ seen = append(seen, rt.Status+"/"+rt.HoldOp)
+ return nil
+ }); err != nil || !ok {
+ t.Fatalf("a passing validation: %v %v", ok, err)
+ }
+ if rt, _ := reg.Get(p1Host); rt.Status != "releasing" || rt.HoldOp != rb {
+ t.Fatalf("after the hand-over: %+v", rt)
+ }
+ if strings.Join(seen, " ") != "landing/"+p1ID+" landing/"+p1ID {
+ t.Fatalf("validations saw %v, want the row before the hand-over, once each", seen)
+ }
+}
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 30b8b5c2e..c7888a25b 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -571,7 +571,7 @@ type moveCfg struct {
// 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)
+ HandHold func(host, status, toOp, fromOp string, validate func() error) (bool, error)
LogEvent func(ctx context.Context, rec provision.Record, event string)
rbFault func(point string) error
}
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go
index a374a4b2f..d2c1e447f 100644
--- a/internal/host/movesrollback.go
+++ b/internal/host/movesrollback.go
@@ -106,6 +106,7 @@ const (
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
+ rbStepCleaned = "rb-target-cleaned" // "done": the record says cleaned, only the journal's park is left
)
// stoppedTargetRole is the target's StoppedReport role.
@@ -357,6 +358,23 @@ func (r *rollbackRun) stillReleasing(ctx context.Context) error {
return nil
}
+// stillReleasingRecord is the hand-over's record check (J3, fix round 1,
+// ruling 1), run by handHold under the host's hold lock: the record, read
+// fresh, must still be this move and incarnation, rolling back under this
+// rollback, with the target releasing. It reads the record only: the lock is
+// the wrapper's, and nothing here takes it again.
+func (r *rollbackRun) stillReleasingRecord(ctx context.Context) error {
+ fresh, err := provision.GetRecord(ctx, r.s, r.rec.Account, r.rec.App, r.rec.Host)
+ if err != nil {
+ return fmt.Errorf("re-read the record: %w", err)
+ }
+ if m := fresh.Move; m == nil || m.ID != r.g.ID || m.Incarnation != r.g.Incarnation || m.State != provision.MoveRollingBack ||
+ m.Rollback == nil || m.Rollback.ID != r.rb || m.Rollback.TargetPhase != provision.RbTargetReleasing {
+ return fmt.Errorf("move %s: the record is no longer rollback %s releasing its target", r.g.ID, r.rb)
+ }
+ return nil
+}
+
// reachedProbing reports whether the landing reached probing: a writer may
// have run on this box.
func (r *rollbackRun) reachedProbing() bool {
@@ -400,7 +418,7 @@ func (r *rollbackRun) releasing(ctx context.Context) (bool, error) {
if err != nil {
return false, err
}
- handed, err := cfg.HandHold(host, "releasing", r.rb, id)
+ handed, err := cfg.HandHold(host, "releasing", r.rb, id, func() error { return r.stillReleasingRecord(ctx) })
if err != nil {
return false, fmt.Errorf("hand the row's hold to the rollback: %w", err)
}
@@ -630,6 +648,10 @@ func (r *rollbackRun) returning(ctx context.Context) (bool, error) {
} else {
lane, err = r.snapshotStopped(ctx, gm, report)
}
+ var refused *returnRefusal
+ if errors.As(err, &refused) {
+ return r.stuck(ctx, refused)
+ }
if err != nil {
return false, err
}
@@ -792,40 +814,106 @@ func downloadExact(ctx context.Context, vs lease.VersionedStore, bucket, key, ve
return nil
}
+// returnRefusal is a dirty return that cannot be built from what the target
+// holds (fix round 1, ruling 2): its database data is missing, it changed
+// after it stopped, or its files cannot be archived. No retry by itself can
+// help, so the record stops for an operator with reason. Storage, network
+// and local read errors are not refusals: they stay ordinary errors, and the
+// next pass retries.
+type returnRefusal struct {
+ reason string
+ err error
+}
+
+func (e *returnRefusal) Error() string { return e.err.Error() }
+func (e *returnRefusal) Unwrap() error { return e.err }
+
+func refuseReturn(reason string, err error) error { return &returnRefusal{reason: reason, err: err} }
+
+// databaseGone reports whether err says the database's data is not there to
+// read (absent, short, or not a database), as opposed to a read that failed:
+// an *os.PathError other than "does not exist" is the disk's, and transient.
+func databaseGone(err error) bool {
+ if errors.Is(err, os.ErrNotExist) {
+ return true
+ }
+ var pe *os.PathError
+ return !errors.As(err, &pe)
+}
+
+// stuck stops the record for an operator (MoveMarkStuck) at the target's
+// returning: the phase, the row and everything the target holds are kept,
+// and move --retry runs the return again.
+func (r *rollbackRun) stuck(ctx context.Context, refused *returnRefusal) (bool, error) {
+ host := r.rec.Host
+ log.Printf("moves: %s: the return of %s cannot be built (%v); it stops for an operator: %s", host, r.rb, refused.err, refused.reason)
+ if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveMarkStuck(r.g, refused.reason)); err != nil {
+ if errors.Is(err, provision.ErrMoveConflict) {
+ log.Printf("moves: %s: the stuck mark of %s refused (%v); the record moved on", host, r.rb, err)
+ return false, nil
+ }
+ return false, fmt.Errorf("mark the return stuck: %w", err)
+ }
+ r.w.logEvent(ctx, r.rec, "target-stuck")
+ nudgeSerial(ctx, r.s)
+ return true, 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.
+// id) and its timers. Each must still be what the stopped report recorded:
+// data that is gone (StuckReturnTargetDatabase), a state that differs from
+// the report (StuckReturnTargetChanged) and files that cannot be archived
+// (StuckReturnTargetFiles) are refusals; anything else is retried.
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)
+ err = fmt.Errorf("the target's stopped database: %w", err)
+ if errors.Is(err, os.ErrNotExist) {
+ err = refuseReturn(provision.StuckReturnTargetDatabase, err)
+ }
+ return returnedLane{}, err
}
if sha != report.DBSHA256 {
- return returnedLane{}, fmt.Errorf("move %s: the target's database changed since its release", id)
+ return returnedLane{}, refuseReturn(provision.StuckReturnTargetChanged, 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)
+ if n == 0 {
+ return returnedLane{}, refuseReturn(provision.StuckReturnTargetDatabase, fmt.Errorf("move %s: the stopped database has no position", id))
+ }
+ if n != report.FinalTXID {
+ return returnedLane{}, refuseReturn(provision.StuckReturnTargetChanged, 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 {
+ if databaseGone(err) {
+ err = refuseReturn(provision.StuckReturnTargetDatabase, err)
+ }
return returnedLane{}, err
}
f, err := os.Open(dbPath)
if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ err = refuseReturn(provision.StuckReturnTargetDatabase, err)
+ }
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)
+ err = fmt.Errorf("snapshot the stopped database: %w", err)
+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
+ // The header names pages the file does not hold.
+ err = refuseReturn(provision.StuckReturnTargetDatabase, err)
+ }
+ return returnedLane{}, err
}
var out returnedLane
key := moves.SnapshotKey(moves.ReplicaPrefix(host, r.rb), n)
@@ -842,11 +930,11 @@ func (r *rollbackRun) snapshotStopped(ctx context.Context, gm *guardedMirror, re
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)
+ return out, refuseReturn(provision.StuckReturnTargetFiles, 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)
+ return out, refuseReturn(provision.StuckReturnTargetChanged, 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 {
@@ -866,7 +954,7 @@ func (r *rollbackRun) snapshotStopped(ctx context.Context, gm *guardedMirror, re
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)
+ return out, refuseReturn(provision.StuckReturnTargetChanged, 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 {
@@ -959,10 +1047,20 @@ func (r *rollbackRun) cleaning(ctx context.Context) (bool, error) {
if err := r.deleteRow(ctx, j, mark); err != nil {
return false, err
}
- return r.phase(ctx, provision.RbTargetCleaning, provision.RbTargetCleaned, func(rb *provision.MoveRollback) {
+ ok, err := r.phase(ctx, provision.RbTargetCleaning, provision.RbTargetCleaned, func(rb *provision.MoveRollback) {
t := r.now().UTC()
rb.TargetParkedAt = &t
}, "target-cleaned")
+ if !ok || err != nil {
+ return ok, err
+ }
+ // The journal is parked in this same call: once target_parked_at is
+ // stamped the source may close the record before this worker reads it
+ // again, and then only the local sweep finds the journal.
+ if err := mark(rbStepCleaned, "done"); err != nil {
+ return true, err
+ }
+ return true, parkRollbackJournal(cfg.DataDir, host, id, j)
}
// remote parks the target region's location and the move's lane in it: the
@@ -1132,9 +1230,15 @@ func (r *rollbackRun) installation(ctx context.Context, ours bool) (string, erro
return r.authorizeChore(c, o)
}
}
+ if err := r.fault("before-rb-local-retire"); err != nil {
+ return "", err
+ }
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)
}
+ if err := r.fault("after-rb-local-retire"); err != nil {
+ return "", err
+ }
log.Printf("moves: %s: the target's registered installation (stamp %d) is retired", host, f)
return "retired", nil
case chore != nil:
@@ -1232,7 +1336,11 @@ func (r *rollbackRun) deleteRow(ctx context.Context, j *moves.Journal, mark func
return
}
if j.Steps[rbStepRowDeleted] != "pending" {
- if err = mark(rbStepRowDeleted, "pending"); err != nil {
+ j.Mark(rbStepRowDeleted, "pending")
+ if err = j.Write(cfg.DataDir); err != nil {
+ return
+ }
+ if err = r.fault("after-rb-row-pending"); err != nil {
return
}
}
@@ -1244,13 +1352,16 @@ func (r *rollbackRun) deleteRow(ctx context.Context, j *moves.Journal, mark func
if deleted {
log.Printf("moves: %s: the target's row and its timers are deleted", host)
}
+ if err = r.fault("after-rb-row-delete"); err != nil {
+ return
+ }
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.
+// cleaned parks the op's journal into the local retirement once the record
+// says cleaned, if a crash or a quick close left it.
func (r *rollbackRun) cleaned() error {
cfg := r.cfg()
host, id := r.rec.Host, r.g.ID
@@ -1261,17 +1372,67 @@ func (r *rollbackRun) cleaned() error {
if err != nil {
return err
}
+ if j.Steps[rbStepCleaned] != "done" {
+ j.Mark(rbStepCleaned, "done")
+ if err := j.Write(cfg.DataDir); err != nil {
+ return err
+ }
+ }
+ return parkRollbackJournal(cfg.DataDir, host, id, j)
+}
+
+// parkRollbackJournal parks the target's op journal (its steps, the landing's
+// build and extract scratch, and the built files archive of a dirty return)
+// into the retirement its own moves-owned rename recorded.
+func parkRollbackJournal(dataDir, host, op string, j *moves.Journal) error {
retirement := j.Steps[rbStepLocal]
if retirement == "" {
- return fmt.Errorf("move %s: the journal records no local retirement for the target's park", id)
+ return fmt.Errorf("move %s: the journal records no local retirement for the target's park", op)
}
plan, err := journalledLocalPlan(j, rbStepLocal)
if err != nil {
return err
}
- if err := parkOpJournal(cfg.DataDir, plan.Incarnation, host, id, retirement); err != nil {
+ if err := parkOpJournal(dataDir, plan.Incarnation, host, op, 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)
+ log.Printf("moves: %s: the target's half of the rollback of %s is complete", host, op)
return nil
}
+
+// sweepRollbackJournal is the local journal sweep's rollback half
+// (sweepFinalJournals, fix round 1, ruling 3): a target's journal whose
+// cleaning reached its moves-owned rename (rbStepLocal) is parked once its
+// completion is shown, independent of any record that still routes here:
+// - the journal records cleaned;
+// - a listed record carries the op and its rollback shows target_parked_at
+// (the cleaned CAS landed);
+// - no listed record carries the op: the only close of a rolling_back move,
+// provision.MoveRolledBackClose, refuses unless target_parked_at is
+// stamped, and a journal at rbStepLocal is past every declared-loss
+// window, so the cleaned CAS landed before the close.
+//
+// A listed record that carries the op without target_parked_at is the
+// cleaning's own pass, and a host the listing does not name is left. A journal
+// that never reached rbStepLocal is never touched.
+func (w *movesWorker) sweepRollbackJournal(dataDir, host, op string, j *moves.Journal, recs []provision.Record) error {
+ if _, ok := j.Steps[rbStepLocal]; !ok {
+ return nil
+ }
+ if j.Steps[rbStepCleaned] != "done" {
+ if len(recs) == 0 {
+ w.notice(host+"#rb-sweep-"+op, "moves: %s: the target's journal of %s reached its park, and no listed record names the host; it is left", host, op)
+ return nil
+ }
+ for _, rec := range recs {
+ if m := rec.Move; m != nil && m.ID == op && (m.Rollback == nil || m.Rollback.TargetParkedAt == nil) {
+ return nil
+ }
+ }
+ j.Mark(rbStepCleaned, "done")
+ if err := j.Write(dataDir); err != nil {
+ return err
+ }
+ }
+ return parkRollbackJournal(dataDir, host, op, j)
+}
diff --git a/internal/host/movesrollback_test.go b/internal/host/movesrollback_test.go
index 3eecb7359..022a5461f 100644
--- a/internal/host/movesrollback_test.go
+++ b/internal/host/movesrollback_test.go
@@ -45,6 +45,10 @@ type rollbackHarness struct {
rbAt string
rbDone bool
rbHits []string
+ // rbHook runs at every fault point, before the crash check; beforeHand
+ // runs inside the hand-over, before its validation.
+ rbHook func(point string)
+ beforeHand func()
}
func newRollbackHarness(t *testing.T) *rollbackHarness {
@@ -87,11 +91,21 @@ func (h *rollbackHarness) rollbackCfg(t *testing.T) moveCfg {
rt := h.route
return &rt, nil
}
- c.HandHold = func(host, status, to, from string) (bool, error) {
+ // The hand-over keeps handHold's contract: under the lock the row is
+ // read, then the caller's validation runs, then the hold moves.
+ c.HandHold = func(host, status, to, from string, validate func() error) (bool, error) {
h.handed = append(h.handed, status+":"+to+":"+from)
if h.rowGone {
return false, nil
}
+ if h.beforeHand != nil {
+ h.beforeHand()
+ }
+ if validate != nil {
+ if err := validate(); err != nil {
+ return false, err
+ }
+ }
if h.route.HoldOp == to && h.route.Status == status {
return true, nil
}
@@ -123,6 +137,9 @@ func (h *rollbackHarness) rollbackCfg(t *testing.T) moveCfg {
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 h.rbHook != nil {
+ h.rbHook(point)
+ }
if point == h.rbAt && !h.rbDone {
h.rbDone = true
return errCrash
@@ -935,3 +952,289 @@ func TestTargetRollbackUnarmedInProduction(t *testing.T) {
t.Fatalf("an unarmed rollback moved: %+v %v", rb, h.handed)
}
}
+
+// Fix round 1, ruling 1 (J3): the release re-reads its record under the hold
+// lock at the hand-over. A record that changed between the pass's
+// observation and the hand-over (here a declared loss took the target) keeps
+// the row held by the move, and nothing after the hand-over runs.
+func TestTargetReleaseRechecksTheRecordAtTheHandOver(t *testing.T) {
+ h := newRollbackHarness(t)
+ h.rollBack(t)
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ h.beforeHand = func() {
+ h.beforeHand = nil
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveRollbackTargetLost(g, "control/losses/x.json", h.clock.Now())); err != nil {
+ t.Fatal(err)
+ }
+ }
+ calls := h.ev.calls
+ h.rollbackPass(t)
+ if h.route.Status != "landing" || h.route.HoldOp != p1ID || len(h.handed) != 1 {
+ t.Fatalf("the hold moved under a changed record: %+v %v", h.route, h.handed)
+ }
+ if h.ev.calls != calls || h.tchecks != 0 {
+ t.Fatalf("the release went on: evidence %d checkpoints %d", h.ev.calls-calls, h.tchecks)
+ }
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetLost {
+ t.Fatalf("rollback %+v", rb)
+ }
+ if _, _, err := h.s.Get(context.Background(), targetStoppedKey()); err == nil {
+ t.Fatal("a stopped report after a refused hand-over")
+ }
+}
+
+// targetChanged makes the target's state differ from what it installed: rows
+// written by the app on the target, so the return snapshots it.
+func (h *rollbackHarness) targetChanged(t *testing.T) {
+ t.Helper()
+ d, err := sql.Open("sqlite", provision.DefaultDBPath(h.tdir, p1Host))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.Exec(`INSERT INTO t (body) VALUES ('written on the target')`); err != nil {
+ t.Fatal(err)
+ }
+ d.Close()
+ h.tpos = h.n + 1
+}
+
+// stuckAtReturning asserts the record stopped for an operator at the
+// target's returning with reason, the target keeping its phase, its row and
+// what it holds, and no return seal.
+func (h *rollbackHarness) stuckAtReturning(t *testing.T, reason string) {
+ t.Helper()
+ m := h.read(t, p1Host).Move
+ if m.State != provision.MoveStuck || m.StuckReason != reason || m.Rollback.TargetPhase != provision.RbTargetReturning ||
+ !m.Rollback.Dirty || m.Rollback.StuckAt != provision.RbSourceWaiting {
+ t.Fatalf("not stuck at returning for %q: %+v %+v", reason, m, m.Rollback)
+ }
+ if h.route.Status != "releasing" || h.route.HoldOp != m.Rollback.ID || h.rowGone {
+ t.Fatalf("the row: %+v gone %v", h.route, h.rowGone)
+ }
+ if _, err := os.Stat(filepath.Join(h.tdir, p1Host, "upload.txt")); err != nil {
+ t.Fatalf("the target's files: %v", err)
+ }
+ if _, ok := h.claim(t).Op.Journal[moves.JournalReturnSeal]; ok {
+ t.Fatal("a return seal on a stuck return")
+ }
+ if h.lease.acquired != h.lease.released {
+ t.Fatalf("the staging lease: %d %d", h.lease.acquired, h.lease.released)
+ }
+}
+
+func (h *rollbackHarness) retry(t *testing.T) {
+ t.Helper()
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveRetry(g)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Fix round 1, ruling 2: a dirty return that cannot be built from what the
+// target holds stops for an operator with a recorded reason, the target at
+// returning with everything kept; a retry runs the return again.
+func TestDirtyReturnGoesStuck(t *testing.T) {
+ t.Run("the database is missing", func(t *testing.T) {
+ h := newRollbackHarness(t)
+ db := provision.DefaultDBPath(h.tdir, p1Host)
+ if err := os.Rename(db, db+".aside"); err != nil {
+ t.Fatal(err)
+ }
+ h.rollBack(t)
+ h.rollbackPass(t)
+ h.stuckAtReturning(t, provision.StuckReturnTargetDatabase)
+ // A stuck record is not the target's to work on.
+ h.rollbackPass(t)
+ h.stuckAtReturning(t, provision.StuckReturnTargetDatabase)
+ // A retry runs the return again; still missing, it stops again.
+ before := len(h.events)
+ h.retry(t)
+ h.rollbackPass(t)
+ h.stuckAtReturning(t, provision.StuckReturnTargetDatabase)
+ if got := strings.Join(h.events[before:], " "); got != "target-stuck" {
+ t.Fatalf("the retry did not run the return: events %q", got)
+ }
+ })
+ t.Run("the files cannot be archived", func(t *testing.T) {
+ h := newRollbackHarness(t)
+ h.targetChanged(t)
+ link := filepath.Join(h.tdir, p1Host, "link")
+ h.rbHook = func(point string) {
+ if point == "after-record-returning" {
+ if err := os.Symlink("/etc/passwd", link); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ h.rollBack(t)
+ h.rollbackPass(t)
+ h.stuckAtReturning(t, provision.StuckReturnTargetFiles)
+ // The operator removes the link and retries: the return completes.
+ h.rbHook = nil
+ if err := os.Remove(link); err != nil {
+ t.Fatal(err)
+ }
+ h.retry(t)
+ h.rollbackPass(t)
+ if rb := h.rb(t); h.read(t, p1Host).Move.State != provision.MoveRollingBack || rb.TargetPhase != provision.RbTargetReturned {
+ t.Fatalf("after the retry: %+v", rb)
+ }
+ })
+ t.Run("a storage error is retried, not stuck", func(t *testing.T) {
+ h := newRollbackHarness(t)
+ h.targetChanged(t)
+ fail := true
+ h.f.failAt = func(op, key string) error {
+ if fail && strings.Contains(key, "/replica/") {
+ return errors.New("503 SlowDown")
+ }
+ return nil
+ }
+ h.rollBack(t)
+ h.rollbackPass(t)
+ if m := h.read(t, p1Host).Move; m.State != provision.MoveRollingBack || m.Rollback.TargetPhase != provision.RbTargetReturning {
+ t.Fatalf("a storage error: %+v %+v", m, m.Rollback)
+ }
+ fail = false
+ h.rollbackPass(t)
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetReturned {
+ t.Fatalf("after the storage recovered: %+v", rb)
+ }
+ })
+}
+
+// closeRolledBack is the source's close of the rollback, as Task 5 will
+// write it: opened, then MoveRolledBackClose.
+func (h *rollbackHarness) closeRolledBack(t *testing.T) {
+ t.Helper()
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ now := h.clock.Now()
+ for _, step := range []func(*provision.Record) error{
+ provision.MoveRollbackSourcePhase(g, provision.RbSourceRecovered, provision.RbSourceOpened, func(rb *provision.MoveRollback) { rb.OpenedAt = &now }),
+ provision.MoveRolledBackClose(g, now),
+ } {
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, step); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// Fix round 1, ruling 3: the source closes the record right after the
+// target's cleaned CAS, before the target's worker re-reads it, and the
+// worker restarts. No record routes the target back, so the local journal
+// sweep parks the op's journal (which holds the built files archive) on the
+// close's evidence.
+func TestTargetJournalSweptAfterTheClose(t *testing.T) {
+ h := newRollbackHarness(t)
+ h.targetChanged(t)
+ h.rollBack(t)
+ h.rollbackPass(t)
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetReturned {
+ t.Fatalf("rollback %+v", rb)
+ }
+ adir := archiveDir(h.tdir, p1Host, p1ID)
+ built, _ := filepath.Glob(filepath.Join(adir, "files-*"))
+ if len(built) == 0 {
+ t.Fatal("the dirty return built no archive in the journal")
+ }
+ h.recovered(t)
+ h.rbHook = func(point string) {
+ if point == "after-record-cleaned" {
+ h.closeRolledBack(t)
+ }
+ }
+ h.rbAt = "after-record-cleaned"
+ h.rollbackPass(t)
+ if !h.rbDone || h.read(t, p1Host).Move != nil {
+ t.Fatalf("the fixture: crashed %v, record %+v", h.rbDone, h.read(t, p1Host).Move)
+ }
+ jdir := moves.JournalDir(h.tdir, p1Host, p1ID)
+ if _, err := os.Stat(jdir); err != nil {
+ t.Fatalf("the fixture left no journal to sweep: %v", err)
+ }
+ // A restart: a fresh worker, and the next pass's sweep.
+ h.rw, h.rbHook = nil, nil
+ h.leases.expire()
+ h.rollbackPass(t)
+ if _, err := os.Lstat(jdir); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the op journal is still live: %v", err)
+ }
+ var parkedArchive int
+ filepath.WalkDir(filepath.Join(h.tdir, ".parked"), func(p string, d os.DirEntry, err error) error {
+ if err == nil && !d.IsDir() && strings.Contains(p, filepath.Join(".moves", p1Host, p1ID, "archive", "files-")) {
+ parkedArchive++
+ }
+ return nil
+ })
+ if parkedArchive == 0 {
+ t.Fatal("the built archive was not parked with the journal")
+ }
+}
+
+// The journal sweep leaves a rollback journal alone while its record still
+// carries the op without target_parked_at: the cleaning is the pass's.
+func TestJournalSweepWaitsForTheTargetPark(t *testing.T) {
+ h := newRollbackHarness(t)
+ h.rollBack(t)
+ h.rollbackPass(t)
+ h.recovered(t)
+ // Crash right after the moves-owned rename, before the row and the CAS.
+ h.rbAt = "after-" + rbStepLocal
+ h.rollbackPass(t)
+ if !h.rbDone {
+ t.Fatalf("never reached %s: %v", h.rbAt, h.rbHits)
+ }
+ w := &movesWorker{cfg: h.rollbackCfg(t), mirrors: map[string]lease.MirrorStore{}}
+ w.sweepFinalJournals([]provision.Record{h.read(t, p1Host)})
+ if _, err := os.Stat(moves.JournalDir(h.tdir, p1Host, p1ID)); err != nil {
+ t.Fatalf("the sweep parked a journal whose cleaning is under way: %v", err)
+ }
+}
+
+// Fix round 1, ruling 4: a crash at each durable step of the cleaning, then a
+// fresh worker, finishes it with nothing live left.
+func TestTargetCleaningReplays(t *testing.T) {
+ for _, point := range []string{
+ "before-rb-local-retire", "after-rb-local-retire", "after-" + rbStepInstallation,
+ "after-" + rbStepLocal + "-plan", "after-rename:" + p1Host + ".db-wal", "after-" + rbStepLocal,
+ "after-rb-row-pending", "after-rb-row-delete", "after-" + rbStepRowDeleted,
+ } {
+ t.Run(point, func(t *testing.T) {
+ h := newRollbackHarness(t)
+ if err := os.WriteFile(provision.DefaultDBPath(h.tdir, p1Host)+"-wal", []byte("a wal"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ h.rollBack(t)
+ h.rollbackPass(t)
+ h.recovered(t)
+ h.rbAt = point
+ h.rollbackPass(t)
+ if !h.rbDone {
+ t.Fatalf("never reached %s: %v", point, h.rbHits)
+ }
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetCleaning {
+ t.Fatalf("after the crash: %+v", rb)
+ }
+ h.rw = nil
+ h.leases.expire()
+ h.rollbackPass(t)
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetCleaned || rb.TargetParkedAt == nil {
+ t.Fatalf("after the replay: %+v", rb)
+ }
+ 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)
+ }
+ if !h.rowGone {
+ t.Fatal("the row is still there")
+ }
+ if c := h.claim(t); len(c.Inventory["uk-box"].Paths) != 0 {
+ t.Fatalf("inventory %+v", c.Inventory["uk-box"])
+ }
+ })
+ }
+}