Merge remote-tracking branch 'origin/main' into moves-phase1-4
# Conflicts: # approved_moves_copy_test.go
12 files changed,
+2597
−7
approved_moves_copy_test.go+4 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+3 −0docs/superpowers/specs/2026-10-02-move-any-app-design.md+9 −5internal/host/movesfinal.go+724 −0internal/host/movesfinal_test.go+998 −0internal/host/movespass.go+31 −0internal/host/movesphase1_harness_test.go+3 −1internal/host/movesretire.go+337 −0internal/moves/startcheck.go+162 −0internal/moves/startcheck_test.go+232 −0internal/registry/hold_test.go+64 −0internal/registry/registry.go+30 −0
CI passed — run details
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index d9d90a27c..d6ada9d46 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -70,7 +70,7 @@ var movesCopyIDs = []string{ |
| "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168", |
| "MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174", |
| "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181", |
| - "MV-N182", "MV-N183", |
| + "MV-N182", "MV-N183", "MV-N184", "MV-N185", "MV-N186", |
| "MV-N300", "MV-N301", "MV-N302", "MV-N303", "MV-N304", "MV-N305", "MV-N306", |
| } |
| @@ -571,6 +571,7 @@ var movesScanFiles = []string{ |
| "internal/host/movesfloor_other.go", |
| "internal/host/movesarchive.go", |
| "internal/host/movescut.go", |
| + "internal/host/movesfinal.go", |
| "internal/host/movesguard.go", |
| "internal/host/movesheldenv.go", |
| "internal/host/movesheldlock.go", |
| @@ -586,6 +587,7 @@ var movesScanFiles = []string{ |
| "internal/host/movespolicy.go", |
| "internal/host/movesprobe.go", |
| "internal/host/movesreport.go", |
| + "internal/host/movesretire.go", |
| "internal/host/movesstage.go", |
| "internal/host/movesstore.go", |
| "internal/host/refusalwatch.go", |
| @@ -628,6 +630,7 @@ var movesScanFiles = []string{ |
| "internal/moves/sealedclient.go", |
| "internal/moves/source.go", |
| "internal/moves/staging.go", |
| + "internal/moves/startcheck.go", |
| "internal/moves/switch.go", |
| "internal/moves/timers.go", |
| "internal/provision/provision.go", |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 15c91f98e..3147cb7f9 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -237,6 +237,9 @@ Columns: id, file, text, status. |
| |---|---|---|---| |
| | MV-N182 | internal/provision/provision.go | the probe ran out of attempts | pending | |
| | MV-N183 | internal/provision/provision.go | the commit came after the deadline | pending | |
| +| MV-N184 | internal/moves/startcheck.go | this box holds another generation of the instance | pending | |
| +| MV-N185 | internal/moves/startcheck.go | the instance's files belong to another generation | pending | |
| +| MV-N186 | internal/moves/startcheck.go | the instance's database belongs to another generation | pending | |
| ## Plan 4 |
diff --git a/docs/superpowers/specs/2026-10-02-move-any-app-design.md b/docs/superpowers/specs/2026-10-02-move-any-app-design.md| index 36c92b479..26471345f 100644 |
| --- a/docs/superpowers/specs/2026-10-02-move-any-app-design.md |
| +++ b/docs/superpowers/specs/2026-10-02-move-any-app-design.md |
| @@ -1838,7 +1838,7 @@ field. |
| | marker | where | written | content | |
| |---|---|---|---| |
| | `generation.json` | `<location>generation.json`, beside `lock.json` | **last**, after the install manifest is complete and verified (§7.6); **conditionally**: If-Match the marker it supersedes, or If-None-Match when there is none (model 29) | `{"op","incarnation","host","box","installed_at","manifest_sha256","snapshot_key","snapshot_version","snapshot_txid","snapshot_crc64"}`: the pinned root, the install's snapshot identity and the floor a successor snapshot must clear (R3-1, R4-1, R5-3) | |
| -| `<dbPath>.gen` | beside the local database | after a restore completes | `{"op","incarnation","location"}` | |
| +| `<dbPath>.gen` | beside the local database | after a restore completes | the generation's name as plain text, one line: the format main already writes (`hostbox.WriteGenerationMarker`). It carries no incarnation and no location; the claim's holder vouches for both (Paul's ruling, moves Phase 1 Plan 3) | |
| | `.files-gen` | inside the files directory | **last**, after files, `<host>.env`, timers and the carried registry columns are all durable (§7.6) | `{"op","incarnation"}` | |
| `generation.json` is the one reused key a generation writes: everything |
| @@ -1888,10 +1888,14 @@ is the region of the holder's current installation: |
| generation for every probe, R10-3); a lagging marker is read through |
| `holder.admissions` (§5.1 step 9); and its incarnation to be the |
| holder's; |
| -2. `.files-gen`, when the files directory exists, to name the same |
| - generation and incarnation; |
| -3. `<dbPath>.gen` to name the same generation and location. A warm |
| - database that does not is from an earlier stay on this box: the |
| +2. `.files-gen`, when the files directory exists, to name the holder's |
| + incarnation and either `holder.generation` or its cold predecessor |
| + (the newest cold admission into `holder.generation`, warm self-links |
| + skipped), which is the prepared generation a restarted probe was restored |
| + from; no older generation counts; |
| +3. `<dbPath>.gen` to name the same generation, exactly: the plain name |
| + carries no location, which the claim's `holder.location` vouches for. |
| + A warm database that does not is from an earlier stay on this box: the |
| activator parks it (§6.3) and restores cold. |
| Rev 5's check compared the three markers with each other only. Astra |
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go| new file mode 100644 |
| index 000000000..e02647890 |
| --- /dev/null |
| +++ b/internal/host/movesfinal.go |
| @@ -0,0 +1,724 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "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" |
| +) |
| + |
| +// The source's finalization after the commit (moves spec §7.9 step 3; Plan 3 |
| +// Task 8 with rulings H5, H7, H19 and H24), behind CARLOS_MOVES_PHASE1. Once |
| +// the record is committed the target's region is authoritative, and the |
| +// source box has four things left to do for the move, in this order: |
| +// |
| +// 1. the remote half: the source region's location park (the park-replica |
| +// the commit queued for instances/<host>, D6, H24) is run through the |
| +// park engine over this box's own bucket until its archive is complete |
| +// (park.Engine.Parked). Its acknowledgement after the horizon is the |
| +// engine's or the regional runner's (C4), never a reason to wait here. |
| +// The horizon starts at the flip (H7), which is after the source's last |
| +// write to its location: a park whose EvidenceAt is earlier is refused; |
| +// 2. the local half (ruling H5(b), Paul): a source whose installation is |
| +// registered (the commit queued its park-local, because the inventory |
| +// lists paths of the journalled source stamp S) retires it through |
| +// park.Local.RetireInstallation under the host guard, which completes the |
| +// chore itself (H19). Everything else the source kept at the live flat |
| +// paths, registered or not, is parked by a moves-owned journalled rename |
| +// into the engine's layout, .parked/<incarnation>/<host>/<retirement>/ |
| +// artifacts/ (parkLocalArtifacts): the retirement id and every artifact's |
| +// identity are journalled before the first rename, and nothing the plan |
| +// did not record is ever moved or removed; |
| +// 3. the row and its timers rows go in one registry transaction, only while |
| +// the move holds the row (registry.DeleteIfHoldWithTimers, H5(d)), under |
| +// provision.WithHostHoldLock with the record re-read there; the journal |
| +// says row-deleted "pending" before it and "done" after it, and a replay |
| +// that finds "pending" and no row of this op's treats the delete as done |
| +// (round-2 finding 12). The box's config ack is copied into the move's |
| +// report prefix as config-ack-source.json and deleted (§6.3); |
| +// 4. MoveSourceParked stamps source_parked_at, and the serial is nudged. |
| +// Last of all the op's own journal directory is renamed into the same |
| +// retirement, so a finished source keeps nothing under .moves/. |
| +// |
| +// Routing (ruling H5(a)): after the flip the record's region is the target's |
| +// and after step 3 this box has no row, so the region-and-row rule that |
| +// admits every other Phase 1 round would never bring the replay of a crash |
| +// between steps 3 and 4 back here. movesParticipantRecord is the rule for the |
| +// states after the flip: this box's region is the move's From (or To), and it |
| +// still has the row or the op's journal. Plan 4's rollback visits reuse it. |
| +// |
| +// Arming. Every hook that exists on main is wired (the guard, park.Local, the |
| +// registry's delete, the versioned store), but the park engine's leases and |
| +// Plan 5's view and authorizer are not (ruling G17, as for the landing), so |
| +// sourceFinalArmed answers false in production and the pass only logs. The |
| +// tests arm it with fakes and the stand-ins below. No production record |
| +// reaches committed before Task 7 and Group D anyway. |
| + |
| +// The finalization's op journal steps (moves.Journal, the source's own |
| +// journal of this op, which the cut began). |
| +const ( |
| + finalStepRemote = "source-park-remote" // the location park's archive is complete, or acknowledged |
| + finalStepInstallation = "source-park-installation" // retired, completed or unregistered |
| + finalStepLocal = "source-park-local" // parkLocalArtifacts' step: the retirement id |
| + finalStepRowDeleted = "row-deleted" // "pending" before the delete, "done" after it |
| + finalStepConfigAck = "config-ack-source" // the config ack is copied and deleted |
| + finalStepParked = "source-parked" // "pending" before the stamp's CAS, "done" after it |
| + finalStepJournal = "source-park-journal" // where the journal is going, written just before it goes |
| +) |
| + |
| +// finalConfigAckName is the config ack's copy under the move's report prefix. |
| +const finalConfigAckName = "config-ack-source.json" |
| + |
| +// movesParticipantStates are the record states after the flip (and a |
| +// revert) in which a participant may have work for a record whose region |
| +// no longer names it, or whose row it has already deleted. |
| +var movesParticipantStates = map[string]bool{ |
| + provision.MoveCommitted: true, |
| + provision.MoveRollingBack: true, |
| + provision.MoveReverting: true, |
| +} |
| + |
| +// movesParticipantRecord is the routing rule for the states after the flip |
| +// (ruling H5(a), shared with Plan 4's J10 and J13): it answers this box's role |
| +// in rec's move, roleSource when this box's region is the move's From, |
| +// roleTarget when it is the move's To, and roleNone otherwise. A role is |
| +// answered only while the box still has something of the move's: its row |
| +// (OwnsRoute) or the op's journal directory (moveJournalPresent). The caller |
| +// decides what a role does in a state; this only says the box is a |
| +// participant with work it can find. |
| +func movesParticipantRecord(cfg moveCfg, rec provision.Record) string { |
| + m := rec.Move |
| + if m == nil || m.Legacy() || !movesParticipantStates[m.State] || m.From == m.To { |
| + return roleNone |
| + } |
| + role := roleNone |
| + switch cfg.Region { |
| + case m.From: |
| + role = roleSource |
| + case m.To: |
| + role = roleTarget |
| + default: |
| + return roleNone |
| + } |
| + if cfg.OwnsRoute == nil || cfg.OwnsRoute(rec.Host) || moveJournalPresent(cfg.DataDir, rec.Host, m.ID) { |
| + return role |
| + } |
| + return roleNone |
| +} |
| + |
| +// moveJournalPresent reports whether this box holds the op's journal |
| +// directory, <dataDir>/.moves/<host>/<op>/. A name that could leave its |
| +// directory is never present. |
| +func moveJournalPresent(dataDir, host, op string) bool { |
| + if dataDir == "" || !journalComponent(host) || !journalComponent(op) { |
| + return false |
| + } |
| + fi, err := os.Lstat(moves.JournalDir(dataDir, host, op)) |
| + return err == nil && fi.IsDir() |
| +} |
| + |
| +// 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. |
| +func (w *movesWorker) participantRound(ctx context.Context, s release.Store, rec provision.Record, role string) error { |
| + if role == roleSource && rec.Move.State == provision.MoveCommitted { |
| + return w.finalizeSource(ctx, s, rec) |
| + } |
| + return nil |
| +} |
| + |
| +// sourceFinalArmed reports whether this box can run the finalization: every |
| +// hook it calls is wired, the park engine's leases and Plan 5's view and |
| +// authorizer included. Nothing stands in for a missing owner in production |
| +// (fix round 1): the tests inject their own. In production none of the |
| +// three is wired yet (ruling G17). |
| +func sourceFinalArmed(cfg moveCfg) bool { |
| + return cfg.Box != "" && cfg.DataDir != "" && cfg.Guard.DataDir != "" && cfg.Local.DataDir != "" && |
| + cfg.ParkLeases != nil && cfg.ParkView != nil && cfg.ParkAuthorize != nil && |
| + cfg.Versioned != nil && cfg.Route != nil && cfg.DeleteHeld != nil |
| +} |
| + |
| +// sourceLocationParkScope is the source region's location park the commit |
| +// queues (Task 7 builds the commit; Task 8 reads it): holder.location, the |
| +// holder's incarnation, the cut's request limit, and a horizon that starts at |
| +// the flip (ruling H7). The flip comes after ExecToNone, after which the |
| +// source never writes its location again; the stop evidence's CheckedAt does |
| +// not, because the final checkpoint writes after it. |
| +func sourceLocationParkScope(rec provision.Record, holder hostclaim.Holder) (hostclaim.ParkScope, error) { |
| + if rec.Move == nil || rec.Move.FlippedAt == nil { |
| + return hostclaim.ParkScope{}, errors.New("the source's location park needs the flip's time") |
| + } |
| + return hostclaim.ParkScope{Location: strings.TrimSuffix(holder.Location, "/"), Incarnation: holder.Incarnation, |
| + EvidenceAt: rec.Move.FlippedAt.UTC(), RequestLimit: cutRequestLimit}, nil |
| +} |
| + |
| +// errFinalChanged is the record or claim no longer being this move's commit |
| +// when a step re-reads it. |
| +var errFinalChanged = errors.New("the record is no longer this move's commit") |
| + |
| +// sourceFinal is one pass's state for one committed record on its source. |
| +type sourceFinal struct { |
| + w *movesWorker |
| + s release.Store |
| + rec provision.Record |
| + g provision.MoveGuard |
| + j *moves.Journal |
| +} |
| + |
| +// finalizeSource is the source's §7.9 step 3 for a committed record. It runs |
| +// only when the record is committed and either this box's row is held by the |
| +// move or the journal has row-deleted (Review Focus 5, round-2 finding 12): |
| +// a landing record, a row held by another op, or no row and no journalled |
| +// delete leaves everything as it is. |
| +func (w *movesWorker) finalizeSource(ctx context.Context, s release.Store, rec provision.Record) error { |
| + cfg := w.cfg |
| + m := rec.Move |
| + if m == nil || m.Legacy() || m.State != provision.MoveCommitted || m.From != cfg.Region || m.To == cfg.Region { |
| + return nil |
| + } |
| + if !sourceFinalArmed(cfg) { |
| + w.notice(rec.Host+"#final", "moves: %s: the source's finalization of %s waits: this box's finalization is not armed (Plan 5's park view and the park engine's leases are needed)", rec.Host, m.ID) |
| + return nil |
| + } |
| + f := &sourceFinal{w: w, s: s, rec: rec, g: provision.MoveGuard{ID: m.ID, Incarnation: m.Incarnation}} |
| + return f.run(ctx) |
| +} |
| + |
| +func (f *sourceFinal) cfg() moveCfg { return f.w.cfg } |
| + |
| +func (f *sourceFinal) now() time.Time { |
| + if n := f.cfg().Now; n != nil { |
| + return n() |
| + } |
| + return time.Now() |
| +} |
| + |
| +// fault is the test seam after a side effect. |
| +func (f *sourceFinal) fault(point string) error { |
| + if fn := f.cfg().finalFault; fn != nil { |
| + return fn(point) |
| + } |
| + return nil |
| +} |
| + |
| +// mark journals a step durably, then passes its fault point. |
| +func (f *sourceFinal) mark(step, value string) error { |
| + f.j.Mark(step, value) |
| + if err := f.j.Write(f.cfg().DataDir); err != nil { |
| + return err |
| + } |
| + return f.fault("after-" + step) |
| +} |
| + |
| +func (f *sourceFinal) run(ctx context.Context) error { |
| + cfg := f.cfg() |
| + host, id := f.rec.Host, f.g.ID |
| + j, err := moves.ReadJournal(cfg.DataDir, host, id) |
| + if err != nil { |
| + return err |
| + } |
| + f.j = j |
| + rt, err := cfg.Route(host) |
| + if err != nil { |
| + return fmt.Errorf("read this box's row: %w", err) |
| + } |
| + held := rt != nil && rt.HoldOp == id |
| + if !held && !j.Done(finalStepRowDeleted) { |
| + f.w.notice(host+"#final-row", "moves: %s: the source's finalization of %s waits: this box's row is not held by the move", host, id) |
| + return nil |
| + } |
| + if held && rt.DBPath != "" && rt.DBPath != provision.DefaultDBPath(cfg.DataDir, host) { |
| + return fmt.Errorf("move %s: the source's row is not at the flat path (%s)", id, rt.DBPath) |
| + } |
| + // source_parked_at is stamped only after every step above it, so once it |
| + // is only the journal's own park is left, and the claim may already |
| + // have moved on to the close. |
| + if f.rec.Move.SourceParkedAt != nil { |
| + if j.Steps[finalStepParked] != "done" { |
| + if err := f.mark(finalStepParked, "done"); err != nil { |
| + return err |
| + } |
| + } |
| + return f.parkJournal() |
| + } |
| + obs, err := hostclaim.Read(ctx, f.s, host) |
| + if err != nil { |
| + return fmt.Errorf("read the host claim: %w", err) |
| + } |
| + if err := f.sourceOf(obs); err != nil { |
| + f.w.notice(host+"#final-claim", "moves: %s: the source's finalization of %s waits: %v", host, id, err) |
| + return nil |
| + } |
| + holder := *obs.Claim.Holder |
| + if !j.Done(finalStepRemote) { |
| + done, err := f.remote(ctx, obs) |
| + if err != nil || !done { |
| + return err |
| + } |
| + if err := f.mark(finalStepRemote, ""); err != nil { |
| + return err |
| + } |
| + } |
| + if !j.Done(finalStepInstallation) { |
| + how, err := f.installation(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if err := f.mark(finalStepInstallation, how); err != nil { |
| + return err |
| + } |
| + } |
| + if _, err := parkLocalArtifacts(ctx, j, f.mark, localArtifactPark{DataDir: cfg.DataDir, Incarnation: holder.Incarnation, |
| + Host: host, Op: id, Step: finalStepLocal, Paths: sourceArtifactPaths(host), NewID: obligationID, Fault: f.fault}); err != nil { |
| + return fmt.Errorf("park the source's local artifacts: %w", err) |
| + } |
| + if err := f.deleteRow(ctx); err != nil { |
| + return err |
| + } |
| + if !j.Done(finalStepConfigAck) { |
| + if err := f.configAck(ctx); err != nil { |
| + return err |
| + } |
| + if err := f.mark(finalStepConfigAck, ""); err != nil { |
| + return err |
| + } |
| + } |
| + if j.Steps[finalStepParked] != "done" { |
| + // "pending" is the stamp step reached: from here the journal is |
| + // the local sweep's to finish if the close overtakes this box |
| + // (sweepFinalJournals). |
| + if j.Steps[finalStepParked] != "pending" { |
| + j.Mark(finalStepParked, "pending") |
| + if err := j.Write(cfg.DataDir); err != nil { |
| + return err |
| + } |
| + if err := f.fault("after-source-parked-pending"); err != nil { |
| + return err |
| + } |
| + } |
| + if err := f.stampParked(ctx); err != nil { |
| + return err |
| + } |
| + if err := f.mark(finalStepParked, "done"); err != nil { |
| + return err |
| + } |
| + } |
| + return f.parkJournal() |
| +} |
| + |
| +// sourceOf is the claim this finalization runs under: this move's landing, |
| +// committed, this box its source with its evidence journalled, and the |
| +// record's incarnation. |
| +func (f *sourceFinal) sourceOf(obs hostclaim.Observed) error { |
| + cfg := f.cfg() |
| + if obs.Absent || obs.Claim == nil { |
| + return fmt.Errorf("%w: no host claim", park.ErrUnsafe) |
| + } |
| + c := obs.Claim |
| + if c.Op.ID != f.g.ID || c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseCommitted { |
| + return fmt.Errorf("%w: the claim is %s/%q for %q, not this move's commit", park.ErrUnsafe, c.Op.Kind, c.Op.Phase, c.Op.ID) |
| + } |
| + if len(c.Op.Participants) != 2 || c.Op.Participants[0] != cfg.Box || c.Op.Intent["source"] != cfg.Box { |
| + return fmt.Errorf("%w: this box is not the move's source in the claim", park.ErrUnsafe) |
| + } |
| + if c.Op.Journal[hostclaim.JournalEvidenceSource] == "" { |
| + return fmt.Errorf("%w: the claim does not journal the source's evidence", park.ErrUnsafe) |
| + } |
| + if c.Holder == nil || c.Holder.Incarnation != f.g.Incarnation { |
| + return fmt.Errorf("%w: the claim's holder is not this move's incarnation", park.ErrUnsafe) |
| + } |
| + return nil |
| +} |
| + |
| +// engine is the park engine over this box's own bucket, with the wired |
| +// leases, view and authorizer (sourceFinalArmed requires all three). |
| +func (f *sourceFinal) engine(vs lease.VersionedStore) park.Engine { |
| + cfg := f.cfg() |
| + return park.Engine{Store: vs, Claims: f.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + Now: f.now, NewID: obligationID, View: cfg.ParkView, Authorize: cfg.ParkAuthorize, After: cfg.parkFault} |
| +} |
| + |
| +// remote is step 1. done is true once the source region's location park has |
| +// its archive complete, or is no longer in the claim (acknowledged: the |
| +// commit queues it in its own write, so a committed claim without it has |
| +// had it acknowledged). The engine takes the location's lease itself. |
| +func (f *sourceFinal) remote(ctx context.Context, obs hostclaim.Observed) (bool, error) { |
| + cfg := f.cfg() |
| + host, id := f.rec.Host, f.g.ID |
| + var chore *hostclaim.Obligation |
| + for i := range obs.Claim.Obligations { |
| + o := &obs.Claim.Obligations[i] |
| + if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.Region && o.Park != nil && !isStagingLocation(o.Park.Location) { |
| + chore = o |
| + } |
| + } |
| + if chore == nil { |
| + return true, nil |
| + } |
| + if flipped := f.rec.Move.FlippedAt; flipped == nil || chore.Park.EvidenceAt.Before(*flipped) { |
| + return false, fmt.Errorf("move %s: the source's location park starts its horizon at %s, before the flip (ruling H7)", id, chore.Park.EvidenceAt.Format(time.RFC3339)) |
| + } |
| + vs, err := cfg.Versioned(ctx, cfg.OwnBucket, cfg.OwnBucketRegion) |
| + if err != nil { |
| + return false, err |
| + } |
| + e := f.engine(vs) |
| + work := park.Work{Host: host, ID: chore.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 the source's location: %w", err) |
| + } |
| + parked, err := e.Parked(ctx, work) |
| + if errors.Is(err, park.ErrChanged) { |
| + return true, nil |
| + } |
| + if err != nil { |
| + return false, fmt.Errorf("the source's location park: %w", err) |
| + } |
| + if !parked { |
| + f.w.notice(host+"#final-remote", "moves: %s: the source's finalization of %s waits for its location park's archive", host, id) |
| + return false, nil |
| + } |
| + log.Printf("moves: %s: the source's location %s is parked", host, chore.Park.Location) |
| + return true, nil |
| +} |
| + |
| +func isStagingLocation(location string) bool { return strings.HasPrefix(location, "moves/") } |
| + |
| +// installation is step 2's registered half (ruling H5(b)). With the box's |
| +// park-local and inventory paths of the journalled source stamp S, the |
| +// installation is retired under the host guard, which completes the chore; a |
| +// park-local with no such paths left is completed; no park-local is an |
| +// unregistered source, whose artifacts are all parkLocalArtifacts'. It |
| +// answers which, for the journal. |
| +func (f *sourceFinal) installation(ctx context.Context) (string, error) { |
| + cfg := f.cfg() |
| + host, id := f.rec.Host, f.g.ID |
| + obs, err := hostclaim.Read(ctx, f.s, host) |
| + if err != nil { |
| + return "", fmt.Errorf("read the host claim: %w", err) |
| + } |
| + if err := f.sourceOf(obs); err != nil { |
| + return "", fmt.Errorf("%w: %v", errFinalChanged, err) |
| + } |
| + c := obs.Claim |
| + stamp, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) |
| + if err != nil || stamp == 0 { |
| + return "", fmt.Errorf("move %s: the claim journals no source 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, stamp) |
| + switch { |
| + case chore != nil && registered: |
| + captured, err := hostguard.Capture(cfg.DataDir, host) |
| + if err != nil { |
| + return "", fmt.Errorf("the source's installation record: %w", err) |
| + } |
| + // A retry after the guard retired the record and before the claim |
| + // completed the chore captures stamp 0; the stamp is the claim's S. |
| + if captured.Incarnation != c.Holder.Incarnation || (captured.Stamp != 0 && captured.Stamp != stamp) { |
| + return "", fmt.Errorf("move %s: the source's installation record is at %s/%d, the claim's source is %s/%d", id, captured.Incarnation, captured.Stamp, c.Holder.Incarnation, stamp) |
| + } |
| + guard := cfg.Guard |
| + if guard.Claims == nil { |
| + guard.Claims = hostguard.StoreReader{Store: f.s} |
| + } |
| + local := cfg.Local |
| + local.Guard, local.Claims = guard, f.s |
| + if local.Box == "" { |
| + local.Box = cfg.Box |
| + } |
| + if local.Authorize == nil { |
| + // park.Local asks the same owner the engine does, over the claim |
| + // it read under the guard. |
| + authorize := cfg.ParkAuthorize |
| + local.Authorize = func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error { |
| + return authorize(ctx, hostclaim.Observed{Host: host, Claim: c}, o) |
| + } |
| + } |
| + if err := local.RetireInstallation(ctx, host, hostguard.Captured{Incarnation: captured.Incarnation, Stamp: stamp}, chore.ID, id); err != nil { |
| + return "", fmt.Errorf("retire the source's installation: %w", err) |
| + } |
| + log.Printf("moves: %s: the source's registered installation (stamp %d) is retired", host, stamp) |
| + return "retired", nil |
| + case chore != nil: |
| + if _, err := hostclaim.Update(ctx, f.s, obs, hostclaim.CompleteLocalPark(id, cfg.Box, stamp)); err != nil { |
| + return "", fmt.Errorf("complete the source's local park: %w", err) |
| + } |
| + return "completed", nil |
| + case registered: |
| + return "", fmt.Errorf("move %s: this box's inventory lists the source's paths, and the commit queued no park-local for them", id) |
| + } |
| + return "unregistered", nil |
| +} |
| + |
| +// sourceArtifactPaths are the source's artifacts at the flat paths (G11), |
| +// relative to the data directory: the database with its marker, journal, |
| +// quarantine and litestream shadow, the files directory and <host>.env. |
| +func sourceArtifactPaths(host string) []string { |
| + db := host + ".db" |
| + return []string{db, db + ".gen", db + "-wal", db + "-shm", db + ".corrupt", ".activator-" + db + "-litestream", host, host + ".env", |
| + host + ".config.env", host + "-sidecar.env"} |
| +} |
| + |
| +// deleteRow is step 3's row and timers delete, under the host's hold lock |
| +// with the record re-read there (the lock reconcile's hold decision holds). |
| +func (f *sourceFinal) deleteRow(ctx context.Context) error { |
| + if f.j.Steps[finalStepRowDeleted] == "done" { |
| + return nil |
| + } |
| + cfg := f.cfg() |
| + host, id := f.rec.Host, f.g.ID |
| + var err error |
| + provision.WithHostHoldLock(host, func() { |
| + fresh, e := provision.GetRecord(ctx, f.s, f.rec.Account, f.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 != f.g.Incarnation || m.State != provision.MoveCommitted { |
| + err = errFinalChanged |
| + return |
| + } |
| + if f.j.Steps[finalStepRowDeleted] != "pending" { |
| + f.j.Mark(finalStepRowDeleted, "pending") |
| + if err = f.j.Write(cfg.DataDir); err != nil { |
| + return |
| + } |
| + if err = f.fault("after-row-pending"); err != nil { |
| + return |
| + } |
| + } |
| + // false: no row is held by this op. With "pending" journalled that is |
| + // this delete, already done; a row another op holds was never this |
| + // move's, and is left as it is. |
| + deleted, e := cfg.DeleteHeld(host, id) |
| + if e != nil { |
| + err = fmt.Errorf("delete the source's row: %w", e) |
| + return |
| + } |
| + if deleted { |
| + log.Printf("moves: %s: the source's row and its timers are deleted", host) |
| + } |
| + if err = f.fault("after-row-delete"); err != nil { |
| + return |
| + } |
| + err = f.mark(finalStepRowDeleted, "done") |
| + }) |
| + return err |
| +} |
| + |
| +// configAck copies this box's config ack for the host into the move's report |
| +// prefix and deletes it (§6.3). Absent is nothing to copy, or a replay after |
| +// the delete. |
| +func (f *sourceFinal) configAck(ctx context.Context) error { |
| + cfg := f.cfg() |
| + key := appconfig.ConfigAckKey(cfg.Box, f.rec.Host) |
| + body, _, err := f.s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return fmt.Errorf("read the config ack: %w", err) |
| + } |
| + // The copy is create-only. A replay after the copy finds it: the same |
| + // body is this copy; another (the ack was rewritten in between) is |
| + // replaced under its etag, so the copy is the last ack the box wrote. |
| + dst := moves.ReportPrefix(f.rec.Account, f.rec.App, f.rec.Host, f.g.ID) + finalConfigAckName |
| + if _, err := f.s.Put(ctx, dst, body, ""); errors.Is(err, release.ErrCASConflict) { |
| + had, etag, gerr := f.s.Get(ctx, dst) |
| + if gerr != nil { |
| + return fmt.Errorf("read the config ack's copy: %w", gerr) |
| + } |
| + if string(had) != string(body) { |
| + if _, err := f.s.Put(ctx, dst, body, etag); err != nil { |
| + return fmt.Errorf("copy the config ack: %w", err) |
| + } |
| + } |
| + } else if err != nil { |
| + return fmt.Errorf("copy the config ack: %w", err) |
| + } |
| + if err := f.fault("after-config-ack-copy"); err != nil { |
| + return err |
| + } |
| + if err := f.s.Delete(ctx, key); err != nil { |
| + return fmt.Errorf("delete the config ack: %w", err) |
| + } |
| + return nil |
| +} |
| + |
| +// stampParked is step 4: MoveSourceParked, then the nudge. A record that has |
| +// moved past this commit (closed, or another move) had it stamped, since the |
| +// close needs it; this commit without the stamp is an error. |
| +func (f *sourceFinal) stampParked(ctx context.Context) error { |
| + host := f.rec.Host |
| + _, err := provision.SetRecordMove(ctx, f.s, f.rec.Account, f.rec.App, host, provision.MoveSourceParked(f.g, f.now())) |
| + if errors.Is(err, provision.ErrMoveConflict) { |
| + fresh, gerr := provision.GetRecord(ctx, f.s, f.rec.Account, f.rec.App, host) |
| + if gerr != nil { |
| + return fmt.Errorf("re-read the record: %w", gerr) |
| + } |
| + if m := fresh.Move; m != nil && m.ID == f.g.ID && m.SourceParkedAt == nil { |
| + return fmt.Errorf("stamp source_parked_at: %w", err) |
| + } |
| + err = nil |
| + } |
| + if err != nil { |
| + return fmt.Errorf("stamp source_parked_at: %w", err) |
| + } |
| + if err := f.fault("after-source-parked-record"); err != nil { |
| + return err |
| + } |
| + nudgeSerial(ctx, f.s) |
| + log.Printf("moves: %s: the source has parked what it kept for %s", host, f.g.ID) |
| + return nil |
| +} |
| + |
| +// parkJournal renames the op's journal directory into the local retirement, |
| +// after source_parked_at is stamped: until then it is what brings a replay |
| +// back here. A crash before the rename leaves the journal, and the next pass |
| +// (or the local sweep) renames it; after it nothing of the move is left |
| +// under .moves/. |
| +func (f *sourceFinal) parkJournal() error { |
| + return parkFinalJournal(f.cfg().DataDir, f.rec.Host, f.g.ID, f.j, f.mark) |
| +} |
| + |
| +// parkFinalJournal parks a finalized op's journal into the retirement its |
| +// own local park recorded, journalling the destination just before it goes. |
| +func parkFinalJournal(dataDir, host, op string, j *moves.Journal, mark func(step, value string) error) error { |
| + retirement := j.Steps[finalStepLocal] |
| + if retirement == "" { |
| + return fmt.Errorf("move %s: the journal records no local retirement for its own park", op) |
| + } |
| + plan, err := journalledLocalPlan(j, finalStepLocal) |
| + if err != nil { |
| + return err |
| + } |
| + dst := filepath.Join(parkedArtifactsDir(dataDir, plan.Incarnation, host, retirement), ".moves", host, op) |
| + if j.Steps[finalStepJournal] != dst { |
| + if err := mark(finalStepJournal, dst); err != nil { |
| + return err |
| + } |
| + } |
| + 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 source's finalization of %s is complete", host, op) |
| + return nil |
| +} |
| + |
| +// 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 |
| +// 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 |
| +// the stamp step is finished here: |
| +// - the stamp journalled "done": the journal is parked; |
| +// - "pending", and the listed record still carries this op: with |
| +// source_parked_at set it is journalled done and parked; without, it is |
| +// left to the finalization's own pass; |
| +// - "pending", and the listed record no longer carries this op (closed, or |
| +// another move since): the stamp landed, because the only transition |
| +// that clears a committed move is MoveClose, which refuses unless |
| +// source_parked_at is set (the aborts end at cutting/requested); so the |
| +// stamp is journalled done and the journal parked; |
| +// - a host the listing does not name (its record deleted, or unreadable) |
| +// is left as it is. |
| +// |
| +// A journal that has not reached the stamp step (no source-parked step: a |
| +// landing's, a cut's, a finalization still under way) is never touched, and |
| +// nothing is ever deleted: a journal is only renamed into its retirement. |
| +func (w *movesWorker) sweepFinalJournals(recs []provision.Record) { |
| + dd := w.cfg.DataDir |
| + if dd == "" { |
| + return |
| + } |
| + hosts, err := os.ReadDir(filepath.Join(dd, ".moves")) |
| + if err != nil { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + w.notice("#final-sweep", "moves: the op journal sweep cannot list %s: %v", filepath.Join(dd, ".moves"), err) |
| + } |
| + return |
| + } |
| + byHost := map[string][]provision.Record{} |
| + for _, rec := range recs { |
| + byHost[rec.Host] = append(byHost[rec.Host], rec) |
| + } |
| + for _, hd := range hosts { |
| + host := hd.Name() |
| + if !hd.IsDir() || !journalComponent(host) { |
| + continue |
| + } |
| + ops, err := os.ReadDir(filepath.Join(dd, ".moves", host)) |
| + if err != nil { |
| + w.notice(host+"#final-sweep", "moves: %s: the op journal sweep cannot list its journals: %v", host, err) |
| + continue |
| + } |
| + for _, od := range ops { |
| + op := od.Name() |
| + if !od.IsDir() || !journalComponent(op) { |
| + continue |
| + } |
| + if err := w.sweepFinalJournal(dd, host, op, byHost[host]); err != nil { |
| + w.logTrouble(host, fmt.Errorf("the op journal sweep, %s: %w", op, err)) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// sweepFinalJournal is one journal of sweepFinalJournals. |
| +func (w *movesWorker) sweepFinalJournal(dataDir, host, op string, recs []provision.Record) error { |
| + j, err := moves.ReadJournal(dataDir, host, op) |
| + if err != nil { |
| + return err |
| + } |
| + stamp, reached := j.Steps[finalStepParked] |
| + if !reached { |
| + return nil |
| + } |
| + if stamp != "done" { |
| + if len(recs) == 0 { |
| + w.notice(host+"#final-sweep-"+op, "moves: %s: the journal of %s reached its stamp, 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.SourceParkedAt == nil { |
| + return nil // the finalization's own pass stamps it |
| + } |
| + } |
| + } |
| + mark := func(step, value string) error { |
| + j.Mark(step, value) |
| + if err := j.Write(dataDir); err != nil { |
| + return err |
| + } |
| + if fn := w.cfg.finalFault; fn != nil { |
| + return fn("after-" + step) |
| + } |
| + return nil |
| + } |
| + if stamp != "done" { |
| + if err := mark(finalStepParked, "done"); err != nil { |
| + return err |
| + } |
| + } |
| + return parkFinalJournal(dataDir, host, op, j, mark) |
| +} |
diff --git a/internal/host/movesfinal_test.go b/internal/host/movesfinal_test.go| new file mode 100644 |
| index 000000000..236dc2510 |
| --- /dev/null |
| +++ b/internal/host/movesfinal_test.go |
| @@ -0,0 +1,998 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "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/registry" |
| +) |
| + |
| +// The source's finalization harness: the landing harness driven through |
| +// restored, then committed the way Task 7 will (the record's probing and |
| +// probed steps, MoveCommit, and hostclaim.Commit with the source's location |
| +// park scoped by sourceLocationParkScope), and the SOURCE box (ie-box) given |
| +// a real registry holding its row "moving" under the move with two timers, |
| +// the config ack the box last wrote, and the database's marker and |
| +// quarantine beside the database the cut harness left at the flat path. |
| +// The source's park engine runs over the fake versioned store and the same |
| +// fake lease table as the landing's (fakeParkLeases). |
| + |
| +const finalAckBody = `{"v":1,"host":"a.go.example.com"}` |
| + |
| +type finalHarness struct { |
| + *landingHarness |
| + reg *registry.Registry |
| + finalAt string // the source's crash point; "" crashes nowhere |
| + finalDone bool |
| + finalHits []string |
| + crashAll map[string]bool // crash once at every point not in it (the replay test) |
| + deletes int // DeleteHeld calls that deleted |
| +} |
| + |
| +func newFinalHarness(t *testing.T) *finalHarness { |
| + t.Helper() |
| + h := &finalHarness{landingHarness: newLandingHarness(t)} |
| + h.flip(t) |
| + h.land(t) |
| + h.installed(t) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + h.reg = reg |
| + src := h.phase1Harness.route |
| + src.Status, src.HoldOp = "moving", p1ID |
| + if err := reg.Upsert(src); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tm := range []registry.Timer{{Host: p1Host, Name: "nightly", Kind: "cron", Spec: "0 3 * * *", NextAt: 1000}, |
| + {Host: p1Host, Name: "hourly", Kind: "cron", Spec: "0 * * * *", NextAt: 2000}} { |
| + if err := reg.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + dd := h.cfg.DataDir |
| + for name, body := range map[string]string{p1Host + ".db.gen": "cur", p1Host + ".db.corrupt": "quarantined", |
| + p1Host + ".config.env": "SECRET=1\n", p1Host + "-sidecar.env": "SIDE=1\n"} { |
| + if err := os.WriteFile(filepath.Join(dd, name), []byte(body), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := h.s.Put(context.Background(), appconfig.ConfigAckKey("ie-box", p1Host), []byte(finalAckBody), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.cfg.Route = func(host string) (*registry.Route, error) { return reg.Get(host) } |
| + h.cfg.OwnsRoute = func(host string) bool { |
| + rt, err := reg.Get(host) |
| + return err == nil && rt != nil && rt.Provisioned |
| + } |
| + h.cfg.DeleteHeld = func(host, op string) (bool, error) { |
| + ok, err := reg.DeleteIfHoldWithTimers(host, op) |
| + if ok { |
| + h.deletes++ |
| + } |
| + return ok, err |
| + } |
| + h.cfg.Guard = hostguard.Guard{DataDir: dd, LockDir: t.TempDir()} |
| + h.cfg.Local = park.Local{DataDir: dd, Box: "ie-box"} |
| + h.cfg.ParkLeases = h.leases |
| + h.cfg.ParkView, h.cfg.ParkAuthorize = sourceTestView, sourceTestAuthorize |
| + h.cfg.parkFault = h.lcfg.parkFault |
| + h.cfg.finalFault = func(point string) error { |
| + h.finalHits = append(h.finalHits, point) |
| + if h.crashAll != nil && !h.crashAll[point] { |
| + h.crashAll[point] = true |
| + return errCrash |
| + } |
| + if point == h.finalAt && !h.finalDone { |
| + h.finalDone = true |
| + return errCrash |
| + } |
| + return nil |
| + } |
| + return h |
| +} |
| + |
| +// advanceToProbed walks the record and the claim from restored through |
| +// probing to probed, as Task 6 will. |
| +func (h *finalHarness) advanceToProbed(t *testing.T) { |
| + t.Helper() |
| + ctx := context.Background() |
| + g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc} |
| + for _, step := range [][2]string{{provision.LandRestored, provision.LandProbing}, {provision.LandProbing, provision.LandProbed}} { |
| + if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveLandingPhase(g, step[0], step[1], nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(ctx, h.s, p1Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(ctx, h.s, obs, hostclaim.SetMovePhase(p1ID, hostclaim.KindLanding, step[0], step[1])); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +// commitRecord is the record's commit alone (MoveCommit). |
| +func (h *finalHarness) commitRecord(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.MoveCommit(g, h.clock.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// commitClaim is the claim's commit (hostclaim.Commit), with the source's |
| +// location park scoped from the flip (H7) and the target's staging lane. |
| +func (h *finalHarness) commitClaim(t *testing.T) { |
| + t.Helper() |
| + ctx := context.Background() |
| + rec := h.read(t, p1Host) |
| + obs, err := hostclaim.Read(ctx, h.s, p1Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + scope, err := sourceLocationParkScope(rec, *obs.Claim.Holder) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + staging := hostclaim.ParkScope{Location: moves.ScopeLocation(p1Host, p1ID), Incarnation: h.inc, EvidenceAt: *rec.Move.FlippedAt, RequestLimit: cutRequestLimit} |
| + if _, err := hostclaim.Update(ctx, h.s, obs, hostclaim.Commit(p1ID, "ie", "ie-box", "uk", scope, staging, obligationID)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (h *finalHarness) commit(t *testing.T) { |
| + t.Helper() |
| + h.advanceToProbed(t) |
| + h.commitRecord(t) |
| + h.commitClaim(t) |
| +} |
| + |
| +// sourcePass runs one pass as the source box. |
| +func (h *finalHarness) sourcePass(t *testing.T) { t.Helper(); h.pass(t) } |
| + |
| +// crashSource runs one source pass that must reach the crash point. |
| +func (h *finalHarness) crashSource(t *testing.T) { |
| + t.Helper() |
| + h.sourcePass(t) |
| + if !h.finalDone { |
| + t.Fatalf("the pass never reached %s (points reached: %v)", h.finalAt, h.finalHits) |
| + } |
| +} |
| + |
| +func (h *finalHarness) sourceJournal(t *testing.T) *moves.Journal { |
| + t.Helper() |
| + j, err := moves.ReadJournal(h.cfg.DataDir, p1Host, p1ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return j |
| +} |
| + |
| +// live lists which of the source's artifacts are at their live paths. |
| +func (h *finalHarness) live(t *testing.T) []string { |
| + t.Helper() |
| + var out []string |
| + for _, p := range append(testSourcePaths(), filepath.Join(".moves", p1Host, p1ID)) { |
| + if _, err := os.Lstat(filepath.Join(h.cfg.DataDir, p)); err == nil { |
| + out = append(out, p) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal(err) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// parked lists every regular file under .parked/<inc>/<host>/, as |
| +// "<retirement>/<rest>", sorted. |
| +func (h *finalHarness) parked(t *testing.T) []string { |
| + t.Helper() |
| + root := filepath.Join(h.cfg.DataDir, ".parked", h.inc, p1Host) |
| + var out []string |
| + filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { |
| + if err == nil && !d.IsDir() { |
| + rel, _ := filepath.Rel(root, p) |
| + out = append(out, filepath.ToSlash(rel)) |
| + } |
| + return nil |
| + }) |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// untouched asserts that nothing of the source's was finalized. |
| +func (h *finalHarness) untouched(t *testing.T, why string) { |
| + t.Helper() |
| + if got, want := h.live(t), []string{p1Host + ".db", p1Host + ".db.gen", p1Host + ".db.corrupt", p1Host, p1Host + ".env", p1Host + ".config.env", p1Host + "-sidecar.env", filepath.Join(".moves", p1Host, p1ID)}; strings.Join(got, " ") != strings.Join(want, " ") { |
| + t.Fatalf("%s: live artifacts %v, want %v", why, got, want) |
| + } |
| + if got := h.parked(t); len(got) != 0 { |
| + t.Fatalf("%s: parked %v", why, got) |
| + } |
| + if rt, err := h.reg.Get(p1Host); err != nil || rt == nil { |
| + t.Fatalf("%s: the row: %+v %v", why, rt, err) |
| + } |
| + if tm, _ := h.reg.TimersFor(p1Host); len(tm) != 2 { |
| + t.Fatalf("%s: timers %+v", why, tm) |
| + } |
| + if m := h.read(t, p1Host).Move; m == nil || m.SourceParkedAt != nil { |
| + t.Fatalf("%s: record %+v", why, m) |
| + } |
| + if _, _, err := h.s.Get(context.Background(), appconfig.ConfigAckKey("ie-box", p1Host)); err != nil { |
| + t.Fatalf("%s: the config ack: %v", why, err) |
| + } |
| + for _, step := range []string{finalStepRemote, finalStepInstallation, finalStepLocal + "-plan", finalStepRowDeleted} { |
| + if h.sourceJournal(t).Done(step) { |
| + t.Fatalf("%s: the journal has %s", why, step) |
| + } |
| + } |
| + if calls := h.vs.callsOn("parked/"); len(calls) != 0 { |
| + t.Fatalf("%s: the source's location was parked: %v", why, calls) |
| + } |
| +} |
| + |
| +// finalized asserts the end state of a source finalization of an |
| +// unregistered source: every artifact in one retirement under the engine's |
| +// layout with the op's journal and a receipt naming the op, nothing live, |
| +// the row and its timers gone, the config ack moved into the report |
| +// prefix, source_parked_at stamped, and the source's location parked. |
| +func (h *finalHarness) finalized(t *testing.T) string { |
| + t.Helper() |
| + ctx := context.Background() |
| + if got := h.live(t); len(got) != 0 { |
| + t.Fatalf("still live: %v", got) |
| + } |
| + parked := h.parked(t) |
| + if len(parked) == 0 { |
| + t.Fatal("nothing parked") |
| + } |
| + ret, _, _ := strings.Cut(parked[0], "/") |
| + var rest []string |
| + for _, p := range parked { |
| + r, tail, _ := strings.Cut(p, "/") |
| + if r != ret { |
| + t.Fatalf("more than one retirement: %v", parked) |
| + } |
| + rest = append(rest, tail) |
| + } |
| + jpath := "artifacts/.moves/" + p1Host + "/" + p1ID + "/journal.json" |
| + want := []string{"artifacts/" + p1Host + "/upload.txt", "artifacts/" + p1Host + ".db", "artifacts/" + p1Host + ".db.corrupt", "artifacts/" + p1Host + ".db.gen", "artifacts/" + p1Host + ".env", |
| + "artifacts/" + p1Host + ".config.env", "artifacts/" + p1Host + "-sidecar.env", localParkReceiptName} |
| + var got []string |
| + sawJournal := false |
| + for _, r := range rest { |
| + switch { |
| + case r == jpath: |
| + sawJournal = true |
| + case strings.HasPrefix(r, "artifacts/.moves/"): |
| + // the op journal directory's other contents (the archive scratch) |
| + default: |
| + got = append(got, r) |
| + } |
| + } |
| + sort.Strings(want) |
| + if strings.Join(got, " ") != strings.Join(want, " ") || !sawJournal { |
| + t.Fatalf("parked %v (journal %v), want %v and the journal", got, sawJournal, want) |
| + } |
| + art := filepath.Join(h.cfg.DataDir, ".parked", h.inc, p1Host, ret, "artifacts") |
| + for name, body := range map[string]string{p1Host + ".db": "the stopped database", p1Host + "/upload.txt": "hello", p1Host + ".env": "APP_MODE=prod\n", p1Host + ".db.gen": "cur"} { |
| + if b, err := os.ReadFile(filepath.Join(art, name)); err != nil || string(b) != body { |
| + t.Fatalf("parked %s: %q %v", name, b, err) |
| + } |
| + } |
| + var receipt localParkReceipt |
| + if b, err := os.ReadFile(filepath.Join(filepath.Dir(art), localParkReceiptName)); err != nil || json.Unmarshal(b, &receipt) != nil || receipt.Op != p1ID || receipt.Retirement != ret || receipt.Incarnation != h.inc { |
| + t.Fatalf("receipt %+v %v", receipt, err) |
| + } |
| + var pj moves.Journal |
| + if b, err := os.ReadFile(filepath.Join(h.cfg.DataDir, ".parked", h.inc, p1Host, ret, jpath)); err != nil || json.Unmarshal(b, &pj) != nil { |
| + t.Fatalf("the parked journal: %v", err) |
| + } |
| + if pj.Steps[finalStepRowDeleted] != "done" || !pj.Done(finalStepParked) || pj.Steps[finalStepLocal] != ret || pj.Steps[finalStepInstallation] != "unregistered" { |
| + t.Fatalf("the parked journal's steps: %v", pj.Steps) |
| + } |
| + if rt, err := h.reg.Get(p1Host); err != nil || rt != nil { |
| + t.Fatalf("the row: %+v %v", rt, err) |
| + } |
| + if tm, err := h.reg.TimersFor(p1Host); err != nil || len(tm) != 0 { |
| + t.Fatalf("timers: %+v %v", tm, err) |
| + } |
| + if _, _, err := h.s.Get(ctx, appconfig.ConfigAckKey("ie-box", p1Host)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the config ack is still there: %v", err) |
| + } |
| + if b, _, err := h.s.Get(ctx, moves.ReportPrefix("bab", "app", p1Host, p1ID)+finalConfigAckName); err != nil || string(b) != finalAckBody { |
| + t.Fatalf("the config ack's copy: %q %v", b, err) |
| + } |
| + m := h.read(t, p1Host).Move |
| + if m == nil || m.State != provision.MoveCommitted || m.SourceParkedAt == nil { |
| + t.Fatalf("record: %+v", m) |
| + } |
| + // The source's location is archived and gone from its live keys; its |
| + // park-replica is still owed until the horizon (the regional runner's). |
| + for k := range h.f.buckets["data-ie"] { |
| + if strings.HasPrefix(k, cutRoot+"/") { |
| + t.Fatalf("still at the source's location: %s", k) |
| + } |
| + } |
| + archived := 0 |
| + for k := range h.f.buckets["data-ie"] { |
| + if strings.HasPrefix(k, "parked/"+h.inc+"/"+p1Host+"/") && strings.Contains(k, "/instances/") { |
| + archived++ |
| + } |
| + } |
| + if archived == 0 { |
| + t.Fatal("nothing archived from the source's location") |
| + } |
| + if held := h.leases.holding(); len(held) != 0 { |
| + t.Fatalf("leases still held: %v", held) |
| + } |
| + return ret |
| +} |
| + |
| +// Review Focus 5: the source finalizes only a committed record whose row |
| +// this move still holds (or whose journal says the row is deleted). |
| +func TestSourceParkWaitsForCommit(t *testing.T) { |
| + t.Run("a landing record", func(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.advanceToProbed(t) |
| + h.sourcePass(t) |
| + h.untouched(t, "landing/probed") |
| + }) |
| + t.Run("a committed record whose claim is not committed", func(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.advanceToProbed(t) |
| + h.commitRecord(t) |
| + h.sourcePass(t) |
| + h.untouched(t, "claim at probed") |
| + }) |
| + t.Run("a committed record whose row another op holds", func(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + if ok, err := h.reg.ReleaseHold(p1Host, p1ID); err != nil || !ok { |
| + t.Fatal(ok, err) |
| + } |
| + if err := h.reg.SetHold(p1Host, "restoring", p1ID2); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.sourcePass(t) |
| + h.untouched(t, "row held by another op") |
| + if rt, _ := h.reg.Get(p1Host); rt.HoldOp != p1ID2 { |
| + t.Fatalf("the other op's row: %+v", rt) |
| + } |
| + }) |
| + t.Run("a committed record with no row and no journalled delete", func(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + if err := h.reg.Delete(p1Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.sourcePass(t) |
| + if got := h.live(t); len(got) != 8 || len(h.parked(t)) != 0 || h.read(t, p1Host).Move.SourceParkedAt != nil { |
| + t.Fatalf("finalized without the row: live %v parked %v", got, h.parked(t)) |
| + } |
| + }) |
| + t.Run("then the commit", func(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.advanceToProbed(t) |
| + h.sourcePass(t) |
| + h.commitRecord(t) |
| + h.commitClaim(t) |
| + h.sourcePass(t) |
| + h.finalized(t) |
| + }) |
| +} |
| + |
| +// The end to end finalization of an unregistered source (ruling H5(b)): the |
| +// database, its marker and quarantine, the files directory and the env are |
| +// gone from the live paths and in one retirement under the engine's layout; |
| +// the row and its timers rows are gone; source_parked_at is set; the op |
| +// journal is parked last; a further pass changes nothing. |
| +func TestSourceParkRetiresInstallationAndRow(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + if c := h.claim(t); len(c.Obligations) != 2 { |
| + t.Fatalf("the commit queued %+v (want the source's location and the target's lane, no park-local)", c.Obligations) |
| + } |
| + h.sourcePass(t) |
| + h.finalized(t) |
| + if h.deletes != 1 { |
| + t.Fatalf("the row was deleted %d times", h.deletes) |
| + } |
| + before := h.parked(t) |
| + h.freshWorker() |
| + h.sourcePass(t) |
| + if after := h.parked(t); strings.Join(after, " ") != strings.Join(before, " ") || h.deletes != 1 { |
| + t.Fatalf("a further pass changed the park: %v", after) |
| + } |
| + if movesParticipantRecord(h.cfg, h.read(t, p1Host)) != roleNone { |
| + t.Fatal("a finalized source still routes the record here") |
| + } |
| +} |
| + |
| +// Past the horizon the engine acknowledges the source's location park in |
| +// the same run, and the finalization goes on. |
| +func TestSourceParkPastTheHorizonAcknowledges(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + h.clock.Advance(park.SignatureAge + cutRequestLimit + time.Minute) |
| + h.sourcePass(t) |
| + h.finalized(t) |
| + for _, o := range h.claim(t).Obligations { |
| + if o.Kind == "park-replica" && o.Region == "ie" { |
| + t.Fatalf("the source's location park is still owed: %+v", o) |
| + } |
| + } |
| +} |
| + |
| +// The remote half comes first: while the archive is incomplete (a delete |
| +// the store accepts but does not land) nothing local moves. |
| +func TestSourceParkWaitsForTheRemoteHalf(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + stuck := true |
| + h.vs.skipDelete = func(key string) bool { return stuck && strings.HasPrefix(key, cutRoot+"/") } |
| + h.sourcePass(t) |
| + if got := h.live(t); len(got) != 8 { |
| + t.Fatalf("the local half ran before the remote: live %v", got) |
| + } |
| + if j := h.sourceJournal(t); j.Done(finalStepRemote) || j.Done(finalStepLocal+"-plan") { |
| + t.Fatalf("journal: %v", j.Steps) |
| + } |
| + if rt, _ := h.reg.Get(p1Host); rt == nil { |
| + t.Fatal("the row went before the remote half") |
| + } |
| + stuck = false |
| + h.sourcePass(t) |
| + h.finalized(t) |
| +} |
| + |
| +// Round-2 finding 12: a crash between the row's delete and the journal's |
| +// "done" (and before MoveSourceParked) is replayed: the record's region is |
| +// the target's and the row is gone, so only the op journal routes the |
| +// record back here, and the replay treats the pending delete as done and |
| +// stamps source_parked_at. |
| +func TestSourceParkSurvivesACrashAfterTheRowDelete(t *testing.T) { |
| + for _, point := range []string{"after-row-pending", "after-row-delete"} { |
| + t.Run(point, func(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + h.finalAt = point |
| + h.crashSource(t) |
| + if j := h.sourceJournal(t); j.Steps[finalStepRowDeleted] != "pending" { |
| + t.Fatalf("journal: %v", j.Steps) |
| + } |
| + rt, _ := h.reg.Get(p1Host) |
| + if (point == "after-row-delete") != (rt == nil) { |
| + t.Fatalf("the row after %s: %+v", point, rt) |
| + } |
| + if h.read(t, p1Host).Move.SourceParkedAt != nil { |
| + t.Fatal("stamped before the crash point") |
| + } |
| + if movesParticipantRecord(h.cfg, h.read(t, p1Host)) != roleSource { |
| + t.Fatal("the journal does not route the record back to the source") |
| + } |
| + h.freshWorker() |
| + h.sourcePass(t) |
| + h.finalized(t) |
| + if h.deletes != 1 { |
| + t.Fatalf("deleted %d times", h.deletes) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Every crash point, each once, with a fresh worker after each: the replay |
| +// converges on the same end state, with each artifact renamed once, the row |
| +// deleted once and one retirement. |
| +func TestSourceParkReplayIsIdempotent(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + h.crashAll = map[string]bool{} |
| + h.parkAt = "delete" |
| + for i := 0; i < 40 && len(h.live(t)) > 0; i++ { |
| + h.freshWorker() |
| + h.sourcePass(t) |
| + } |
| + h.finalized(t) |
| + if h.deletes != 1 { |
| + t.Fatalf("deleted %d times", h.deletes) |
| + } |
| + want := []string{"after-" + finalStepRemote, "after-" + finalStepInstallation, "after-" + finalStepLocal + "-plan", |
| + "after-" + finalStepLocal, |
| + "after-row-pending", "after-row-delete", "after-" + finalStepRowDeleted, "after-config-ack-copy", "after-" + finalStepConfigAck, |
| + "after-source-parked-pending", "after-source-parked-record", "after-" + finalStepParked, "after-" + finalStepJournal} |
| + for _, p := range []string{p1Host + ".db", p1Host + ".db.gen", p1Host + ".db.corrupt", p1Host, p1Host + ".env", p1Host + ".config.env", p1Host + "-sidecar.env"} { |
| + want = append(want, "after-rename-unsynced:"+p, "after-rename:"+p) |
| + } |
| + for _, p := range want { |
| + if !h.crashAll[p] { |
| + t.Errorf("never crashed at %s (crashed at %v)", p, h.crashAll) |
| + } |
| + } |
| + if !h.parkDone { |
| + t.Error("the park engine never crashed at its delete") |
| + } |
| +} |
| + |
| +// A registered source (the commit queued its park-local because the |
| +// inventory lists paths of the journalled stamp S) retires its installation |
| +// through park.Local under the guard, which completes the park-local; what |
| +// the inventory does not list is parked by the moves-owned rename. |
| +func TestSourceParkRetiresARegisteredInstallation(t *testing.T) { |
| + h := newFinalHarness(t) |
| + dd := h.cfg.DataDir |
| + for _, d := range []string{"hostguard", ".parked"} { |
| + if err := os.MkdirAll(filepath.Join(dd, d), 0o770); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + rec := `{"v":1,"host":"` + p1Host + `","incarnation":"` + h.inc + `","active_stamp":1,"retired_stamps":[]}` |
| + if err := os.WriteFile(filepath.Join(dd, "hostguard", p1Host+".json"), []byte(rec), 0o660); err != nil { |
| + t.Fatal(err) |
| + } |
| + rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { |
| + if c.Op.Journal[hostclaim.JournalSourceStamp] != "1" { |
| + t.Fatalf("fixture: source stamp %q", c.Op.Journal[hostclaim.JournalSourceStamp]) |
| + } |
| + if c.Inventory == nil { |
| + c.Inventory = map[string]hostclaim.InventoryEntry{} |
| + } |
| + c.Inventory["ie-box"] = hostclaim.InventoryEntry{Layout: "flat", Paths: map[string]uint64{p1Host + ".db": 1, p1Host + ".db.gen": 1, p1Host: 1, p1Host + ".env": 1}} |
| + }) |
| + h.commit(t) |
| + var chore hostclaim.Obligation |
| + for _, o := range h.claim(t).Obligations { |
| + if o.Kind == "park-local" { |
| + chore = o |
| + } |
| + } |
| + if chore.Box != "ie-box" { |
| + t.Fatalf("the commit queued no park-local for the source: %+v", h.claim(t).Obligations) |
| + } |
| + h.sourcePass(t) |
| + 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["ie-box"].Paths) != 0 { |
| + t.Fatalf("inventory: %+v", c.Inventory["ie-box"]) |
| + } |
| + if got := h.live(t); len(got) != 0 { |
| + t.Fatalf("still live: %v", got) |
| + } |
| + parked := strings.Join(h.parked(t), " ") |
| + for _, p := range []string{chore.Retirement + "/artifacts/" + p1Host + ".db", chore.Retirement + "/artifacts/" + p1Host + "/upload.txt", chore.Retirement + "/.journal.json"} { |
| + if !strings.Contains(parked, p) { |
| + t.Fatalf("park.Local's retirement lacks %s: %s", p, parked) |
| + } |
| + } |
| + j := h.parkedJournal(t) |
| + if j.Steps[finalStepInstallation] != "retired" { |
| + t.Fatalf("installation step %q", j.Steps[finalStepInstallation]) |
| + } |
| + ret := j.Steps[finalStepLocal] |
| + if ret == chore.Retirement || !strings.Contains(parked, ret+"/artifacts/"+p1Host+".db.corrupt") { |
| + t.Fatalf("the unregistered quarantine is not in the moves-owned retirement %s: %s", ret, parked) |
| + } |
| + if captured, err := hostguard.Capture(dd, p1Host); err != nil || captured.Stamp != 0 { |
| + t.Fatalf("the installation record is still active: %+v %v", captured, err) |
| + } |
| + if rt, _ := h.reg.Get(p1Host); rt != nil || h.read(t, p1Host).Move.SourceParkedAt == nil { |
| + t.Fatal("the row or the stamp") |
| + } |
| +} |
| + |
| +// parkedJournal reads the op journal from wherever the finalization parked it. |
| +func (h *finalHarness) parkedJournal(t *testing.T) moves.Journal { |
| + t.Helper() |
| + var j moves.Journal |
| + for _, p := range h.parked(t) { |
| + if strings.HasSuffix(p, "/.moves/"+p1Host+"/"+p1ID+"/journal.json") { |
| + b, err := os.ReadFile(filepath.Join(h.cfg.DataDir, ".parked", h.inc, p1Host, p)) |
| + if err != nil || json.Unmarshal(b, &j) != nil { |
| + t.Fatalf("parked journal: %v", err) |
| + } |
| + return j |
| + } |
| + } |
| + t.Fatalf("no parked journal in %v", h.parked(t)) |
| + return j |
| +} |
| + |
| +// The rename moves only what its plan recorded: after the plan, an |
| +// artifact replaced at its live path (another identity) and one that |
| +// appears where the plan found nothing are both left where they are, |
| +// untouched; everything recorded is parked. |
| +func TestSourceParkLeavesWhatItDidNotRecord(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + dd := h.cfg.DataDir |
| + if err := os.Remove(filepath.Join(dd, p1Host+".db.corrupt")); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.finalAt = "after-" + finalStepLocal + "-plan" |
| + h.crashSource(t) |
| + envPath := filepath.Join(dd, p1Host+".env") |
| + if err := os.Rename(envPath, envPath+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(envPath, []byte("SOMEONE=else\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dd, p1Host+".db.corrupt"), []byte("new"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.freshWorker() |
| + h.sourcePass(t) |
| + for name, body := range map[string]string{p1Host + ".env": "SOMEONE=else\n", p1Host + ".db.corrupt": "new"} { |
| + if b, err := os.ReadFile(filepath.Join(dd, name)); err != nil || string(b) != body { |
| + t.Fatalf("%s was touched: %q %v", name, b, err) |
| + } |
| + } |
| + parked := strings.Join(h.parked(t), " ") |
| + if strings.Contains(parked, p1Host+".env") || strings.Contains(parked, p1Host+".db.corrupt") || !strings.Contains(parked, "artifacts/"+p1Host+".db ") { |
| + t.Fatalf("parked %s", parked) |
| + } |
| + if h.read(t, p1Host).Move.SourceParkedAt == nil { |
| + t.Fatal("the finalization did not complete") |
| + } |
| +} |
| + |
| +// H7: the source's location park's horizon starts at the flip, so a final |
| +// checkpoint request signed after the stop evidence was collected, and |
| +// before the flip, is inside it; a park scoped from the stop evidence is |
| +// refused before the engine runs. |
| +func TestSourceLocationParkScopeCoversTheFinalCheckpoint(t *testing.T) { |
| + checked := time.Date(2026, 10, 7, 9, 1, 0, 0, time.UTC) |
| + flipped := checked.Add(40 * time.Second) |
| + signed := checked.Add(30 * time.Second) // the final checkpoint, after CheckedAt |
| + rec := provision.Record{Host: p1Host, Move: &provision.MoveState{ID: p1ID, FlippedAt: &flipped}} |
| + scope, err := sourceLocationParkScope(rec, hostclaim.Holder{Location: cutRoot + "/", Incarnation: "inc"}) |
| + if err != nil || scope.Location != cutRoot || !scope.EvidenceAt.Equal(flipped) || scope.RequestLimit != cutRequestLimit { |
| + t.Fatalf("scope %+v %v", scope, err) |
| + } |
| + horizon := scope.EvidenceAt.Add(park.SignatureAge + scope.RequestLimit) |
| + if latest := signed.Add(park.SignatureAge + cutRequestLimit); latest.After(horizon) { |
| + t.Fatalf("a request signed at %s can land until %s, after the horizon %s", signed, latest, horizon) |
| + } |
| + if early := checked.Add(park.SignatureAge + cutRequestLimit); !signed.Add(park.SignatureAge + cutRequestLimit).After(early) { |
| + t.Fatal("fixture: the stop evidence's horizon would have covered it too") |
| + } |
| + if _, err := sourceLocationParkScope(provision.Record{Move: &provision.MoveState{}}, hostclaim.Holder{}); err == nil { |
| + t.Fatal("a scope without the flip") |
| + } |
| + |
| + h := newFinalHarness(t) |
| + h.advanceToProbed(t) |
| + h.commitRecord(t) |
| + rec = h.read(t, p1Host) |
| + obs, err := hostclaim.Read(context.Background(), h.s, p1Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + early := hostclaim.ParkScope{Location: cutRoot, Incarnation: h.inc, EvidenceAt: rec.Move.FlippedAt.Add(-time.Minute), RequestLimit: cutRequestLimit} |
| + staging := hostclaim.ParkScope{Location: moves.ScopeLocation(p1Host, p1ID), Incarnation: h.inc, EvidenceAt: *rec.Move.FlippedAt, RequestLimit: cutRequestLimit} |
| + if _, err := hostclaim.Update(context.Background(), h.s, obs, hostclaim.Commit(p1ID, "ie", "ie-box", "uk", early, staging, obligationID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.sourcePass(t) |
| + h.untouched(t, "a horizon before the flip") |
| +} |
| + |
| +// The routing rule for the states after the flip (H5(a)): this box's |
| +// region names the role, and the row or the op's journal says it has work. |
| +func TestMovesParticipantRecord(t *testing.T) { |
| + dd := t.TempDir() |
| + owns := false |
| + cfg := moveCfg{Region: "ie", DataDir: dd, OwnsRoute: func(string) bool { return owns }} |
| + rec := func(state, from, to string) provision.Record { |
| + return provision.Record{Host: p1Host, Region: to, Move: &provision.MoveState{ID: p1ID, State: state, From: from, To: to}} |
| + } |
| + if got := movesParticipantRecord(cfg, rec(provision.MoveCommitted, "ie", "uk")); got != roleNone { |
| + t.Fatalf("no row and no journal: %q", got) |
| + } |
| + owns = true |
| + for _, c := range []struct { |
| + rec provision.Record |
| + want string |
| + }{ |
| + {rec(provision.MoveCommitted, "ie", "uk"), roleSource}, |
| + {rec(provision.MoveRollingBack, "ie", "uk"), roleSource}, |
| + {rec(provision.MoveReverting, "ie", "uk"), roleSource}, |
| + {rec(provision.MoveCommitted, "uk", "ie"), roleTarget}, |
| + {rec(provision.MoveCommitted, "fr", "uk"), roleNone}, |
| + {rec(provision.MoveLanding, "ie", "uk"), roleNone}, |
| + {rec(provision.MovePreparing, "ie", "uk"), roleNone}, |
| + {rec(provision.MoveCommitted, "ie", "ie"), roleNone}, |
| + {provision.Record{Host: p1Host, Move: &provision.MoveState{State: provision.MoveCommitted, From: "ie", To: "uk"}}, roleNone}, |
| + {provision.Record{Host: p1Host}, roleNone}, |
| + } { |
| + if got := movesParticipantRecord(cfg, c.rec); got != c.want { |
| + t.Errorf("%+v: %q, want %q", c.rec.Move, got, c.want) |
| + } |
| + } |
| + owns = false |
| + j := &moves.Journal{Host: p1Host, Op: p1ID} |
| + if err := j.Write(dd); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := movesParticipantRecord(cfg, rec(provision.MoveCommitted, "ie", "uk")); got != roleSource { |
| + t.Fatalf("the op's journal: %q", got) |
| + } |
| + if got := movesParticipantRecord(cfg, provision.Record{Host: p1Host, Move: &provision.MoveState{ID: "../x", State: provision.MoveCommitted, From: "ie", To: "uk"}}); got != roleNone { |
| + t.Fatalf("an unusable op id: %q", got) |
| + } |
| +} |
| + |
| +// Production leaves the finalization unarmed (no park engine leases until |
| +// Plan 5, ruling G17) but wires the registry's delete; an unarmed pass |
| +// changes nothing. |
| +func TestSourceParkWaitsUnarmedInProduction(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + for name, unarm := range map[string]func(*moveCfg){ |
| + "no park leases": func(c *moveCfg) { c.ParkLeases = nil }, |
| + "no park view": func(c *moveCfg) { c.ParkView = nil }, |
| + "no park authorizer": func(c *moveCfg) { c.ParkAuthorize = nil }, |
| + } { |
| + saved := h.cfg |
| + unarm(&h.cfg) |
| + if sourceFinalArmed(h.cfg) { |
| + t.Fatalf("%s: armed", name) |
| + } |
| + h.sourcePass(t) |
| + h.untouched(t, name) |
| + h.cfg = saved |
| + } |
| + var prod moveCfg |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + hib := activator.Config{Bucket: "data-ie", Region: "eu-west-1", Prefix: "instances"} |
| + armPhase1(&prod, &movesWorker{mirrors: map[string]lease.MirrorStore{}}, hib, t.TempDir(), reg, nil, nil, hostwrite.Config{}, func(string) string { return "" }) |
| + if prod.DeleteHeld == nil || prod.Guard.DataDir == "" || prod.Local.DataDir == "" || prod.Versioned == nil { |
| + t.Fatalf("production wiring: %+v", prod) |
| + } |
| + prod.Route = func(string) (*registry.Route, error) { return nil, nil } |
| + if prod.ParkLeases != nil || prod.ParkView != nil || prod.ParkAuthorize != nil || sourceFinalArmed(prod) { |
| + t.Fatal("production armed the finalization") |
| + } |
| +} |
| + |
| +// The rename helper on its own: absent candidates, a refused path, and a |
| +// destination that holds something else. |
| +func TestParkLocalArtifactsRefusals(t *testing.T) { |
| + dd := t.TempDir() |
| + j := &moves.Journal{Host: p1Host, Op: p1ID, Steps: map[string]string{}} |
| + mark := func(step, value string) error { j.Mark(step, value); return nil } |
| + p := localArtifactPark{DataDir: dd, Incarnation: "inc", Host: p1Host, Op: p1ID, Step: "s", NewID: func() string { return "r1" }} |
| + for _, bad := range []string{"../x", "/abs", ".parked/x", "hostguard", "a/../b", ""} { |
| + p.Paths = []string{bad} |
| + if _, err := parkLocalArtifacts(context.Background(), j, mark, p); err == nil { |
| + t.Errorf("%q was accepted", bad) |
| + } |
| + } |
| + if len(j.Steps) != 0 { |
| + t.Fatalf("a refused park journalled %v", j.Steps) |
| + } |
| + if err := os.WriteFile(filepath.Join(dd, "a"), []byte("a"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + p.Paths = []string{"a", "absent"} |
| + p.Fault = func(point string) error { |
| + if point == "after-rename:a" { |
| + return errCrash |
| + } |
| + return nil |
| + } |
| + if _, err := parkLocalArtifacts(context.Background(), j, mark, p); !errors.Is(err, errCrash) { |
| + t.Fatalf("crash: %v", err) |
| + } |
| + // Something else at the destination stops the park; nothing more moves. |
| + dst := filepath.Join(parkedArtifactsDir(dd, "inc", p1Host, "r1"), "a") |
| + if err := os.Rename(dst, filepath.Join(dd, "a")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(dst, []byte("other"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + p.Fault = nil |
| + if _, err := parkLocalArtifacts(context.Background(), j, mark, p); err == nil { |
| + t.Fatal("a foreign destination was accepted") |
| + } |
| + if b, _ := os.ReadFile(filepath.Join(dd, "a")); string(b) != "a" { |
| + t.Fatalf("the live artifact moved: %q", b) |
| + } |
| + os.Remove(dst) |
| + if r, err := parkLocalArtifacts(context.Background(), j, mark, p); err != nil || r != "r1" { |
| + t.Fatalf("resume: %q %v", r, err) |
| + } |
| + if _, err := os.Lstat(filepath.Join(dd, "a")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a is still live: %v", err) |
| + } |
| + // Another plan under the same step is refused. |
| + p.Paths = []string{"b"} |
| + delete(j.Steps, "s") |
| + if _, err := parkLocalArtifacts(context.Background(), j, mark, p); err == nil { |
| + t.Fatal("a plan for other paths was accepted") |
| + } |
| +} |
| + |
| +// testSourcePaths is what the tests expect the source to keep at its flat |
| +// live paths and park: the database with its marker, journal, quarantine |
| +// and litestream shadow, the files directory, <host>.env, and the two |
| +// rendered env files (fix round 1, ruling 3). |
| +func testSourcePaths() []string { |
| + db := p1Host + ".db" |
| + return []string{db, db + ".gen", db + "-wal", db + "-shm", db + ".corrupt", ".activator-" + db + "-litestream", p1Host, p1Host + ".env", |
| + p1Host + ".config.env", p1Host + "-sidecar.env"} |
| +} |
| + |
| +// sourceTestView and sourceTestAuthorize are the park view and authorizer |
| +// the tests inject for the source's location park, in place of Plan 5's |
| +// owners (fix round 1, ruling 4: production never substitutes stand-ins). |
| +// The target's region is authoritative after the commit, so every version |
| +// under the location is selected; a chore is authorized only when it is |
| +// this move's, for ie-box or ie, under the move's committed landing with |
| +// ie-box its source. |
| +func sourceTestView(_ context.Context, obs hostclaim.Observed) (park.View, error) { |
| + if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { |
| + return park.View{}, park.ErrUnsafe |
| + } |
| + location := strings.TrimSuffix(obs.Claim.Holder.Location, "/") |
| + return park.View{ClaimETag: obs.ETag, Region: "ie", AuthoritativeRegion: "uk", |
| + GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil |
| +} |
| + |
| +func sourceTestAuthorize(_ context.Context, obs hostclaim.Observed, o hostclaim.Obligation) error { |
| + c := obs.Claim |
| + if obs.Absent || c == nil || c.Op.ID != p1ID || c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseCommitted || |
| + len(c.Op.Participants) != 2 || c.Op.Participants[0] != "ie-box" || o.Op != p1ID { |
| + return park.ErrUnsafe |
| + } |
| + if (o.Kind == "park-local" && o.Box == "ie-box") || (o.Kind == "park-replica" && o.Region == "ie") { |
| + return nil |
| + } |
| + return park.ErrUnsafe |
| +} |
| + |
| +// Fix round 1, ruling 1: a crash after source_parked_at is stamped and |
| +// before the journal is parked, then the close (which clears the move, so |
| +// no record routes here any more), then a fresh source pass: the local |
| +// sweep parks the journal, because the close proves the stamp landed. |
| +func TestSourceParkJournalSurvivesTheClose(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + h.finalAt = "after-source-parked-record" |
| + h.crashSource(t) |
| + if j := h.sourceJournal(t); j.Done(finalStepParked) && j.Steps[finalStepParked] == "done" { |
| + t.Fatalf("fixture: the stamp is journalled done: %v", j.Steps) |
| + } |
| + ctx := context.Background() |
| + g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc} |
| + for _, mut := range []func(*provision.Record) error{provision.MoveOpened(g, h.clock.Now()), provision.MoveClose(g, h.clock.Now())} { |
| + if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, mut); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if rec := h.read(t, p1Host); rec.Move != nil || rec.LastMove == nil || rec.LastMove.ID != p1ID { |
| + t.Fatalf("fixture: not closed: %+v", rec) |
| + } |
| + if movesParticipantRecord(h.cfg, h.read(t, p1Host)) != roleNone { |
| + t.Fatal("fixture: a closed record still routes here") |
| + } |
| + h.freshWorker() |
| + h.sourcePass(t) |
| + if got := h.live(t); len(got) != 0 { |
| + t.Fatalf("still live after the close: %v", got) |
| + } |
| + j := h.parkedJournal(t) |
| + if j.Steps[finalStepParked] != "done" { |
| + t.Fatalf("the parked journal's stamp: %v", j.Steps) |
| + } |
| +} |
| + |
| +// The sweep never touches a journal that has not reached the stamp, nor one |
| +// whose stamp is pending while the record still carries the op unstamped: a |
| +// journal of another op (a landing's, say) under a record with no move, and |
| +// this op's journal stopped right after it journalled the stamp pending, |
| +// before the stamp's CAS. |
| +func TestSourceParkSweepLeavesUnstampedJournals(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + // Another op's journal that would park cleanly if the sweep took it: a |
| + // complete local plan and retirement, everything but the stamp step. |
| + plan, err := json.Marshal(localParkPlan{Retirement: "r0", Incarnation: h.inc}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + otherSteps := map[string]string{finalStepLocal + "-plan": string(plan), finalStepLocal: "r0", finalStepRowDeleted: "done"} |
| + other := &moves.Journal{Host: p1Host, Op: p1ID2, Steps: otherSteps} |
| + if err := other.Write(h.cfg.DataDir); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.finalAt = "after-source-parked-pending" |
| + h.crashSource(t) |
| + if j := h.sourceJournal(t); j.Steps[finalStepParked] != "pending" || h.read(t, p1Host).Move.SourceParkedAt != nil { |
| + t.Fatalf("fixture: %v", j.Steps) |
| + } |
| + h.cfg.ParkLeases = nil // unarmed: only the sweep runs |
| + h.freshWorker() |
| + h.sourcePass(t) |
| + for _, op := range []string{p1ID, p1ID2} { |
| + if !moveJournalPresent(h.cfg.DataDir, p1Host, op) { |
| + t.Fatalf("the sweep took %s's journal before its stamp", op) |
| + } |
| + } |
| + if j, err := moves.ReadJournal(h.cfg.DataDir, p1Host, p1ID2); err != nil || len(j.Steps) != len(otherSteps) { |
| + t.Fatalf("the sweep wrote into another op's journal: %v %v", j.Steps, err) |
| + } |
| +} |
| + |
| +// Fix round 1, ruling 2: a replay that finds an artifact already at its |
| +// destination (a stop after the rename, before either sync) syncs the |
| +// source's parent and every destination parent before it goes on; and the |
| +// rename offers an interruption point between the rename and the syncs. |
| +func TestParkLocalArtifactsSyncsOnReplay(t *testing.T) { |
| + dd := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(dd, "d", "e"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dd, "d", "e", "a"), []byte("a"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + j := &moves.Journal{Host: p1Host, Op: p1ID, Steps: map[string]string{}} |
| + mark := func(step, value string) error { j.Mark(step, value); return nil } |
| + var points []string |
| + p := localArtifactPark{DataDir: dd, Incarnation: "inc", Host: p1Host, Op: p1ID, Step: "s", Paths: []string{"d/e/a"}, NewID: func() string { return "r1" }, |
| + Fault: func(point string) error { |
| + points = append(points, point) |
| + if point == "after-rename-unsynced:d/e/a" { |
| + return errCrash |
| + } |
| + return nil |
| + }} |
| + if _, err := parkLocalArtifacts(context.Background(), j, mark, p); !errors.Is(err, errCrash) { |
| + t.Fatalf("no interruption between the rename and its syncs: %v (points %v)", err, points) |
| + } |
| + var synced []string |
| + saved := retireSync |
| + retireSync = func(path string) error { synced = append(synced, path); return saved(path) } |
| + t.Cleanup(func() { retireSync = saved }) |
| + p.Fault = nil |
| + if r, err := parkLocalArtifacts(context.Background(), j, mark, p); err != nil || r != "r1" { |
| + t.Fatalf("replay: %q %v", r, err) |
| + } |
| + art := parkedArtifactsDir(dd, "inc", p1Host, "r1") |
| + for _, want := range []string{filepath.Join(dd, "d", "e"), filepath.Join(art, "d", "e"), filepath.Join(art, "d"), art, filepath.Dir(art), filepath.Join(dd, ".parked")} { |
| + found := false |
| + for _, s := range synced { |
| + found = found || s == want |
| + } |
| + if !found { |
| + t.Errorf("the replay did not sync %s (synced %v)", want, synced) |
| + } |
| + } |
| +} |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index b570dd677..12f4ba48b 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -315,6 +315,10 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin |
| cfg.ReplaceTimers = heldTimersReplacer(writes, reg) |
| cfg.SetReplication = reg.SetReplication |
| cfg.SetNextDue = reg.SetNextDue |
| + // The source's finalization (movesfinal.go): a plain registry call |
| + // 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 |
| } |
| if eb != nil { |
| // A box with no delegated cgroup has no manager, and Reconstruct |
| @@ -546,6 +550,16 @@ type moveCfg struct { |
| // and the park engine's (park.Engine.After). Nil in production. |
| landFault func(point string) error |
| parkFault func(step string) error |
| + |
| + // The source's finalization (§7.9 step 3, movesfinal.go). DeleteHeld |
| + // deletes this box's row and its timers rows in one transaction, only |
| + // while op holds the row (registry.DeleteIfHoldWithTimers, ruling |
| + // H5(d)). The finalization also uses Guard, Local, ParkLeases, |
| + // ParkView, ParkAuthorize, Versioned and Route above; ParkLeases nil |
| + // leaves it unarmed in production (sourceFinalArmed). finalFault is |
| + // its test seam, nil in production. |
| + DeleteHeld func(host, op string) (bool, error) |
| + finalFault func(point string) error |
| } |
| // defaultMoveReadyDelta is the readiness bar when |
| @@ -617,6 +631,18 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error { |
| continue |
| } |
| if rec.Region != cfg.Region || (cfg.OwnsRoute != nil && !cfg.OwnsRoute(rec.Host)) { |
| + // After the flip the record's region is the target's, and |
| + // after the source's finalization deletes its row OwnsRoute |
| + // no longer holds: a participant that still has the row or |
| + // the op's journal is visited by the post-flip rule (ruling |
| + // H5(a), movesfinal.go), never by phase1MoveRound. |
| + if role := movesParticipantRecord(cfg, rec); role != roleNone { |
| + if err := w.participantRound(ctx, s, rec, role); err != nil { |
| + w.logTrouble(rec.Host, err) |
| + continue |
| + } |
| + w.trouble.Delete(rec.Host) |
| + } |
| continue |
| } |
| if !loadFleet() { |
| @@ -678,6 +704,11 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error { |
| w.evictArchives(nil) // no move into this box: no verdict to keep |
| w.retireHeldRefusals(nil) |
| } |
| + if cfg.Enabled { |
| + // The source's op journals that reached the stamp, whoever's record |
| + // no longer routes here (movesfinal.go, fix round 1). |
| + w.sweepFinalJournals(recs) |
| + } |
| w.liftFences(recs) |
| return nil |
| } |
diff --git a/internal/host/movesphase1_harness_test.go b/internal/host/movesphase1_harness_test.go| index 812f8f9c2..0aea92faf 100644 |
| --- a/internal/host/movesphase1_harness_test.go |
| +++ b/internal/host/movesphase1_harness_test.go |
| @@ -231,7 +231,9 @@ func (v *fakeVersioned) DeleteVersion(_ context.Context, bucket, key, versionID |
| if v.skipDelete != nil && v.skipDelete(key) { |
| return nil |
| } |
| - if _, ok := v.f.buckets[bucket][key]; ok && v.f.versionOf(bucket, key) == versionID { |
| + // "null" is how S3 names an object written while the bucket was not |
| + // versioning (versionID), and deleting that version removes it. |
| + if _, ok := v.f.buckets[bucket][key]; ok && v.versionID(bucket, key) == versionID { |
| delete(v.f.buckets[bucket], key) |
| } |
| return nil |
diff --git a/internal/host/movesretire.go b/internal/host/movesretire.go| new file mode 100644 |
| index 000000000..9dd91bf26 |
| --- /dev/null |
| +++ b/internal/host/movesretire.go |
| @@ -0,0 +1,337 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| +) |
| + |
| +// The moves-owned local retirement (ruling H5(b), the pattern of Plan 2's |
| +// extraction staging): artifacts the claim's inventory does not list, so |
| +// park.Local cannot retire them, renamed into the park engine's local |
| +// layout under a journalled plan, and an op's own journal directory parked |
| +// after them. The source's finalization (movesfinal.go) uses it; Plan 4's |
| +// rollback reuses it. |
| + |
| +func journalComponent(s string) bool { |
| + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| +} |
| + |
| +// localArtifactPark is one moves-owned local retirement (ruling H5(b), the |
| +// pattern of Plan 2's extraction staging): artifacts the claim's inventory |
| +// does not list, so park.Local cannot retire them, renamed into the park |
| +// engine's local layout, .parked/<incarnation>/<host>/<retirement>/artifacts/, |
| +// keeping their paths. Plan 4's rollback reuses it. |
| +type localArtifactPark struct { |
| + DataDir, Incarnation, Host, Op string |
| + // Paths are the candidates, relative to DataDir. Absent ones are recorded |
| + // as absent and never touched. |
| + Paths []string |
| + // Step is the journal step that records the retirement id once every |
| + // rename is durable; Step+"-plan" records the plan before the first one. |
| + Step string |
| + NewID func() string |
| + // Fault is the test seam, after the plan and after each rename. |
| + Fault func(point string) error |
| +} |
| + |
| +// localParkPlan is what is journalled before the first rename: the |
| +// retirement id and every candidate's identity ("dev:ino"; "" when absent). |
| +type localParkPlan struct { |
| + Retirement string `json:"retirement"` |
| + Incarnation string `json:"incarnation"` |
| + Items []localParkItem `json:"items"` |
| +} |
| + |
| +type localParkItem struct { |
| + Path string `json:"path"` |
| + ID string `json:"id,omitempty"` |
| +} |
| + |
| +// localParkReceipt sits beside artifacts/ in the retirement, so a later |
| +// reader can find a moves-owned retirement by its op (D6, I57). |
| +type localParkReceipt struct { |
| + V int `json:"v"` |
| + Op string `json:"op"` |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + Retirement string `json:"retirement"` |
| + Paths []string `json:"paths"` |
| +} |
| + |
| +const localParkReceiptName = "moves-retirement.json" |
| + |
| +// retireSync is the fsync every moves-owned retirement makes durable with |
| +// (syncPath); a variable so a test can see which directories were synced. |
| +var retireSync = syncPath |
| + |
| +// parkedEventDir and parkedArtifactsDir are the engine's local layout |
| +// (park.Local's parkLocal): one directory per retirement event. |
| +func parkedEventDir(dataDir, incarnation, host, retirement string) string { |
| + return filepath.Join(dataDir, ".parked", incarnation, host, retirement) |
| +} |
| + |
| +func parkedArtifactsDir(dataDir, incarnation, host, retirement string) string { |
| + return filepath.Join(parkedEventDir(dataDir, incarnation, host, retirement), "artifacts") |
| +} |
| + |
| +func journalledLocalPlan(j *moves.Journal, step string) (localParkPlan, error) { |
| + var plan localParkPlan |
| + raw, ok := j.Steps[step+"-plan"] |
| + if !ok { |
| + return plan, fmt.Errorf("the journal has no %s plan", step) |
| + } |
| + if err := json.Unmarshal([]byte(raw), &plan); err != nil { |
| + return plan, fmt.Errorf("the journal's %s plan: %w", step, err) |
| + } |
| + if !journalComponent(plan.Retirement) || !journalComponent(plan.Incarnation) { |
| + return plan, fmt.Errorf("the journal's %s plan names an unusable retirement", step) |
| + } |
| + return plan, nil |
| +} |
| + |
| +// artifactPathOK is a relative path under the data directory that is not |
| +// the guard's or the park's own. |
| +func artifactPathOK(p string) bool { |
| + if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || strings.ContainsAny(p, "\\\x00") { |
| + return false |
| + } |
| + for _, c := range strings.Split(p, "/") { |
| + if !journalComponent(c) { |
| + return false |
| + } |
| + } |
| + first, _, _ := strings.Cut(p, "/") |
| + return first != ".parked" && first != "hostguard" |
| +} |
| + |
| +// parkLocalArtifacts runs one moves-owned local retirement and returns its |
| +// retirement id. In order: the plan (a fresh retirement id and each |
| +// candidate's identity) is journalled before anything moves; the retirement's |
| +// directories and its receipt are made; each recorded artifact is renamed to |
| +// the same path under artifacts/ and its source's parent and every |
| +// destination directory up to the data directory synced; then Step records |
| +// the retirement id. A replay resumes from the journal: |
| +// - an artifact already at its destination with its identity is synced as |
| +// a fresh rename is (a stop between the rename and its syncs leaves the |
| +// rename undurable, fix round 1), and is then done; |
| +// - one still at its live path with its identity is renamed; |
| +// - one recorded absent, or whose live path now holds something with |
| +// another identity, is not this park's: it is left where it is, never |
| +// moved or removed; |
| +// - a destination that holds anything else is an error, and nothing more |
| +// is moved. |
| +func parkLocalArtifacts(ctx context.Context, j *moves.Journal, mark func(step, value string) error, p localArtifactPark) (string, error) { |
| + if r, ok := j.Steps[p.Step]; ok { |
| + return r, nil |
| + } |
| + if p.DataDir == "" || !journalComponent(p.Incarnation) || !journalComponent(p.Host) || p.Op == "" || p.Step == "" || p.NewID == nil { |
| + return "", errors.New("a local park names its data directory, incarnation, host, op and step") |
| + } |
| + for _, path := range p.Paths { |
| + if !artifactPathOK(path) { |
| + return "", fmt.Errorf("a local park cannot retire %q", path) |
| + } |
| + } |
| + fault := func(point string) error { |
| + if p.Fault != nil { |
| + return p.Fault(point) |
| + } |
| + return nil |
| + } |
| + plan, err := journalledLocalPlan(j, p.Step) |
| + if err != nil { |
| + if _, planned := j.Steps[p.Step+"-plan"]; planned { |
| + return "", err |
| + } |
| + if plan, err = p.plan(); err != nil { |
| + return "", err |
| + } |
| + body, err := json.Marshal(plan) |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := mark(p.Step+"-plan", string(body)); err != nil { |
| + return "", err |
| + } |
| + } |
| + if plan.Incarnation != p.Incarnation || len(plan.Items) != len(p.Paths) { |
| + return "", fmt.Errorf("the journal's %s plan is not this park's", p.Step) |
| + } |
| + for i, it := range plan.Items { |
| + if it.Path != p.Paths[i] { |
| + return "", fmt.Errorf("the journal's %s plan is not this park's", p.Step) |
| + } |
| + } |
| + dest := parkedArtifactsDir(p.DataDir, p.Incarnation, p.Host, plan.Retirement) |
| + if err := mkdirSynced(p.DataDir, dest); err != nil { |
| + return "", err |
| + } |
| + receipt, err := json.Marshal(localParkReceipt{V: 1, Op: p.Op, Host: p.Host, Incarnation: p.Incarnation, Retirement: plan.Retirement, Paths: p.Paths}) |
| + if err != nil { |
| + return "", err |
| + } |
| + event := parkedEventDir(p.DataDir, p.Incarnation, p.Host, plan.Retirement) |
| + if err := writeFileAtomic(filepath.Join(event, localParkReceiptName), receipt, 0o640); err != nil { |
| + return "", err |
| + } |
| + for _, it := range plan.Items { |
| + if err := ctx.Err(); err != nil { |
| + return "", err |
| + } |
| + src, dst := filepath.Join(p.DataDir, it.Path), filepath.Join(dest, it.Path) |
| + if it.ID == "" { |
| + if _, err := os.Lstat(src); err == nil { |
| + log.Printf("moves: %s: %s appeared after the park recorded it absent; it is not this park's and is left where it is", p.Host, it.Path) |
| + } |
| + continue |
| + } |
| + if done, err := sameIdentity(dst, it.ID); err != nil { |
| + return "", err |
| + } else if done { |
| + if err := syncRename(p.DataDir, src, dst); err != nil { |
| + return "", err |
| + } |
| + if err := fault("after-rename:" + it.Path); err != nil { |
| + return "", err |
| + } |
| + continue |
| + } |
| + if _, err := os.Lstat(dst); err == nil { |
| + return "", fmt.Errorf("%s holds what this park did not put there", dst) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return "", err |
| + } |
| + ours, err := sameIdentity(src, it.ID) |
| + if err != nil { |
| + return "", err |
| + } |
| + if !ours { |
| + log.Printf("moves: %s: %s is no longer the artifact the park recorded; what is there is left where it is", p.Host, it.Path) |
| + continue |
| + } |
| + if err := mkdirSynced(p.DataDir, filepath.Dir(dst)); err != nil { |
| + return "", err |
| + } |
| + if err := os.Rename(src, dst); err != nil { |
| + return "", fmt.Errorf("park %s: %w", it.Path, err) |
| + } |
| + if err := fault("after-rename-unsynced:" + it.Path); err != nil { |
| + return "", err |
| + } |
| + if err := syncRename(p.DataDir, src, dst); err != nil { |
| + return "", err |
| + } |
| + if err := fault("after-rename:" + it.Path); err != nil { |
| + return "", err |
| + } |
| + } |
| + if err := retireSync(dest); err != nil { |
| + return "", err |
| + } |
| + if err := mark(p.Step, plan.Retirement); err != nil { |
| + return "", err |
| + } |
| + log.Printf("moves: %s: the local artifacts of %s are parked in %s", p.Host, p.Op, event) |
| + return plan.Retirement, nil |
| +} |
| + |
| +// plan records a fresh retirement id and each candidate's identity. The |
| +// retirement's directory must not exist yet: the id is this park's alone. |
| +func (p localArtifactPark) plan() (localParkPlan, error) { |
| + plan := localParkPlan{Retirement: p.NewID(), Incarnation: p.Incarnation} |
| + if !journalComponent(plan.Retirement) { |
| + return plan, errors.New("no retirement id") |
| + } |
| + if _, err := os.Lstat(parkedEventDir(p.DataDir, p.Incarnation, p.Host, plan.Retirement)); err == nil { |
| + return plan, fmt.Errorf("retirement %s already exists", plan.Retirement) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return plan, err |
| + } |
| + for _, path := range p.Paths { |
| + id, err := pathIdentity(filepath.Join(p.DataDir, path)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + id, err = "", nil |
| + } |
| + if err != nil { |
| + return plan, err |
| + } |
| + plan.Items = append(plan.Items, localParkItem{Path: path, ID: id}) |
| + } |
| + return plan, nil |
| +} |
| + |
| +// mkdirSynced makes dir (under root) and syncs it and every directory above |
| +// it up to root. Every call syncs the whole chain, not only the directories |
| +// it made: a replay after a stop between MkdirAll and the syncs finds the |
| +// directories there and must still make their entries durable. |
| +func mkdirSynced(root, dir string) error { |
| + if err := os.MkdirAll(dir, 0o770); err != nil { |
| + return err |
| + } |
| + return syncChain(root, dir) |
| +} |
| + |
| +// syncChain syncs dir and every directory above it up to and including root. |
| +// dir must be root or under it. |
| +func syncChain(root, dir string) error { |
| + root, dir = filepath.Clean(root), filepath.Clean(dir) |
| + if rel, err := filepath.Rel(root, dir); err != nil || rel == ".." || strings.HasPrefix(rel, "../") { |
| + return fmt.Errorf("%s is not under %s", dir, root) |
| + } |
| + for d := dir; ; d = filepath.Dir(d) { |
| + if err := retireSync(d); err != nil { |
| + return err |
| + } |
| + if d == root || d == filepath.Dir(d) { |
| + return nil |
| + } |
| + } |
| +} |
| + |
| +// syncRename makes a rename from src to dst durable: the source's parent, |
| +// and every destination directory up to root. |
| +func syncRename(root, src, dst string) error { |
| + if err := retireSync(filepath.Dir(src)); err != nil { |
| + return err |
| + } |
| + return syncChain(root, filepath.Dir(dst)) |
| +} |
| + |
| +// parkOpJournal renames the op's journal directory into the retirement's |
| +// artifacts, at .moves/<host>/<op>. The directory is the op's own (its name |
| +// is the op id). Absent with the destination there is a replay after the |
| +// rename, which is synced again and done; absent with no destination is |
| +// nothing to park; a destination already there beside the journal is |
| +// refused. |
| +func parkOpJournal(dataDir, incarnation, host, op, retirement string) error { |
| + src := moves.JournalDir(dataDir, host, op) |
| + dst := filepath.Join(parkedArtifactsDir(dataDir, incarnation, host, retirement), ".moves", host, op) |
| + if _, err := os.Lstat(src); errors.Is(err, os.ErrNotExist) { |
| + if _, err := os.Lstat(dst); err == nil { |
| + return syncRename(dataDir, src, dst) |
| + } |
| + return nil |
| + } else if err != nil { |
| + return err |
| + } |
| + if _, err := os.Lstat(dst); err == nil { |
| + return fmt.Errorf("%s already holds a journal", dst) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + if err := mkdirSynced(dataDir, filepath.Dir(dst)); err != nil { |
| + return err |
| + } |
| + if err := os.Rename(src, dst); err != nil { |
| + return err |
| + } |
| + return syncRename(dataDir, src, dst) |
| +} |
diff --git a/internal/moves/startcheck.go b/internal/moves/startcheck.go| new file mode 100644 |
| index 000000000..c7586cdf8 |
| --- /dev/null |
| +++ b/internal/moves/startcheck.go |
| @@ -0,0 +1,162 @@ |
| +package moves |
| + |
| +import ( |
| + "errors" |
| + |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +// The start check (spec §5.1, INV-4, J2-R2-8): before every spawn the three |
| +// markers a generation leaves on a box are compared with the host claim. A |
| +// marker that merely exists proves nothing; the claim says which generation |
| +// this box may run. |
| +// |
| +// The three markers have three formats (Paul's ruling on H11): |
| +// |
| +// - generation.json is genselect.Marker, read from the box's own location; |
| +// absent is the origin. |
| +// - .files-gen is the JSON {op, incarnation} (FilesGen); absent is "no |
| +// files directory yet". |
| +// - <dbPath>.gen is the plain generation name hostbox.WriteGenerationMarker |
| +// writes and ColdRestore rewrites; "" is absent. It carries no incarnation |
| +// and no location: the claim's holder and the restore vouch for those. |
| +// |
| +// Refusals are typed and carry member-safe reasons, never marker contents. |
| +var ( |
| + // ErrStartMarker: generation.json does not name a generation the claim |
| + // expects, or names another incarnation. |
| + ErrStartMarker = errors.New("this box holds another generation of the instance") |
| + // ErrStartFiles: .files-gen does not name a generation and incarnation |
| + // the claim expects. |
| + ErrStartFiles = errors.New("the instance's files belong to another generation") |
| + // ErrStartDB: the database's marker does not name the claim's generation. |
| + ErrStartDB = errors.New("the instance's database belongs to another generation") |
| +) |
| + |
| +// FilesGen is the .files-gen body (§5.1): the op and the incarnation. |
| +type FilesGen struct { |
| + Op string `json:"op"` |
| + Incarnation string `json:"incarnation"` |
| +} |
| + |
| +// originOr names the origin generation the way the claim's admissions do. |
| +func originOr(g string) string { |
| + if g == "" { |
| + return hostclaim.RootGeneration |
| + } |
| + return g |
| +} |
| + |
| +// coldAdmission is the newest admission into generation g that is not a warm |
| +// self-link (F from F, the warm keep genselect's unfinished skips). Its |
| +// predecessor is where g came from. |
| +func coldAdmission(h *hostclaim.Holder, g string) (hostclaim.AdmissionRecord, bool) { |
| + for i := len(h.Admissions) - 1; i >= 0; i-- { |
| + a := h.Admissions[i] |
| + if originOr(a.Generation) != g || a.Mode == "warm" { |
| + continue |
| + } |
| + return a, true |
| + } |
| + return hostclaim.AdmissionRecord{}, false |
| +} |
| + |
| +// markerGenerations is the set of generations generation.json may name for |
| +// this holder: holder.generation, as the admission's CAS left it, and the |
| +// lagging ones. A marker is published once per admission, at Replicate, so |
| +// after a restarted attempt (a fresh generation restored from the prepared |
| +// one) or a run of admissions that published nothing, the marker still names |
| +// an earlier generation. Walking holder.admissions from the newest, skipping |
| +// warm self-links, each cold admission into an accepted generation adds its |
| +// predecessor. The origin is hostclaim.RootGeneration here. |
| +func markerGenerations(h *hostclaim.Holder) map[string]bool { |
| + cur := originOr(h.Generation) |
| + ok := map[string]bool{cur: true} |
| + for { |
| + a, found := coldAdmission(h, cur) |
| + if !found { |
| + return ok |
| + } |
| + next := originOr(a.Predecessor) |
| + if ok[next] { // damaged record: predecessors that loop |
| + return ok |
| + } |
| + ok[next] = true |
| + cur = next |
| + } |
| +} |
| + |
| +// filesGenerationOK reports whether .files-gen may name g: exactly |
| +// holder.generation, or the cold predecessor of holder.generation (the |
| +// prepared generation a restarted attempt was restored from, whose files |
| +// are still the ones installed). Nothing further back counts: files from |
| +// before the preparation are stale. |
| +func filesGenerationOK(h *hostclaim.Holder, g string) bool { |
| + if g == "" || g == hostclaim.RootGeneration { |
| + return false |
| + } |
| + if g == h.Generation { |
| + return true |
| + } |
| + a, found := coldAdmission(h, originOr(h.Generation)) |
| + return found && a.Predecessor == g |
| +} |
| + |
| +// StartCheck is the §5.1 start check against the claim, run after the |
| +// admission CAS and before every spawn. |
| +// |
| +// 1. marker (generation.json; nil is the origin) names a generation the |
| +// claim expects (holder.generation or, lagging, an earlier link of the |
| +// admission chain) and the holder's incarnation. |
| +// 2. filesGen, when present, names holder.generation or its cold |
| +// predecessor (a restarted attempt's files still name the prepared |
| +// generation; nothing older) and the holder's incarnation. |
| +// 3. dbGen, when not "", is exactly holder.generation: the restore rewrites |
| +// it, so it is current after every restore. A warm database that fails |
| +// here is from an earlier stay; the caller parks it and restores cold. |
| +// |
| +// A claim with no holder expects no generation, so every start is refused |
| +// with ErrStartMarker. |
| +func StartCheck(c *hostclaim.Claim, marker *genselect.Marker, filesGen *FilesGen, dbGen string) error { |
| + if c == nil || c.Holder == nil { |
| + return ErrStartMarker |
| + } |
| + h := c.Holder |
| + want := markerGenerations(h) |
| + if marker == nil { |
| + if !want[hostclaim.RootGeneration] { |
| + return ErrStartMarker |
| + } |
| + } else if marker.Op == "" || marker.Op == hostclaim.RootGeneration || |
| + !want[marker.Op] || marker.Incarnation != h.Incarnation { |
| + return ErrStartMarker |
| + } |
| + if filesGen != nil { |
| + if !filesGenerationOK(h, filesGen.Op) || filesGen.Incarnation != h.Incarnation { |
| + return ErrStartFiles |
| + } |
| + } |
| + if dbGen != "" && dbGen != h.Generation { |
| + return ErrStartDB |
| + } |
| + return nil |
| +} |
| + |
| +// PreparedCheck validates a landing probe's inputs before admission (Plan 3 |
| +// Task 5 step 3): the files marker and the database marker both exist and |
| +// name the prepared generation (the move id), and the files marker names the |
| +// holder's incarnation. The database marker is the plain name (H11); the |
| +// location is vouched for by the holder. |
| +func PreparedCheck(prepared string, holder hostclaim.Holder, filesGen *FilesGen, dbGen string) error { |
| + if prepared == "" || prepared == hostclaim.RootGeneration { |
| + return ErrStartFiles |
| + } |
| + if filesGen == nil || filesGen.Op != prepared || filesGen.Incarnation != holder.Incarnation { |
| + return ErrStartFiles |
| + } |
| + if dbGen != prepared { |
| + return ErrStartDB |
| + } |
| + return nil |
| +} |
diff --git a/internal/moves/startcheck_test.go b/internal/moves/startcheck_test.go| new file mode 100644 |
| index 000000000..c99281ce0 |
| --- /dev/null |
| +++ b/internal/moves/startcheck_test.go |
| @@ -0,0 +1,232 @@ |
| +package moves |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +func startClaim(gen string, adm ...hostclaim.AdmissionRecord) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: &hostclaim.Holder{ |
| + Incarnation: "inc-1", Location: "loc/", Generation: gen, Admissions: adm, |
| + }} |
| +} |
| + |
| +func startMarker(op string) *genselect.Marker { |
| + return &genselect.Marker{Op: op, Incarnation: "inc-1"} |
| +} |
| + |
| +func TestStartCheckOriginEverywhere(t *testing.T) { |
| + if err := StartCheck(startClaim(""), nil, nil, ""); err != nil { |
| + t.Fatalf("origin everywhere: %v", err) |
| + } |
| +} |
| + |
| +func TestStartCheckMarkerNamesTheHoldersGeneration(t *testing.T) { |
| + c := startClaim("g2", hostclaim.AdmissionRecord{Generation: "g2", Predecessor: "g1"}) |
| + fg := &FilesGen{Op: "g2", Incarnation: "inc-1"} |
| + if err := StartCheck(c, startMarker("g2"), fg, "g2"); err != nil { |
| + t.Fatalf("all three name the holder's generation: %v", err) |
| + } |
| +} |
| + |
| +func TestStartCheckLaggingMarker(t *testing.T) { |
| + // The latest admission replaced g1 with g2 and has published nothing. |
| + c := startClaim("g2", hostclaim.AdmissionRecord{Generation: "g2", Predecessor: "g1"}) |
| + if err := StartCheck(c, startMarker("g1"), nil, ""); err != nil { |
| + t.Fatalf("marker naming the latest admission's predecessor: %v", err) |
| + } |
| + // A restarted probe: the fresh generation F restored from the prepared M; |
| + // the files still name M. |
| + c = startClaim("F", hostclaim.AdmissionRecord{Generation: "M", Predecessor: "g0"}, |
| + hostclaim.AdmissionRecord{Generation: "F", Predecessor: "M"}) |
| + fg := &FilesGen{Op: "M", Incarnation: "inc-1"} |
| + if err := StartCheck(c, startMarker("M"), fg, "F"); err != nil { |
| + t.Fatalf("restarted attempt: %v", err) |
| + } |
| +} |
| + |
| +func TestStartCheckChainOfAdmissionsWithoutMarkers(t *testing.T) { |
| + c := startClaim("g3", |
| + hostclaim.AdmissionRecord{Generation: "g2", Predecessor: "g1"}, |
| + hostclaim.AdmissionRecord{Generation: "g3", Predecessor: "g2"}) |
| + for _, op := range []string{"g3", "g2", "g1"} { |
| + if err := StartCheck(c, startMarker(op), nil, ""); err != nil { |
| + t.Errorf("marker %s through the chain: %v", op, err) |
| + } |
| + } |
| + if err := StartCheck(c, startMarker("g0"), nil, ""); !errors.Is(err, ErrStartMarker) { |
| + t.Errorf("marker beyond the chain = %v, want ErrStartMarker", err) |
| + } |
| + // The chain is by predecessor link, not by position: an admission whose |
| + // generation the chain never reaches adds nothing. |
| + c = startClaim("g3", |
| + hostclaim.AdmissionRecord{Generation: "x2", Predecessor: "x1"}, |
| + hostclaim.AdmissionRecord{Generation: "g3", Predecessor: "g2"}) |
| + if err := StartCheck(c, startMarker("x1"), nil, ""); !errors.Is(err, ErrStartMarker) { |
| + t.Errorf("marker off the chain = %v, want ErrStartMarker", err) |
| + } |
| +} |
| + |
| +func TestStartCheckChainTerminatesOnACycle(t *testing.T) { |
| + // A damaged record whose predecessors loop must still end. |
| + c := startClaim("g2", |
| + hostclaim.AdmissionRecord{Generation: "g1", Predecessor: "g2"}, |
| + hostclaim.AdmissionRecord{Generation: "g2", Predecessor: "g1"}) |
| + if err := StartCheck(c, startMarker("g1"), nil, ""); err != nil { |
| + t.Errorf("marker in the cycle: %v", err) |
| + } |
| + if err := StartCheck(c, startMarker("g9"), nil, ""); !errors.Is(err, ErrStartMarker) { |
| + t.Errorf("marker outside the cycle = %v, want ErrStartMarker", err) |
| + } |
| +} |
| + |
| +func TestStartCheckRejectsFilesBeforePreparedGeneration(t *testing.T) { |
| + // M was prepared over S; F is the restarted attempt restored from M. |
| + // Files from before the preparation are stale, even though S is an |
| + // ancestor of the holder's generation. |
| + c := startClaim("F", |
| + hostclaim.AdmissionRecord{Generation: "M", Predecessor: "S", Mode: "cold"}, |
| + hostclaim.AdmissionRecord{Generation: "F", Predecessor: "M", Mode: "cold"}) |
| + if err := StartCheck(c, startMarker("M"), &FilesGen{Op: "S", Incarnation: "inc-1"}, "F"); !errors.Is(err, ErrStartFiles) { |
| + t.Errorf("files naming S = %v, want ErrStartFiles", err) |
| + } |
| + if err := StartCheck(c, startMarker("M"), &FilesGen{Op: "M", Incarnation: "inc-1"}, "F"); err != nil { |
| + t.Errorf("files naming M: %v", err) |
| + } |
| + if err := StartCheck(c, startMarker("M"), &FilesGen{Op: "F", Incarnation: "inc-1"}, "F"); err != nil { |
| + t.Errorf("files naming F: %v", err) |
| + } |
| +} |
| + |
| +func TestStartCheckLaggingFilesAfterWarmAdmission(t *testing.T) { |
| + // Cold F from M, then a warm re-admission of F (F from F): the files |
| + // still name M. |
| + c := startClaim("F", |
| + hostclaim.AdmissionRecord{Generation: "F", Predecessor: "M", Mode: "cold"}, |
| + hostclaim.AdmissionRecord{Generation: "F", Predecessor: "F", Mode: "warm"}) |
| + if err := StartCheck(c, startMarker("F"), &FilesGen{Op: "M", Incarnation: "inc-1"}, "F"); err != nil { |
| + t.Errorf("files naming M after a warm admission: %v", err) |
| + } |
| + if err := StartCheck(c, startMarker("F"), &FilesGen{Op: "other", Incarnation: "inc-1"}, "F"); !errors.Is(err, ErrStartFiles) { |
| + t.Errorf("files naming another generation = %v, want ErrStartFiles", err) |
| + } |
| +} |
| + |
| +func TestStartCheckLaggingMarkerAfterWarmAdmission(t *testing.T) { |
| + c := startClaim("F", |
| + hostclaim.AdmissionRecord{Generation: "F", Predecessor: "M", Mode: "cold"}, |
| + hostclaim.AdmissionRecord{Generation: "F", Predecessor: "F", Mode: "warm"}) |
| + if err := StartCheck(c, startMarker("M"), nil, ""); err != nil { |
| + t.Errorf("marker naming M after a warm admission: %v", err) |
| + } |
| +} |
| + |
| +func TestStartCheckOriginAfterAFirstAdmission(t *testing.T) { |
| + // The first admission left the origin: its predecessor is the origin, so |
| + // an absent marker is lagging, not wrong. |
| + c := startClaim("g1", hostclaim.AdmissionRecord{Generation: "g1"}) |
| + if err := StartCheck(c, nil, nil, ""); err != nil { |
| + t.Fatalf("absent marker, predecessor the origin: %v", err) |
| + } |
| + // With no admission reaching the origin, absent is refused. |
| + c = startClaim("g2", hostclaim.AdmissionRecord{Generation: "g2", Predecessor: "g1"}) |
| + if err := StartCheck(c, nil, nil, ""); !errors.Is(err, ErrStartMarker) { |
| + t.Errorf("absent marker with a published generation = %v, want ErrStartMarker", err) |
| + } |
| +} |
| + |
| +func TestStartCheckMarkerRefusals(t *testing.T) { |
| + c := startClaim("g2", hostclaim.AdmissionRecord{Generation: "g2", Predecessor: "g1"}) |
| + for name, m := range map[string]*genselect.Marker{ |
| + "unrelated generation": startMarker("other"), |
| + "another incarnation": {Op: "g2", Incarnation: "inc-2"}, |
| + "empty op": {Incarnation: "inc-1"}, |
| + "root as an op": startMarker(hostclaim.RootGeneration), |
| + } { |
| + if err := StartCheck(c, m, nil, ""); !errors.Is(err, ErrStartMarker) { |
| + t.Errorf("%s = %v, want ErrStartMarker", name, err) |
| + } |
| + } |
| + if err := StartCheck(&hostclaim.Claim{}, nil, nil, ""); !errors.Is(err, ErrStartMarker) { |
| + t.Errorf("no holder = %v, want ErrStartMarker", err) |
| + } |
| + if err := StartCheck(nil, nil, nil, ""); !errors.Is(err, ErrStartMarker) { |
| + t.Errorf("nil claim = %v, want ErrStartMarker", err) |
| + } |
| +} |
| + |
| +func TestStartCheckFilesGen(t *testing.T) { |
| + c := startClaim("g2", hostclaim.AdmissionRecord{Generation: "g2", Predecessor: "g1"}) |
| + m := startMarker("g2") |
| + for name, fg := range map[string]*FilesGen{ |
| + "another op": {Op: "other", Incarnation: "inc-1"}, |
| + "another incarnation": {Op: "g2", Incarnation: "inc-2"}, |
| + "empty": {}, |
| + } { |
| + if err := StartCheck(c, m, fg, ""); !errors.Is(err, ErrStartFiles) { |
| + t.Errorf("%s = %v, want ErrStartFiles", name, err) |
| + } |
| + } |
| + // The origin's own files carry no .files-gen, and a stray one is refused. |
| + if err := StartCheck(startClaim(""), nil, &FilesGen{Op: "g1", Incarnation: "inc-1"}, ""); !errors.Is(err, ErrStartFiles) { |
| + t.Errorf("files-gen on an origin claim = %v, want ErrStartFiles", err) |
| + } |
| +} |
| + |
| +func TestStartCheckDBGen(t *testing.T) { |
| + c := startClaim("g2", hostclaim.AdmissionRecord{Generation: "g2", Predecessor: "g1"}) |
| + m := startMarker("g2") |
| + // The .gen is plain and exact: a lagging name is a warm database from an |
| + // earlier stay, even though the chain would admit it elsewhere. |
| + for _, g := range []string{"other", "g1", hostclaim.RootGeneration} { |
| + if err := StartCheck(c, m, nil, g); !errors.Is(err, ErrStartDB) { |
| + t.Errorf(".gen %q = %v, want ErrStartDB", g, err) |
| + } |
| + } |
| + if err := StartCheck(c, m, nil, "g2"); err != nil { |
| + t.Errorf(".gen g2: %v", err) |
| + } |
| + if err := StartCheck(startClaim(""), nil, nil, "g1"); !errors.Is(err, ErrStartDB) { |
| + t.Errorf(".gen on an origin claim = %v, want ErrStartDB", err) |
| + } |
| +} |
| + |
| +func TestStartCheckReportsTheFirstFailingMarker(t *testing.T) { |
| + c := startClaim("g2") |
| + err := StartCheck(c, startMarker("other"), &FilesGen{Op: "other", Incarnation: "inc-1"}, "other") |
| + if !errors.Is(err, ErrStartMarker) || errors.Is(err, ErrStartFiles) || errors.Is(err, ErrStartDB) { |
| + t.Errorf("err = %v, want only ErrStartMarker", err) |
| + } |
| +} |
| + |
| +func TestPreparedCheck(t *testing.T) { |
| + h := hostclaim.Holder{Incarnation: "inc-1", Location: "loc/", Generation: "g1"} |
| + ok := &FilesGen{Op: "mv-1", Incarnation: "inc-1"} |
| + if err := PreparedCheck("mv-1", h, ok, "mv-1"); err != nil { |
| + t.Fatalf("both name the prepared generation: %v", err) |
| + } |
| + cases := []struct { |
| + name string |
| + prepared string |
| + fg *FilesGen |
| + db string |
| + want error |
| + }{ |
| + {"files-gen absent", "mv-1", nil, "mv-1", ErrStartFiles}, |
| + {"files-gen another op", "mv-1", &FilesGen{Op: "mv-2", Incarnation: "inc-1"}, "mv-1", ErrStartFiles}, |
| + {"files-gen another incarnation", "mv-1", &FilesGen{Op: "mv-1", Incarnation: "inc-2"}, "mv-1", ErrStartFiles}, |
| + {"gen absent", "mv-1", ok, "", ErrStartDB}, |
| + {"gen another op", "mv-1", ok, "mv-2", ErrStartDB}, |
| + {"gen the holder's generation", "mv-1", ok, "g1", ErrStartDB}, |
| + {"no prepared generation", "", &FilesGen{}, "", ErrStartFiles}, |
| + {"root as prepared", hostclaim.RootGeneration, &FilesGen{Op: hostclaim.RootGeneration, Incarnation: "inc-1"}, hostclaim.RootGeneration, ErrStartFiles}, |
| + } |
| + for _, tc := range cases { |
| + if err := PreparedCheck(tc.prepared, h, tc.fg, tc.db); !errors.Is(err, tc.want) { |
| + t.Errorf("%s = %v, want %v", tc.name, err, tc.want) |
| + } |
| + } |
| +} |
diff --git a/internal/registry/hold_test.go b/internal/registry/hold_test.go| index 6a4422504..473a98762 100644 |
| --- a/internal/registry/hold_test.go |
| +++ b/internal/registry/hold_test.go |
| @@ -185,6 +185,70 @@ func TestDeleteIfHold(t *testing.T) { |
| } |
| } |
| +// The source's finalization (moves spec §7.9 step 3, ruling H5(d)): the row |
| +// and the host's timer rows go in one transaction, and only while the op |
| +// holds the row. Any refusal leaves both, and the neighbour's timers stay. |
| +func TestDeleteIfHoldWithTimers(t *testing.T) { |
| + r := holdFixture(t, "h") |
| + if err := r.Upsert(Route{Host: "n", Kind: "instance", Socket: "/n", App: "a", Hibernate: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tm := range []Timer{{Host: "h", Name: "nightly", Kind: "cron", Spec: "0 3 * * *", NextAt: 1000}, |
| + {Host: "h", Name: "hourly", Kind: "cron", Spec: "0 * * * *", NextAt: 2000}, |
| + {Host: "n", Name: "nightly", Kind: "cron", Spec: "0 3 * * *", NextAt: 1000}} { |
| + if err := r.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + timers := func(host string) int { |
| + t.Helper() |
| + got, err := r.TimersFor(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return len(got) |
| + } |
| + // Not held, held by another op, an empty op: nothing goes. |
| + for _, c := range []struct{ name, hold, op string }{{"unheld", "", holdOpA}, {"another op", holdOpB, holdOpA}, {"empty op", holdOpA, ""}} { |
| + if c.hold != "" { |
| + if err := r.SetHold("h", "moving", c.hold); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if ok, err := r.DeleteIfHoldWithTimers("h", c.op); err != nil || ok { |
| + t.Fatalf("%s: %v %v", c.name, ok, err) |
| + } |
| + if rt, _ := r.Get("h"); rt == nil || timers("h") != 2 { |
| + t.Fatalf("%s: the row or its timers changed: %+v, %d timers", c.name, rt, timers("h")) |
| + } |
| + if c.hold != "" { |
| + if ok, err := r.ReleaseHold("h", c.hold); err != nil || !ok { |
| + t.Fatal(ok, err) |
| + } |
| + } |
| + } |
| + if err := r.SetHold("h", "moving", holdOpA); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := r.DeleteIfHoldWithTimers("h", holdOpA); err != nil || !ok { |
| + t.Fatalf("delete under the holding op: %v %v", ok, err) |
| + } |
| + if rt, _ := r.Get("h"); rt != nil || timers("h") != 0 { |
| + t.Fatalf("after the delete: %+v, %d timers", rt, timers("h")) |
| + } |
| + if rt, _ := r.Get("n"); rt == nil || timers("n") != 1 { |
| + t.Fatalf("the neighbour changed: %+v, %d timers", rt, timers("n")) |
| + } |
| + // A replay finds no row held by the op and deletes nothing, timers |
| + // written since included. |
| + if err := r.UpsertTimer(Timer{Host: "h", Name: "late", Kind: "cron", Spec: "0 3 * * *", NextAt: 1000}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := r.DeleteIfHoldWithTimers("h", holdOpA); err != nil || ok || timers("h") != 1 { |
| + t.Fatalf("replay: %v %v, %d timers", ok, err, timers("h")) |
| + } |
| +} |
| + |
| func TestReleaseHoldEmptyOpMatchesNothing(t *testing.T) { |
| r := holdFixture(t, "h") |
| if ok, err := r.ReleaseHold("h", ""); err != nil || ok { |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 73b5481f4..a4b410bf2 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1172,6 +1172,36 @@ func (r *Registry) DeleteIfHold(host, op string) (bool, error) { |
| return n > 0, err |
| } |
| +// DeleteIfHoldWithTimers is the source's finalization (moves spec §7.9 step |
| +// 3, ruling H5(d)): host's row and every timer row of host are deleted in one |
| +// transaction, and only while op holds the row. false means nothing matched |
| +// and nothing was deleted, timers included, so a timers clear never runs |
| +// ahead of the row it belongs to or lands on a successor's rows. |
| +func (r *Registry) DeleteIfHoldWithTimers(host, op string) (bool, error) { |
| + if op == "" { |
| + return false, nil |
| + } |
| + tx, err := r.sql.Begin() |
| + if err != nil { |
| + return false, err |
| + } |
| + defer tx.Rollback() |
| + res, err := tx.Exec(`DELETE FROM routes WHERE host = ? AND hold_op = ?`, host, op) |
| + if err != nil { |
| + return false, err |
| + } |
| + if n, err := res.RowsAffected(); err != nil || n == 0 { |
| + return false, err |
| + } |
| + if _, err := tx.Exec(`DELETE FROM timers WHERE host = ?`, host); err != nil { |
| + return false, err |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return false, err |
| + } |
| + return true, nil |
| +} |
| + |
| // HoldRows returns every row held by an operation (a non-empty hold_op), ordered by |
| // host. Over a fresh connection, like List, so a reader in another process |
| // sees the latest commit. |