moves: the move log and member-stream events (§13, Plan 6 Task 3)
- internal/moves/log.go: LogEvent, LogKeyFor (replacing the counter-keyed LogKey), WriteLog (create-only; the first writer of a transition creates it, ErrCASConflict is success with nothing created, L27) and ReadLog (ordered by At). - internal/host/movesevents.go: every box write of a move is wrapped in observedMove, which keeps the record the write found; logTransition names the transition from it and the record written: <state>-<phase>, probe_attempt-<n>, stuck-<epoch> and retry-<epoch> on claim Plan 10's RecoveryRetryEpoch (L11), and the terminal outcome. observeMove logs the console doors' request, manual cut, abort and retry, and a declared loss, the first time a box's pass sees them; logRevPhase a revert's claim steps. A worker hands each identity to LogEvent once. - moveCfg.LogEvent is widened to (ctx, store, record, moves.LogEvent) (L10); production wires moveLogWriter with the agent's rollout sink as moveCfg.Events: the object create-only, and the member event only for the writer that created it (L9): M-C1, M-C5, M-C6, M-C7, M-C8, M-C9, and MV-N380 (pending) for stuck, with no operator command. - Old event names map once (rollback -> rolling_back, target-released -> released, source-stuck/target-stuck -> stuck, source-opened -> rev_opened, ...); the rollback, recovery and revert harnesses follow, and the rollback harness retries through MoveRetryRecovery. - B8 census: 17 writers' digests (each gains observedMove and its logTransition) and moves.WriteLog as a nonrecord Put (L21).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
28 files changed,
+967
−96
approved_moves_copy_test.go+5 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+2 −1internal/console/apiinstancemove_test.go+1 −1internal/host/agent.go+1 −1internal/host/movesabort.go+6 −1internal/host/movescut.go+4 −1internal/host/movesevents.go+342 −0internal/host/movesevents_test.go+268 −0internal/host/movesfinal.go+5 −1internal/host/movesinstall.go+8 −2internal/host/movesland.go+4 −1internal/host/moveslandcommit.go+9 −2internal/host/movesparks.go+4 −2internal/host/movespass.go+14 −5internal/host/movespass_test.go+2 −2internal/host/movesphase1.go+11 −2internal/host/movesphase1_test.go+1 −1internal/host/movesrecover.go+16 −11internal/host/movesrecover_test.go+5 −3internal/host/movesrevert.go+7 −5internal/host/movesrevert_test.go+3 −1internal/host/movesrollback.go+14 −24internal/host/movesrollback_test.go+13 −7internal/moves/log.go+99 −0internal/moves/log_test.go+97 −0internal/moves/staging.go+5 −4internal/moves/staging_test.go+1 −1internal/provision/plan9_b8_census_test.go+20 −17
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 960f9a71b..8b8716aa6 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -80,6 +80,7 @@ var movesCopyIDs = []string{ |
| "MV-N357", "MV-N358", "MV-N359", "MV-N360", "MV-N361", "MV-N362", "MV-N363", "MV-N364", |
| "MV-N365", "MV-N366", "MV-N367", "MV-N368", "MV-N369", "MV-N370", "MV-N371", |
| "MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378", "MV-N379", |
| + "MV-N380", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
| @@ -587,6 +588,7 @@ var movesScanFiles = []string{ |
| "internal/host/movesfloorgate.go", |
| "internal/host/movesfloorgate_linux.go", |
| "internal/host/movesfloorgate_other.go", |
| + "internal/host/movesevents.go", |
| "internal/host/movesabort.go", |
| "internal/host/movesarchive.go", |
| "internal/host/movescut.go", |
| @@ -759,6 +761,9 @@ var movesReturnedSentences = map[string]map[string]bool{ |
| "internal/host/movesreport.go": {"t1Below": true, "t3Differs": true, "t3Shadowed": true, "t4Failed": true, |
| "t6NoRoom": true, "t8Count": true, "t12NotQuiet": true, "replicaReason": true}, |
| "internal/host/movesrollback.go": {"stuckPage": true}, |
| + // memberLine is what an app's members read for a move event (Plan 6 |
| + // Task 3, preflight L9). |
| + "internal/host/movesevents.go": {"memberLine": true}, |
| // moveDeleteRefusal is M-C19, which the delete door answers (Task 2b). |
| "internal/console/apiinstancemove.go": {"moveDeleteRefusal": true}, |
| } |
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 0f32c72ba..cd800c89c 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -284,7 +284,7 @@ Columns: id, file, text, status. |
| ## Plan 6 |
| -(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N371 and Task 1b MV-N372 to MV-N379 (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved. Task 1b speaks M-C18 verbatim from move.go and MV-N11 from the resolve door of a console without the host claim, so both are approved) |
| +(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N371 and Task 1b MV-N372 to MV-N379, Task 3 MV-N380 (the member line for stuck, preflight L9) (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved. Task 1b speaks M-C18 verbatim from move.go and MV-N11 from the resolve door of a console without the host claim, so both are approved) |
| | id | file | text | status | |
| |---|---|---|---| |
| @@ -319,6 +319,7 @@ Columns: id, file, text, status. |
| | MV-N377 | move.go | The console asks to confirm a loss carlos move does not know. Use carlos ops host resolve. | pending | |
| | MV-N378 | move.go | carlos move resolve takes --retry or --declare-lost, not both. | pending | |
| | MV-N379 | move.go | The move changed since it was read. Nothing was declared. Run the command again. | pending | |
| +| MV-N380 | internal/host/movesevents.go | <host> could not start in <to> or in <from>. It is offline. An operator has been paged. | pending | |
| ## Review of 2026-10-09 |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index 968918638..dec908463 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -1898,7 +1898,7 @@ func TestMoveDetailLeaksNothing(t *testing.T) { |
| putJSON(moves.TargetReportKey(prefix), dst) |
| putJSON(srcKey, map[string]any{"v": 1, "role": "source", "note": "evidence-body-canary-source", "env": env["DATABASE_URL"]}) |
| putJSON(dstKey, map[string]any{"v": 1, "role": "target", "note": "evidence-body-canary-target"}) |
| - logKeys := []string{moves.LogKey(prefix, 1, "requested"), moves.LogKey(prefix, 2, "rollback")} |
| + logKeys := []string{moves.LogKeyFor(prefix, "requested"), moves.LogKeyFor(prefix, "rolling_back")} |
| for _, k := range logKeys { |
| putJSON(k, map[string]any{"event": "x", "note": "log-body-canary", "actor": "alice@keymail.dev"}) |
| } |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 7457ed444..7fce00b5b 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -447,7 +447,7 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| } else { |
| eb.Mirror = m |
| } |
| - armMovesPass(*cfg, dd, reg, svc, eb, claims.Writes) |
| + armMovesPass(*cfg, dd, reg, svc, eb, claims.Writes, rolloutSink) |
| // The startup sweep (design §6.7): a leftover instance unit from a |
| // previous agent process, one this box's registry no longer holds |
| // a live wake for, is stopped before this process starts spawning |
diff --git a/internal/host/movesabort.go b/internal/host/movesabort.go| index 7f1ecd3e1..31fed78b2 100644 |
| --- a/internal/host/movesabort.go |
| +++ b/internal/host/movesabort.go |
| @@ -143,7 +143,12 @@ func (w *movesWorker) abortSource(ctx context.Context, s release.Store, rec prov |
| return nil |
| } |
| g := provision.MoveGuard{ID: id, Incarnation: rec.Move.Incarnation} |
| - if _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MoveGuardedAbortComplete(g, cfg.Now())); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, observedMove(&pre, provision.MoveGuardedAbortComplete(g, cfg.Now()))) |
| + if err == nil { |
| + w.logTransition(ctx, s, pre, *after) |
| + } |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: abort of %s: the record moved on (%v)", rec.Host, id, err) |
| return nil |
diff --git a/internal/host/movescut.go b/internal/host/movescut.go| index 6605bb021..daeceb755 100644 |
| --- a/internal/host/movescut.go |
| +++ b/internal/host/movescut.go |
| @@ -158,13 +158,16 @@ func (c *cutRun) step(ctx context.Context) (bool, error) { |
| // someone else's decision (an abort, a console write): logged, and the |
| // pass ends without error. |
| func (c *cutRun) cutTransition(ctx context.Context, what string, mut func(*provision.Record) error) (bool, error) { |
| - if _, err := provision.SetRecordMove(ctx, c.s, c.rec.Account, c.rec.App, c.rec.Host, mut); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, c.s, c.rec.Account, c.rec.App, c.rec.Host, observedMove(&pre, mut)) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: %s of %s refused (%v); the record moved on", c.rec.Host, what, c.g.ID, err) |
| return false, nil |
| } |
| return false, fmt.Errorf("%s: %w", what, err) |
| } |
| + c.w.logTransition(ctx, c.s, pre, *after) |
| return true, nil |
| } |
diff --git a/internal/host/movesevents.go b/internal/host/movesevents.go| new file mode 100644 |
| index 000000000..0ef9d2166 |
| --- /dev/null |
| +++ b/internal/host/movesevents.go |
| @@ -0,0 +1,342 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "log" |
| + "math" |
| + "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The move log and the member stream (spec §13, Plan 6 Task 3). |
| +// |
| +// Every record write a box makes for a move goes through observedMove, which |
| +// keeps the record as the write found it; logTransition names what the write |
| +// did, from that record and the one written: one event, one identity |
| +// (moves.LogEvent). Transitions another writer made (the console's request, |
| +// manual cut, abort and retry; a declared loss) are seen by the box's own |
| +// pass (observeMove) and named the same way. The identity is the log object's |
| +// key, written create-only behind moveCfg.LogEvent (moveLogWriter in |
| +// production), and only the writer that created the object tells the app's |
| +// members (preflight L9, L27). A worker also remembers what it logged, so a |
| +// pass that sees the same transition again does not even try. |
| +// |
| +// Recovery probe attempts are not logged: their counter is the recovery's own |
| +// attempt journal, not the record (Plan 6 Task 3 report). |
| + |
| +// moveLogEntry is what one transition is in the log. |
| +type moveLogEntry struct { |
| + op, event, identity string |
| + fields map[string]string |
| +} |
| + |
| +// movePhaseOf is the phase a record's state is at: the cut's, the landing's, |
| +// or (rolling back) the target's and the source's. |
| +func movePhaseOf(m *provision.MoveState) (phase, target, source string) { |
| + switch m.State { |
| + case provision.MoveCutting: |
| + return m.CutPhase, "", "" |
| + case provision.MoveLanding: |
| + return m.LandingPhase, "", "" |
| + case provision.MoveRollingBack: |
| + if m.Rollback != nil { |
| + return "", m.Rollback.TargetPhase, m.Rollback.SourcePhase |
| + } |
| + } |
| + return "", "", "" |
| +} |
| + |
| +func moveRegions(m *provision.MoveState) map[string]string { |
| + return map[string]string{"from": m.From, "to": m.To} |
| +} |
| + |
| +// moveTransition names the transition from before to after, one record write: |
| +// before as the write found it, after as written. ok is false when the write |
| +// changed nothing the log records (a pin, recovery inputs, a replay). |
| +func moveTransition(before, after provision.Record) (moveLogEntry, bool) { |
| + bm, am := before.Move, after.Move |
| + if am == nil { |
| + // A close clears the move and leaves its outcome (D11). |
| + if bm == nil || after.LastMove == nil || after.LastMove.ID != bm.ID || bm.ID == "" { |
| + return moveLogEntry{}, false |
| + } |
| + f := moveRegions(bm) |
| + f["offline_s"] = strconv.FormatFloat(after.LastMove.OfflineS, 'f', -1, 64) |
| + return moveLogEntry{op: bm.ID, event: after.LastMove.Outcome, identity: after.LastMove.Outcome, fields: f}, true |
| + } |
| + if am.ID == "" || (bm != nil && bm.ID != am.ID) { |
| + return moveLogEntry{}, false |
| + } |
| + e := moveLogEntry{op: am.ID, fields: moveRegions(am)} |
| + if bm == nil { |
| + bm = &provision.MoveState{} |
| + } |
| + switch { |
| + case am.State == provision.MoveStuck: |
| + if bm.State == provision.MoveStuck { |
| + return moveLogEntry{}, false |
| + } |
| + e.event, e.identity = "stuck", "stuck-"+strconv.FormatUint(am.RecoveryRetryEpoch, 10) |
| + return e, true |
| + case am.State == provision.MoveLanding && am.Landing != nil && (bm.Landing == nil || am.Landing.Attempts > bm.Landing.Attempts) && am.Landing.Attempts > 0: |
| + e.event, e.identity = "probe_attempt", "probe_attempt-"+strconv.Itoa(am.Landing.Attempts) |
| + return e, true |
| + case bm.State == provision.MoveCutting && am.State == provision.MoveLanding: |
| + // The flip is the record's one write from cutting to landing. |
| + e.event, e.identity = "flipped", "cutting-flipped" |
| + return e, true |
| + case am.State == provision.MoveCommitted && bm.State == provision.MoveCommitted: |
| + switch { |
| + case am.SourceParkedAt != nil && bm.SourceParkedAt == nil: |
| + e.event, e.identity = "source_parked", "committed-source_parked" |
| + case am.OpenedAt != nil && bm.OpenedAt == nil: |
| + e.event, e.identity = "opened", "committed-opened" |
| + if am.CutAt != nil { |
| + e.fields["offline_s"] = strconv.FormatInt(int64(am.OpenedAt.Sub(*am.CutAt).Seconds()), 10) |
| + } |
| + default: |
| + return moveLogEntry{}, false |
| + } |
| + return e, true |
| + case am.State == provision.MoveReverting && bm.State == provision.MoveReverting: |
| + if am.ReopenedAt == nil || bm.ReopenedAt != nil { |
| + return moveLogEntry{}, false |
| + } |
| + e.event, e.identity = "rev_opened", "reverting-opened" |
| + return e, true |
| + } |
| + phase, target, source := movePhaseOf(am) |
| + bphase, btarget, bsource := movePhaseOf(bm) |
| + if am.State == bm.State { |
| + switch { |
| + case am.State == provision.MoveRollingBack && target != btarget: |
| + e.event, e.identity = target, "rolling_back-target-"+target |
| + if target == provision.RbTargetLost { |
| + e.event = "declared_lost" |
| + } |
| + case am.State == provision.MoveRollingBack && source != bsource: |
| + e.event, e.identity = source, "rolling_back-source-"+source |
| + if source == provision.RbSourceOpened { |
| + e.fields["offline_s"] = moveOfflineSince(am) |
| + } |
| + case phase != bphase && phase != "": |
| + e.event, e.identity = phase, am.State+"-"+phase |
| + if am.State == provision.MoveCutting && phase == provision.CutRequested { |
| + e.event = "cut_requested" |
| + } |
| + default: |
| + return moveLogEntry{}, false |
| + } |
| + return e, true |
| + } |
| + // A new state. |
| + switch am.State { |
| + case provision.MovePreparing: |
| + e.event, e.identity = "requested", "requested" |
| + case provision.MoveCutting: |
| + e.event, e.identity = phase, "cutting-"+phase |
| + if phase == provision.CutRequested || phase == "" { |
| + e.event, e.identity = "cut_requested", "cutting-requested" |
| + } |
| + case provision.MoveLanding: |
| + e.event, e.identity = phase, "landing-"+phase |
| + case provision.MoveRollingBack: |
| + e.event, e.identity = "rolling_back", "rolling_back" |
| + default: |
| + // ready, committed, aborting, reverting, relocating: the state is the |
| + // event. |
| + e.event, e.identity = am.State, am.State |
| + } |
| + return e, true |
| +} |
| + |
| +// moveOfflineSince is the seconds from the cut to the source's reopen, for a |
| +// rollback's open, as the close counts it. |
| +func moveOfflineSince(m *provision.MoveState) string { |
| + if m.CutAt == nil || m.Rollback == nil || m.Rollback.OpenedAt == nil { |
| + return "" |
| + } |
| + return strconv.FormatInt(int64(m.Rollback.OpenedAt.Sub(*m.CutAt).Seconds()), 10) |
| +} |
| + |
| +// copyMove is a deep enough copy of a move for moveTransition: the fields it |
| +// compares, safe from a mutation that edits the journal in place. |
| +func copyMove(m *provision.MoveState) *provision.MoveState { |
| + if m == nil { |
| + return nil |
| + } |
| + c := *m |
| + if m.Rollback != nil { |
| + rb := *m.Rollback |
| + c.Rollback = &rb |
| + } |
| + if m.Landing != nil { |
| + l := *m.Landing |
| + c.Landing = &l |
| + } |
| + return &c |
| +} |
| + |
| +// observedMove wraps a record mutation so *pre keeps the record as the write |
| +// found it: the transition's before, for the log. |
| +func observedMove(pre *provision.Record, mut func(*provision.Record) error) func(*provision.Record) error { |
| + return func(rec *provision.Record) error { |
| + *pre = *rec |
| + pre.Move = copyMove(rec.Move) |
| + return mut(rec) |
| + } |
| +} |
| + |
| +// logTransition logs the transition one write made (moveTransition). |
| +func (w *movesWorker) logTransition(ctx context.Context, s release.Store, before, after provision.Record) { |
| + if e, ok := moveTransition(before, after); ok { |
| + w.record(ctx, s, after, e) |
| + } |
| +} |
| + |
| +// observeMove logs the transitions another writer made that this box's pass |
| +// sees: the console's request, manual cut, abort and retry, and a declared |
| +// loss. Each is named as if it were this box's write from a record with no |
| +// move (or, for a retry, by its epoch), so it has the same identity whoever |
| +// logs it. |
| +func (w *movesWorker) observeMove(ctx context.Context, s release.Store, rec provision.Record) { |
| + m := rec.Move |
| + if m == nil || m.ID == "" { |
| + return |
| + } |
| + switch { |
| + case m.State == provision.MovePreparing, m.State == provision.MoveAborting, |
| + m.State == provision.MoveCutting && m.CutPhase == provision.CutRequested: |
| + w.logTransition(ctx, s, provision.Record{}, rec) |
| + case m.State == provision.MoveRollingBack && m.Rollback != nil && m.Rollback.TargetPhase == provision.RbTargetLost: |
| + before := provision.Record{Move: copyMove(m)} |
| + before.Move.Rollback.TargetPhase = "" |
| + w.logTransition(ctx, s, before, rec) |
| + } |
| + if m.RecoveryRetryEpoch > 0 && m.State != provision.MoveStuck { |
| + w.record(ctx, s, rec, moveLogEntry{op: m.ID, event: "retry", identity: "retry-" + strconv.FormatUint(m.RecoveryRetryEpoch, 10), fields: moveRegions(m)}) |
| + } |
| +} |
| + |
| +// logRevPhase logs a revert's step, which is the claim's rev_phase, not the |
| +// record's: reverting-<phase>, event rev_<phase>. |
| +func (w *movesWorker) logRevPhase(ctx context.Context, s release.Store, rec provision.Record, before, after hostclaim.Observed) { |
| + phase := func(o hostclaim.Observed) string { |
| + if o.Claim == nil { |
| + return "" |
| + } |
| + return o.Claim.Op.Journal[hostclaim.JournalRevPhase] |
| + } |
| + p := phase(after) |
| + if p == "" || p == phase(before) || rec.Move == nil || rec.Move.ID == "" { |
| + return |
| + } |
| + w.record(ctx, s, rec, moveLogEntry{op: rec.Move.ID, event: "rev_" + p, identity: "reverting-" + p, fields: moveRegions(rec.Move)}) |
| +} |
| + |
| +// record hands one entry to the LogEvent hook, once per worker: a pass that |
| +// sees the same transition again does not write it again. With no hook (a |
| +// test's worker) the event is a log line. |
| +func (w *movesWorker) record(ctx context.Context, s release.Store, rec provision.Record, e moveLogEntry) { |
| + key := rec.Host + "/" + e.op + "/" + e.identity |
| + if _, seen := w.logged.LoadOrStore(key, true); seen { |
| + return |
| + } |
| + ev := moves.LogEvent{V: 1, Op: e.op, Host: rec.Host, Event: e.event, Identity: e.identity, Actor: w.cfg.Box, Fields: e.fields} |
| + if w.cfg.Now != nil { |
| + ev.At = w.cfg.Now().UTC() |
| + } |
| + if f := w.cfg.LogEvent; f != nil { |
| + f(ctx, s, rec, ev) |
| + return |
| + } |
| + log.Printf("moves: %s: %s %s (%s)", rec.Host, e.op, e.event, e.identity) |
| +} |
| + |
| +// moveLogWriter is the production LogEvent: the object, create-only, under |
| +// the move's report prefix in the deployment bucket, and the member event |
| +// when this writer created it. A failed write is logged; the next pass that |
| +// sees the transition does not try again (the worker remembers it), so a lost |
| +// object is a gap in the log, never a second event. |
| +func moveLogWriter(sink activator.EventSink) func(context.Context, release.Store, provision.Record, moves.LogEvent) { |
| + return func(ctx context.Context, s release.Store, rec provision.Record, ev moves.LogEvent) { |
| + if s == nil { |
| + return |
| + } |
| + created, err := moves.WriteLog(ctx, s, moves.ReportPrefix(rec.Account, rec.App, rec.Host, ev.Op), ev) |
| + if err != nil { |
| + log.Printf("moves: %s: the log of %s at %s: %v", rec.Host, ev.Op, ev.Identity, err) |
| + return |
| + } |
| + if !created || sink == nil { |
| + return |
| + } |
| + if line, ok := memberLine(ev, rec); ok { |
| + sink.Event(rec.Account, rec.App, rec.Host, memberLevel(ev.Event), line) |
| + } |
| + } |
| +} |
| + |
| +// memberLine is what the app's members read for an event (preflight L9: M-C1, |
| +// M-C5, M-C6, M-C7, M-C8, M-C9, and for stuck a line with no operator command |
| +// in it, MV-N380). ok is false for an event members are not told of, and for |
| +// one whose regions the record names only as the home's "". Its level is |
| +// memberLevel's. |
| +func memberLine(ev moves.LogEvent, rec provision.Record) (line string, ok bool) { |
| + host, from, to := rec.Host, ev.Fields["from"], ev.Fields["to"] |
| + if from == "" || to == "" { |
| + return "", false |
| + } |
| + switch ev.Event { |
| + case "requested": |
| + return fmt.Sprintf("Moving %s from %s to %s.", host, from, to), true |
| + case "admitted": |
| + if rec.Move == nil || rec.Move.CutBudgetS <= 0 { |
| + return "", false |
| + } |
| + return fmt.Sprintf("%s is ready. Starting the cut. %s will be offline for about %s.", to, host, memberEstimate(rec.Move.CutBudgetS)), true |
| + case "flipped": |
| + return fmt.Sprintf("Stopped in %s. Starting in %s.", from, to), true |
| + case "done": |
| + offline, err := strconv.ParseFloat(ev.Fields["offline_s"], 64) |
| + if err != nil { |
| + return "", false |
| + } |
| + return fmt.Sprintf("Moved. %s is served from %s. It was offline for %d seconds.", host, to, int(math.Round(offline))), true |
| + case "rolling_back": |
| + if rec.Move == nil || rec.Move.Rollback == nil || rec.Move.Rollback.Reason == "" { |
| + return "", false |
| + } |
| + return fmt.Sprintf("%s could not start %s: %s. Moving it back to %s.", to, host, rec.Move.Rollback.Reason, from), true |
| + case "rolled_back": |
| + return fmt.Sprintf("%s is back in %s and serving. Nothing was lost.", host, from), true |
| + case "stuck": |
| + return fmt.Sprintf("%s could not start in %s or in %s. It is offline. An operator has been paged.", host, to, from), true |
| + } |
| + return "", false |
| +} |
| + |
| +// memberLevel is the level of an event's member line. |
| +func memberLevel(event string) string { |
| + switch event { |
| + case "rolling_back", "rolled_back": |
| + return "warn" |
| + case "stuck": |
| + return "error" |
| + } |
| + return "info" |
| +} |
| + |
| +// memberEstimate renders a cut budget for M-C5, as the CLI does. |
| +func memberEstimate(seconds int) string { |
| + if seconds < 90 { |
| + return fmt.Sprintf("%d seconds", seconds) |
| + } |
| + return fmt.Sprintf("%d minutes", (seconds+59)/60) |
| +} |
diff --git a/internal/host/movesevents_test.go b/internal/host/movesevents_test.go| new file mode 100644 |
| index 000000000..72540ec9f |
| --- /dev/null |
| +++ b/internal/host/movesevents_test.go |
| @@ -0,0 +1,268 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const evMoveID = "mv_abcdefghijklmnopqrstuvwxyz" |
| + |
| +// evRec is a record whose move is in the given state, edited by mut. |
| +func evRec(state string, mut func(*provision.MoveState)) provision.Record { |
| + m := &provision.MoveState{ID: evMoveID, Incarnation: strings.Repeat("ab", 32), From: "ie", To: "uk", Cut: "auto", State: state, RequestedAt: time.Unix(1_800_000_000, 0).UTC()} |
| + if mut != nil { |
| + mut(m) |
| + } |
| + return provision.Record{Account: "bab", App: "titogo", Host: "acme.go.tito.io", Region: "ie", Move: m} |
| +} |
| + |
| +func evAt(sec int64) *time.Time { |
| + t := time.Unix(1_800_000_000+sec, 0).UTC() |
| + return &t |
| +} |
| + |
| +// The events a full move that ends done goes through, each record the one a |
| +// writer leaves, in order. The console's request is seen by the source's |
| +// pass (requested); every later record is a box writer's. |
| +func doneFlow() []provision.Record { |
| + cut := func(phase string) provision.Record { |
| + return evRec(provision.MoveCutting, func(m *provision.MoveState) { |
| + m.CutPhase, m.CutAt, m.CutBudgetS = phase, evAt(10), 45 |
| + }) |
| + } |
| + land := func(phase string, attempts int) provision.Record { |
| + r := evRec(provision.MoveLanding, func(m *provision.MoveState) { |
| + m.CutPhase, m.CutAt, m.CutBudgetS, m.FlippedAt, m.LandingPhase = provision.CutFlipped, evAt(10), 45, evAt(20), phase |
| + if attempts > 0 { |
| + m.Landing = &provision.MoveLandingJournal{Attempts: attempts} |
| + } |
| + }) |
| + r.Region = "uk" |
| + return r |
| + } |
| + committed := func(mut func(*provision.MoveState)) provision.Record { |
| + r := evRec(provision.MoveCommitted, func(m *provision.MoveState) { |
| + m.CutPhase, m.CutAt, m.FlippedAt, m.CommittedAt = provision.CutFlipped, evAt(10), evAt(20), evAt(40) |
| + m.Landing = &provision.MoveLandingJournal{Attempts: 1} |
| + if mut != nil { |
| + mut(m) |
| + } |
| + }) |
| + r.Region = "uk" |
| + return r |
| + } |
| + closed := provision.Record{Account: "bab", App: "titogo", Host: "acme.go.tito.io", Region: "uk", |
| + LastMove: &provision.MoveOutcome{ID: evMoveID, Outcome: "done", At: *evAt(60), OfflineS: 41.2}} |
| + return []provision.Record{ |
| + evRec(provision.MovePreparing, nil), |
| + evRec(provision.MoveReady, nil), |
| + cut(provision.CutRequested), |
| + cut(provision.CutAdmitted), cut(provision.CutStopping), cut(provision.CutStopped), cut(provision.CutStaged), cut(provision.CutArchived), |
| + land(provision.LandChecking, 0), |
| + land(provision.LandParking, 0), land(provision.LandBuilding, 0), land(provision.LandPublished, 0), land(provision.LandLocal, 0), |
| + land(provision.LandConfigured, 0), land(provision.LandRestored, 0), land(provision.LandProbing, 0), land(provision.LandProbing, 1), |
| + land(provision.LandProbed, 1), |
| + committed(nil), |
| + committed(func(m *provision.MoveState) { m.OpenedAt = evAt(51) }), |
| + committed(func(m *provision.MoveState) { m.OpenedAt, m.SourceParkedAt = evAt(51), evAt(55) }), |
| + closed, |
| + } |
| +} |
| + |
| +var doneFlowEvents = []string{ |
| + "requested", "ready", "cut_requested", "admitted", "stopping", "stopped", "staged", "archived", "flipped", |
| + "parking", "building", "published", "local", "configured", "restored", "probing", "probe_attempt", "probed", |
| + "committed", "opened", "source_parked", "done", |
| +} |
| + |
| +// eventWorker is a worker whose LogEvent hook records every event it is |
| +// handed. |
| +func eventWorker(got *[]moves.LogEvent) *movesWorker { |
| + w := &movesWorker{mirrors: map[string]lease.MirrorStore{}} |
| + w.cfg.Box = "box-test" |
| + w.cfg.Now = func() time.Time { return time.Unix(1_800_000_100, 0).UTC() } |
| + w.cfg.LogEvent = func(_ context.Context, _ release.Store, _ provision.Record, ev moves.LogEvent) { |
| + *got = append(*got, ev) |
| + } |
| + return w |
| +} |
| + |
| +// Review Focus 3: across a full done flow, every transition gives one event, |
| +// in order, with its identity; the record seen again (a status poll, a pass |
| +// that changes nothing, a replay of the same write) gives none. |
| +func TestEveryTransitionEmitsOneEvent(t *testing.T) { |
| + ctx := context.Background() |
| + var got []moves.LogEvent |
| + w := eventWorker(&got) |
| + flow := doneFlow() |
| + w.observeMove(ctx, nil, flow[0]) |
| + for i := 1; i < len(flow); i++ { |
| + w.logTransition(ctx, nil, flow[i-1], flow[i]) |
| + // A status poll, a pass that changes nothing, a replayed write. |
| + w.observeMove(ctx, nil, flow[i]) |
| + w.logTransition(ctx, nil, flow[i], flow[i]) |
| + w.logTransition(ctx, nil, flow[i-1], flow[i]) |
| + } |
| + var events []string |
| + for _, ev := range got { |
| + events = append(events, ev.Event) |
| + if ev.Op != evMoveID || ev.Host != "acme.go.tito.io" || ev.Actor != "box-test" || ev.V != 1 || ev.At.IsZero() { |
| + t.Errorf("event %+v", ev) |
| + } |
| + } |
| + if strings.Join(events, " ") != strings.Join(doneFlowEvents, " ") { |
| + t.Fatalf("events\n got %v\nwant %v", events, doneFlowEvents) |
| + } |
| + ids := map[string]bool{} |
| + for _, ev := range got { |
| + if ids[ev.Identity] { |
| + t.Fatalf("identity %q twice", ev.Identity) |
| + } |
| + ids[ev.Identity] = true |
| + } |
| + for _, want := range []string{"requested", "ready", "cutting-requested", "cutting-stopped", "cutting-flipped", "landing-probing", "probe_attempt-1", "committed", "committed-opened", "done"} { |
| + if !ids[want] { |
| + t.Errorf("no event with identity %q in %v", want, ids) |
| + } |
| + } |
| + if f := got[len(got)-1].Fields; f["offline_s"] != "41.2" { |
| + t.Errorf("done carries %v, want offline_s", f) |
| + } |
| + if f := got[19].Fields; got[19].Event != "opened" || f["offline_s"] != "41" { |
| + t.Errorf("opened carries %v, want offline_s from cut_at to the open", f) |
| + } |
| +} |
| + |
| +// A rollback, a stuck and a retry: the rollback's phases are named for the |
| +// half that moved; stuck and retry are keyed by the recovery retry epoch |
| +// (claim Plan 10's RecoveryRetryEpoch, preflight L11). |
| +func TestRollbackStuckAndRetryEvents(t *testing.T) { |
| + ctx := context.Background() |
| + var got []moves.LogEvent |
| + w := eventWorker(&got) |
| + rb := func(target, source string, mut func(*provision.MoveState)) provision.Record { |
| + return evRec(provision.MoveRollingBack, func(m *provision.MoveState) { |
| + m.CutAt = evAt(10) |
| + m.Rollback = &provision.MoveRollback{ID: "rb_x", Reason: provision.RollbackLandingDeadline, TargetPhase: target, SourcePhase: source} |
| + if mut != nil { |
| + mut(m) |
| + } |
| + }) |
| + } |
| + stuck := func(epoch uint64) provision.Record { |
| + return evRec(provision.MoveStuck, func(m *provision.MoveState) { |
| + m.CutAt = evAt(10) |
| + m.Rollback = &provision.MoveRollback{ID: "rb_x", TargetPhase: provision.RbTargetCleaned, SourcePhase: provision.RbSourceVerified, StuckAt: provision.RbSourceVerified} |
| + m.StuckReason, m.RecoveryRetryEpoch = provision.StuckBaselineMismatch, epoch |
| + }) |
| + } |
| + landing := evRec(provision.MoveLanding, func(m *provision.MoveState) { m.LandingPhase = provision.LandProbing }) |
| + seq := []provision.Record{ |
| + landing, |
| + rb(provision.RbTargetReleasing, provision.RbSourceWaiting, nil), |
| + rb(provision.RbTargetReleased, provision.RbSourceWaiting, nil), |
| + rb(provision.RbTargetReleased, provision.RbSourceClaimed, nil), |
| + rb(provision.RbTargetCleaned, provision.RbSourceVerified, nil), |
| + stuck(0), |
| + } |
| + for i := 1; i < len(seq); i++ { |
| + w.logTransition(ctx, nil, seq[i-1], seq[i]) |
| + } |
| + // The operator's retry (the console's write, seen by the box) advances |
| + // the epoch and leaves stuck; the second stuck is a new entry. |
| + retried := rb(provision.RbTargetCleaned, provision.RbSourceVerified, func(m *provision.MoveState) { m.RecoveryRetryEpoch = 1 }) |
| + w.observeMove(ctx, nil, retried) |
| + w.observeMove(ctx, nil, retried) |
| + w.logTransition(ctx, nil, retried, stuck(1)) |
| + w.logTransition(ctx, nil, stuck(1), provision.Record{Account: "bab", App: "titogo", Host: "acme.go.tito.io", Region: "ie", |
| + LastMove: &provision.MoveOutcome{ID: evMoveID, Outcome: "rolled_back", At: *evAt(90), OfflineS: 80}}) |
| + var events, ids []string |
| + for _, ev := range got { |
| + events = append(events, ev.Event) |
| + ids = append(ids, ev.Identity) |
| + } |
| + want := "rolling_back released claimed cleaned stuck retry stuck rolled_back" |
| + if strings.Join(events, " ") != want { |
| + t.Fatalf("events %v, want %s", events, want) |
| + } |
| + wantIDs := "rolling_back rolling_back-target-released rolling_back-source-claimed rolling_back-target-cleaned stuck-0 retry-1 stuck-1 rolled_back" |
| + if strings.Join(ids, " ") != wantIDs { |
| + t.Fatalf("identities %v, want %s", ids, wantIDs) |
| + } |
| +} |
| + |
| +type memberSink struct{ events []string } |
| + |
| +func (s *memberSink) App(acct, app, host, line string) {} |
| +func (s *memberSink) Event(acct, app, host, level, msg string) { |
| + s.events = append(s.events, acct+"/"+app+"/"+host+" "+level+" "+msg) |
| +} |
| + |
| +// Preflight L9 and L27: the production LogEvent writes the object |
| +// create-only, and only the writer that created it tells members; a second |
| +// writer, or a replay, does neither. Steps members do not need (stopping) |
| +// are logged and not shown to them. |
| +func TestMoveLogWriterTellsMembersOnce(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + sink := &memberSink{} |
| + write := moveLogWriter(sink) |
| + flow := doneFlow() |
| + ev := func(event, identity string, fields map[string]string) moves.LogEvent { |
| + return moves.LogEvent{V: 1, Op: evMoveID, Host: "acme.go.tito.io", Event: event, Identity: identity, Actor: "box-uk", At: time.Now().UTC(), Fields: fields} |
| + } |
| + done := flow[len(flow)-1] |
| + write(ctx, s, done, ev("done", "done", map[string]string{"offline_s": "41.2", "from": "ie", "to": "uk"})) |
| + write(ctx, s, done, ev("done", "done", map[string]string{"offline_s": "41.2", "from": "ie", "to": "uk"})) |
| + write(ctx, s, flow[4], ev("stopping", "cutting-stopping", map[string]string{"from": "ie", "to": "uk"})) |
| + if len(sink.events) != 1 || sink.events[0] != "bab/titogo/acme.go.tito.io info Moved. acme.go.tito.io is served from uk. It was offline for 41 seconds." { |
| + t.Fatalf("member events %q", sink.events) |
| + } |
| + prefix := moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", evMoveID) |
| + logged, err := moves.ReadLog(ctx, s, prefix) |
| + if err != nil || len(logged) != 2 || logged[0].Actor != "box-uk" { |
| + t.Fatalf("log %+v %v", logged, err) |
| + } |
| +} |
| + |
| +// The member lines (preflight L9, Paul's choice): M-C1, M-C5, M-C6, M-C7, |
| +// M-C8, M-C9, and for stuck a line with no operator command in it. |
| +func TestMemberLines(t *testing.T) { |
| + flow := doneFlow() |
| + rolling := evRec(provision.MoveRollingBack, func(m *provision.MoveState) { |
| + m.Rollback = &provision.MoveRollback{ID: "rb_x", Reason: provision.RollbackLandingDeadline} |
| + }) |
| + stuck := evRec(provision.MoveStuck, nil) |
| + rolledBack := provision.Record{Account: "bab", App: "titogo", Host: "acme.go.tito.io", Region: "ie", |
| + LastMove: &provision.MoveOutcome{ID: evMoveID, Outcome: "rolled_back"}} |
| + for _, c := range []struct { |
| + event string |
| + rec provision.Record |
| + level string |
| + line string |
| + }{ |
| + {"requested", flow[0], "info", "Moving acme.go.tito.io from ie to uk."}, |
| + {"admitted", flow[3], "info", "uk is ready. Starting the cut. acme.go.tito.io will be offline for about 45 seconds."}, |
| + {"flipped", flow[8], "info", "Stopped in ie. Starting in uk."}, |
| + {"rolling_back", rolling, "warn", "uk could not start acme.go.tito.io: " + provision.RollbackLandingDeadline + ". Moving it back to ie."}, |
| + {"rolled_back", rolledBack, "warn", "acme.go.tito.io is back in ie and serving. Nothing was lost."}, |
| + {"stuck", stuck, "error", "acme.go.tito.io could not start in uk or in ie. It is offline. An operator has been paged."}, |
| + {"stopping", flow[4], "", ""}, |
| + } { |
| + line, ok := memberLine(moves.LogEvent{Event: c.event, Fields: map[string]string{"from": "ie", "to": "uk"}}, c.rec) |
| + level := "" |
| + if ok { |
| + level = memberLevel(c.event) |
| + } |
| + if ok != (c.line != "") || level != c.level || line != c.line { |
| + t.Errorf("%s: %q %q %v, want %q %q", c.event, level, line, ok, c.level, c.line) |
| + } |
| + } |
| +} |
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go| index 3c3ed9d32..bf83f6409 100644 |
| --- a/internal/host/movesfinal.go |
| +++ b/internal/host/movesfinal.go |
| @@ -678,7 +678,11 @@ func (f *sourceFinal) configAck(ctx context.Context) error { |
| // 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())) |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, f.s, f.rec.Account, f.rec.App, host, observedMove(&pre, provision.MoveSourceParked(f.g, f.now()))) |
| + if err == nil { |
| + f.w.logTransition(ctx, f.s, pre, *after) |
| + } |
| if errors.Is(err, provision.ErrMoveConflict) { |
| fresh, gerr := provision.GetRecord(ctx, f.s, f.rec.Account, f.rec.App, host) |
| if gerr != nil { |
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go| index f90509e38..6b68e8fc5 100644 |
| --- a/internal/host/movesinstall.go |
| +++ b/internal/host/movesinstall.go |
| @@ -264,13 +264,16 @@ func (ir *installRun) seal(c *hostclaim.Claim) (moves.Seal, error) { |
| // decision (a rollback, a stuck mark, a console write): logged, and the call |
| // ends without error. |
| func (ir *installRun) transition(ctx context.Context, what string, mut func(*provision.Record) error) (bool, error) { |
| - if _, err := provision.SetRecordMove(ctx, ir.s, ir.rec.Account, ir.rec.App, ir.rec.Host, mut); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, ir.s, ir.rec.Account, ir.rec.App, ir.rec.Host, observedMove(&pre, mut)) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: %s of %s refused (%v); the record moved on", ir.rec.Host, what, ir.g.ID, err) |
| return false, nil |
| } |
| return false, fmt.Errorf("%s: %w", what, err) |
| } |
| + ir.w.logTransition(ctx, ir.s, pre, *after) |
| return true, nil |
| } |
| @@ -1082,13 +1085,16 @@ func recoveryInstallSource(w *movesWorker, s release.Store, rec provision.Record |
| default: |
| return false, "", fmt.Errorf("move %s: a return's install has no refusal %d", g.ID, why) |
| } |
| - if _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, host, provision.MoveMarkStuck(g, reason)); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, host, observedMove(&pre, provision.MoveMarkStuck(g, reason))) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the stuck mark of %s refused (%v); the record moved on", host, rb, err) |
| return false, "", nil |
| } |
| return false, "", fmt.Errorf("mark the return stuck: %w", err) |
| } |
| + w.logTransition(ctx, s, pre, *after) |
| log.Printf("moves: %s: the return %s stops for an operator: %s", host, rb, reason) |
| nudgeSerial(ctx, s) |
| return true, reason, nil |
diff --git a/internal/host/movesland.go b/internal/host/movesland.go| index 6367404db..f446b5b5f 100644 |
| --- a/internal/host/movesland.go |
| +++ b/internal/host/movesland.go |
| @@ -285,13 +285,16 @@ func landingIndex(order []string, phase string) int { |
| // else's decision (a rollback, a console write): logged, and the pass ends |
| // without error. |
| func (l *landingRun) transition(ctx context.Context, what string, mut func(*provision.Record) error) (bool, error) { |
| - if _, err := provision.SetRecordMove(ctx, l.s, l.rec.Account, l.rec.App, l.rec.Host, mut); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, l.s, l.rec.Account, l.rec.App, l.rec.Host, observedMove(&pre, mut)) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: %s of %s refused (%v); the record moved on", l.rec.Host, what, l.g.ID, err) |
| return false, nil |
| } |
| return false, fmt.Errorf("%s: %w", what, err) |
| } |
| + l.w.logTransition(ctx, l.s, pre, *after) |
| return true, nil |
| } |
diff --git a/internal/host/moveslandcommit.go b/internal/host/moveslandcommit.go| index 2a564e71b..f0410e810 100644 |
| --- a/internal/host/moveslandcommit.go |
| +++ b/internal/host/moveslandcommit.go |
| @@ -56,7 +56,11 @@ func (l *landingRun) probed(ctx context.Context) (bool, error) { |
| if err := l.fault("before-commit"); err != nil { |
| return false, err |
| } |
| - fresh, err := provision.SetRecordMove(ctx, l.s, l.rec.Account, l.rec.App, l.rec.Host, provision.MoveCommit(l.g, l.cfg().Now())) |
| + var pre provision.Record |
| + fresh, err := provision.SetRecordMove(ctx, l.s, l.rec.Account, l.rec.App, l.rec.Host, observedMove(&pre, provision.MoveCommit(l.g, l.cfg().Now()))) |
| + if err == nil { |
| + l.w.logTransition(ctx, l.s, pre, *fresh) |
| + } |
| switch { |
| case errors.Is(err, provision.ErrCutDeadline): |
| log.Printf("moves: %s: the commit of landing %s came after its deadline", l.rec.Host, l.g.ID) |
| @@ -243,10 +247,13 @@ func (l *landingRun) open(ctx context.Context) (stamped bool, err error) { |
| cfg.ConfirmProbeOpen(host, id) |
| } |
| if fresh.Move.OpenedAt == nil { |
| - if _, err = provision.SetRecordMove(ctx, l.s, l.rec.Account, l.rec.App, host, provision.MoveOpened(l.g, cfg.Now())); err != nil { |
| + var pre provision.Record |
| + var opened *provision.Record |
| + if opened, err = provision.SetRecordMove(ctx, l.s, l.rec.Account, l.rec.App, host, observedMove(&pre, provision.MoveOpened(l.g, cfg.Now()))); err != nil { |
| err = fmt.Errorf("the record's opened_at: %w", err) |
| return |
| } |
| + l.w.logTransition(ctx, l.s, pre, *opened) |
| stamped = true |
| } |
| }) |
diff --git a/internal/host/movesparks.go b/internal/host/movesparks.go| index 0e515bf47..8fb5df098 100644 |
| --- a/internal/host/movesparks.go |
| +++ b/internal/host/movesparks.go |
| @@ -97,7 +97,6 @@ func (w *movesWorker) closeMove(ctx context.Context, s release.Store, rec provis |
| w.notice(host+"#close", "moves: %s: move %s waits for %d park(s) of the move to be acknowledged before it closes", host, id, owed) |
| return nil |
| } |
| - w.logEvent(ctx, *fresh, "done") |
| target := c.Op.Participants[1] |
| if _, err := hostclaim.Update(ctx, s, obs, hostclaim.ReleaseMove(id, target)); err != nil { |
| return fmt.Errorf("release the claim: %w", err) |
| @@ -110,13 +109,16 @@ func (w *movesWorker) closeMove(ctx context.Context, s release.Store, rec provis |
| } |
| } |
| g := provision.MoveGuard{ID: id, Incarnation: inc} |
| - if _, err := provision.SetRecordMove(ctx, s, fresh.Account, fresh.App, host, provision.MoveClose(g, w.now())); err != nil { |
| + var pre provision.Record |
| + closed, err := provision.SetRecordMove(ctx, s, fresh.Account, fresh.App, host, observedMove(&pre, provision.MoveClose(g, w.now()))) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the close of move %s refused (%v); the record moved on", host, id, err) |
| return nil |
| } |
| return fmt.Errorf("close the move: %w", err) |
| } |
| + w.logTransition(ctx, s, pre, *closed) |
| log.Printf("moves: %s: move %s is done; the instance is in %s", host, id, m.To) |
| nudgeSerial(ctx, s) |
| return nil |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 4edda2f80..90d9dcb8a 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -90,7 +90,10 @@ type movesWorker struct { |
| archives map[string]verifiedArchive |
| measured moves.Measurements |
| notices sync.Map // key → time.Time of the last line |
| - strict atomic.Pointer[map[string]bool] |
| + // logged is the move log identities this worker has handed to LogEvent |
| + // (movesevents.go): host/op/identity → true. |
| + logged sync.Map |
| + strict atomic.Pointer[map[string]bool] |
| // holds is what reconcile and the schedule halves ask about this |
| // box's running moves (moveshold.go), rebuilt every pass. Nil with the |
| // switch off. |
| @@ -185,7 +188,7 @@ func (w *movesWorker) mirror(ctx context.Context, dstRegion string) (lease.Mirro |
| // Phase 1 launch environment and S2). Called once, before svc.Start: with |
| // the Phase 1 switch on it sets eb.Strict, which the spawn path reads; with |
| // it off eb.Strict stays nil, so every spawn is main's. |
| -func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service, eb *activator.ExecBackend, writes hostwrite.Config) { |
| +func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service, eb *activator.ExecBackend, writes hostwrite.Config, events activator.EventSink) { |
| readyDelta, ok := moveReadyDeltaFromEnv(os.Getenv) |
| if !ok { |
| log.Printf("agent: %s=%q ignored: want a whole number of bytes, 0 or more — keeping the built-in default of %d", moveReadyDeltaEnv, os.Getenv(moveReadyDeltaEnv), defaultMoveReadyDelta) |
| @@ -233,6 +236,9 @@ func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, |
| Enabled: moves.Enabled(os.Getenv), |
| claimRecords: writes.Enabled, |
| Page: PageOperator, |
| + // The move log and the member stream (Plan 6 Task 3, preflight L9). |
| + Events: events, |
| + LogEvent: moveLogWriter(events), |
| } |
| w := &movesWorker{mirrors: map[string]lease.MirrorStore{}} |
| if cfg.Enabled { |
| @@ -609,13 +615,16 @@ type moveCfg struct { |
| // The rollback on the target (§7.8, movesrollback.go). HandHold hands |
| // the row's hold from the move to its rollback (handHold, ruling J3). |
| - // LogEvent records a move event (moves.LogEvent, Plan 6): nil in |
| - // production, where the event is a log line (ruling J18). The target's |
| + // LogEvent records a move event (moves.LogEvent, Plan 6 Task 3): in |
| + // production moveLogWriter, the move log's create-only object and, when |
| + // this writer created it, the member event through Events (the agent's |
| + // rollout sink). Nil, the event is a log line. The target's |
| // half also uses the cut's stop hooks, the landing's park hooks and |
| // DeleteHeld above; ParkLeases nil leaves it unarmed in production |
| // (rollbackTargetArmed). rbFault is its test seam, nil in production. |
| HandHold func(host, status, toOp, fromOp string, validate func() error) (bool, error) |
| - LogEvent func(ctx context.Context, rec provision.Record, event string) |
| + LogEvent func(ctx context.Context, s release.Store, rec provision.Record, ev moves.LogEvent) |
| + Events activator.EventSink |
| rbFault func(point string) error |
| // Page pages an operator (moves spec §13, K11): PageOperator in |
diff --git a/internal/host/movespass_test.go b/internal/host/movespass_test.go| index 7a4161b49..5fc8b47f5 100644 |
| --- a/internal/host/movespass_test.go |
| +++ b/internal/host/movespass_test.go |
| @@ -926,7 +926,7 @@ func TestArmMovesPassReadsTheReadyDelta(t *testing.T) { |
| {"lots", defaultMoveReadyDelta}, |
| } { |
| t.Setenv("CARLOS_MOVE_READY_DELTA_BYTES", tc.raw) |
| - armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}, nil) |
| if got := armedMovesWorker.Load().cfg.ReadyDelta; got != tc.want { |
| t.Errorf("CARLOS_MOVE_READY_DELTA_BYTES=%q armed a bar of %d, want %d", tc.raw, got, tc.want) |
| } |
| @@ -945,7 +945,7 @@ func TestArmMovesPassReadsTheSwitch(t *testing.T) { |
| {"", false}, {"on", true}, {"1", true}, {"true", true}, {"off", false}, {"yes", false}, |
| } { |
| t.Setenv(moves.SwitchEnv, tc.raw) |
| - armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil, nil, hostwrite.Config{}, nil) |
| if got := armedMovesWorker.Load().cfg.Enabled; got != tc.want { |
| t.Errorf("%s=%q armed Enabled=%v, want %v", moves.SwitchEnv, tc.raw, got, tc.want) |
| } |
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go| index 0e2eb9f8e..952c3a3c1 100644 |
| --- a/internal/host/movesphase1.go |
| +++ b/internal/host/movesphase1.go |
| @@ -180,6 +180,9 @@ func writeMoveLedger(ctx context.Context, m lease.MirrorStore, bucket, key strin |
| // phase1MoveRound is the Phase 1 worker's step for one id'd record this box |
| // is the source of. |
| func phase1MoveRound(ctx context.Context, s release.Store, w *movesWorker, rec provision.Record, fleet moveFleet) error { |
| + // The transitions another writer made (the console's doors, a declared |
| + // loss) are logged by the box that sees them (movesevents.go). |
| + w.observeMove(ctx, s, rec) |
| switch rec.Move.State { |
| case provision.MovePreparing, provision.MoveReady: |
| return w.preparingRound(ctx, s, rec, fleet) |
| @@ -899,13 +902,16 @@ func (w *movesWorker) decideReady(ctx context.Context, s release.Store, rec prov |
| if repin { |
| mut = provision.MoveGuardedRepin(g, in) |
| } |
| - if _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, mut); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, observedMove(&pre, mut)) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the record no longer holds move %s as %s without a pin (%v); this round ends", rec.Host, g.ID, state, err) |
| return nil |
| } |
| return err |
| } |
| + w.logTransition(ctx, s, pre, *after) |
| nudgeSerial(ctx, s) |
| if repin { |
| log.Printf("moves: %s: move %s pinned its inputs again", rec.Host, g.ID) |
| @@ -915,13 +921,16 @@ func (w *movesWorker) decideReady(ctx context.Context, s release.Store, rec prov |
| state = provision.MoveReady |
| } |
| if state == provision.MoveReady && rec.Move.Cut == "auto" && layoutErr == nil { |
| - if _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MoveGuardedCut(g)); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, observedMove(&pre, provision.MoveGuardedCut(g))) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the automatic cut of %s was refused (%v)", rec.Host, g.ID, err) |
| return nil |
| } |
| return err |
| } |
| + w.logTransition(ctx, s, pre, *after) |
| nudgeSerial(ctx, s) |
| log.Printf("moves: %s: move %s cut requested automatically", rec.Host, g.ID) |
| } |
diff --git a/internal/host/movesphase1_test.go b/internal/host/movesphase1_test.go| index a6efc9a4c..46106094b 100644 |
| --- a/internal/host/movesphase1_test.go |
| +++ b/internal/host/movesphase1_test.go |
| @@ -144,7 +144,7 @@ func TestPhase1IsInertWithTheSwitchOff(t *testing.T) { |
| } |
| t.Setenv(moves.SwitchEnv, v) |
| eb := &activator.ExecBackend{} |
| - armMovesPass(activator.Config{}, t.TempDir(), nil, nil, eb, hostwrite.Config{}) |
| + armMovesPass(activator.Config{}, t.TempDir(), nil, nil, eb, hostwrite.Config{}, nil) |
| if (eb.Strict != nil) != on { |
| t.Fatalf("switch %v: eb.Strict set = %v", on, eb.Strict != nil) |
| } |
diff --git a/internal/host/movesrecover.go b/internal/host/movesrecover.go| index db4e59706..d7d0f8a3c 100644 |
| --- a/internal/host/movesrecover.go |
| +++ b/internal/host/movesrecover.go |
| @@ -273,11 +273,14 @@ func (r *recoverRun) step(ctx context.Context) (bool, error) { |
| return false, nil |
| } |
| -// phase writes one source phase step, then logs the event, if any, and |
| +// phase writes one source phase step, logs it (movesevents.go names it from |
| +// the record; the last argument, the step's old event name, is unused) and |
| // passes the step's fault point. A conflict is someone else's decision. |
| -func (r *recoverRun) phase(ctx context.Context, from, to string, stamp func(*provision.MoveRollback), event string) (bool, error) { |
| +func (r *recoverRun) phase(ctx context.Context, from, to string, stamp func(*provision.MoveRollback), _ string) (bool, error) { |
| host := r.rec.Host |
| - if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveRollbackSourcePhase(r.g, from, to, stamp)); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, observedMove(&pre, provision.MoveRollbackSourcePhase(r.g, from, to, stamp))) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the source's recovery %s to %s refused (%v); the record moved on", host, from, to, err) |
| return false, nil |
| @@ -285,9 +288,7 @@ func (r *recoverRun) phase(ctx context.Context, from, to string, stamp func(*pro |
| return false, fmt.Errorf("the source's recovery %s to %s: %w", from, to, err) |
| } |
| log.Printf("moves: %s: the source of rollback %s is %s", host, r.rb, to) |
| - if event != "" { |
| - r.w.logEvent(ctx, r.rec, event) |
| - } |
| + r.w.logTransition(ctx, r.s, pre, *after) |
| if err := r.fault("after-record-" + to); err != nil { |
| return true, err |
| } |
| @@ -299,14 +300,16 @@ func (r *recoverRun) phase(ctx context.Context, from, to string, stamp func(*pro |
| func (r *recoverRun) stuck(ctx context.Context, reason string, why error) (bool, error) { |
| host := r.rec.Host |
| log.Printf("moves: %s: the recovery of %s stops for an operator (%v): %s", host, r.rb, why, reason) |
| - if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveMarkStuck(r.g, reason)); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, observedMove(&pre, provision.MoveMarkStuck(r.g, reason))) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the stuck mark of %s refused (%v); the record moved on", host, r.rb, err) |
| return false, nil |
| } |
| return false, fmt.Errorf("mark the recovery stuck: %w", err) |
| } |
| - r.w.logEvent(ctx, r.rec, "source-stuck") |
| + r.w.logTransition(ctx, r.s, pre, *after) |
| r.w.pageStuck(ctx, r.rec, "source", reason) |
| nudgeSerial(ctx, r.s) |
| return true, nil |
| @@ -583,7 +586,7 @@ func (r *recoverRun) install(ctx context.Context) (bool, error) { |
| advanced, refused, err := installGeneration(ctx, r.s, r.w, r.rec, src) |
| if refused != "" { |
| log.Printf("moves: %s: the return of %s stopped for an operator: %s", host, r.rb, refused) |
| - r.w.logEvent(ctx, r.rec, "source-stuck") |
| + // The stuck mark is installGeneration's write, logged there. |
| r.w.pageStuck(ctx, r.rec, "source", refused) |
| } |
| return advanced, err |
| @@ -802,7 +805,6 @@ func (r *recoverRun) finalize(ctx context.Context) (bool, error) { |
| r.w.notice(host+"#rc-final", "moves: %s: rollback %s waits for %d chore(s) of the move to be acknowledged before it closes", host, r.rb, owed) |
| return false, nil |
| } |
| - r.w.logEvent(ctx, r.rec, "rolled_back") |
| if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.ReleaseMove(id, cfg.Box)); err != nil { |
| return false, fmt.Errorf("release the claim: %w", err) |
| } |
| @@ -811,13 +813,16 @@ func (r *recoverRun) finalize(ctx context.Context) (bool, error) { |
| return false, err |
| } |
| } |
| - if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveRolledBackClose(r.g, r.now())); err != nil { |
| + var pre provision.Record |
| + closed, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, observedMove(&pre, provision.MoveRolledBackClose(r.g, r.now()))) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the close of rollback %s refused (%v); the record moved on", host, r.rb, err) |
| return false, nil |
| } |
| return false, fmt.Errorf("close the rolled-back move: %w", err) |
| } |
| + r.w.logTransition(ctx, r.s, pre, *closed) |
| log.Printf("moves: %s: move %s is rolled back and closed", host, id) |
| nudgeSerial(ctx, r.s) |
| if err := r.fault("after-close"); err != nil { |
diff --git a/internal/host/movesrecover_test.go b/internal/host/movesrecover_test.go| index 928368cb6..6f354333f 100644 |
| --- a/internal/host/movesrecover_test.go |
| +++ b/internal/host/movesrecover_test.go |
| @@ -150,7 +150,9 @@ func (h *recoverHarness) sourceCfg(t *testing.T) moveCfg { |
| h.srcRoute.Status, h.srcRoute.HoldOp = "ready", "" |
| return true, nil |
| } |
| - c.LogEvent = func(_ context.Context, _ provision.Record, event string) { h.sEvents = append(h.sEvents, event) } |
| + c.LogEvent = func(_ context.Context, _ release.Store, _ provision.Record, ev moves.LogEvent) { |
| + h.sEvents = append(h.sEvents, ev.Event) |
| + } |
| c.Page = h.pages.hook() |
| c.recoveryProbe = func(ctx context.Context, _ release.Store, rec provision.Record, req recoveryProbeRequest) (recoveryProbeOutcome, error) { |
| h.probes = append(h.probes, req) |
| @@ -367,7 +369,7 @@ func TestCleanReturnVerifiesAndOpens(t *testing.T) { |
| t.Fatalf("a clean return queued %+v", o) |
| } |
| } |
| - if !strings.Contains(strings.Join(h.sEvents, " "), "source-recovered source-opened") { |
| + if !strings.Contains(strings.Join(h.sEvents, " "), "recovered opened") { |
| t.Fatalf("events %v", h.sEvents) |
| } |
| // The record waits for the target's cleaning: a second pass changes |
| @@ -542,7 +544,7 @@ func TestVerifyMismatchIsStuck(t *testing.T) { |
| h.retry(t) |
| h.srcPass(t) |
| h.opened(t) |
| - if !strings.Contains(strings.Join(h.sEvents, " "), "source-stuck") { |
| + if !strings.Contains(strings.Join(h.sEvents, " "), "stuck") { |
| t.Fatalf("events %v", h.sEvents) |
| } |
| }) |
diff --git a/internal/host/movesrevert.go b/internal/host/movesrevert.go| index 406b0294e..4e06954e4 100644 |
| --- a/internal/host/movesrevert.go |
| +++ b/internal/host/movesrevert.go |
| @@ -277,22 +277,27 @@ func (r *revertRun) stillReverting(ctx context.Context) error { |
| // update writes one claim transition over obs. |
| func (r *revertRun) update(ctx context.Context, obs hostclaim.Observed, what string, step func(*hostclaim.Claim) error) error { |
| - if _, err := hostclaim.Update(ctx, r.s, obs, step); err != nil { |
| + after, err := hostclaim.Update(ctx, r.s, obs, step) |
| + if err != nil { |
| return fmt.Errorf("%s: %w", what, err) |
| } |
| + r.w.logRevPhase(ctx, r.s, r.rec, obs, after) |
| return nil |
| } |
| // record writes one record transition. A conflict is someone else's |
| // decision: logged, and the pass ends without error. |
| func (r *revertRun) record(ctx context.Context, what string, mut func(*provision.Record) error) (bool, error) { |
| - if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, r.rec.Host, mut); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, r.rec.Host, observedMove(&pre, mut)) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: %s of %s refused (%v); the record moved on", r.rec.Host, what, r.g.ID, err) |
| return false, nil |
| } |
| return false, fmt.Errorf("%s: %w", what, err) |
| } |
| + r.w.logTransition(ctx, r.s, pre, *after) |
| return true, nil |
| } |
| @@ -679,7 +684,6 @@ func (r *revertRun) probed(ctx context.Context, obs hostclaim.Observed) (bool, e |
| return false, err |
| } |
| log.Printf("moves: %s: the source serves again; the revert of %s waits for its lane's park", host, id) |
| - r.w.logEvent(ctx, r.rec, "source-opened") |
| nudgeSerial(ctx, r.s) |
| if err := r.fault("after-rev-opened"); err != nil { |
| return false, err |
| @@ -706,7 +710,6 @@ func (r *revertRun) opened(ctx context.Context, obs hostclaim.Observed) (bool, e |
| r.w.notice(host+"#rv-opened", "moves: %s: the revert of %s waits for %d chore(s) of the move, its lane's park, to be acknowledged", host, id, owed) |
| return false, nil |
| } |
| - r.w.logEvent(ctx, r.rec, "rolled_back") |
| if err := r.update(ctx, obs, "release the claim", hostclaim.ReleaseMove(id, cfg.Box)); err != nil { |
| return false, err |
| } |
| @@ -746,7 +749,6 @@ func (r *revertRun) stuck(ctx context.Context, reason string, why error) (bool, |
| if !ok { |
| return false, err |
| } |
| - r.w.logEvent(ctx, r.rec, "source-stuck") |
| r.w.pageStuck(ctx, r.rec, "source", reason) |
| nudgeSerial(ctx, r.s) |
| return true, nil |
diff --git a/internal/host/movesrevert_test.go b/internal/host/movesrevert_test.go| index dd8dfd456..a291bcde2 100644 |
| --- a/internal/host/movesrevert_test.go |
| +++ b/internal/host/movesrevert_test.go |
| @@ -132,7 +132,9 @@ func (h *revertHarness) arm(t *testing.T) { |
| h.route.Status, h.route.HoldOp = "ready", "" |
| return true, nil |
| } |
| - h.cfg.LogEvent = func(_ context.Context, _ provision.Record, event string) { h.events = append(h.events, event) } |
| + h.cfg.LogEvent = func(_ context.Context, _ release.Store, _ provision.Record, ev moves.LogEvent) { |
| + h.events = append(h.events, ev.Event) |
| + } |
| h.cfg.recoveryProbe = func(ctx context.Context, _ release.Store, _ provision.Record, req recoveryProbeRequest) (recoveryProbeOutcome, error) { |
| h.probes = append(h.probes, req) |
| if h.probe != nil { |
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go| index d52afdedc..c3b60ff54 100644 |
| --- a/internal/host/movesrollback.go |
| +++ b/internal/host/movesrollback.go |
| @@ -123,20 +123,6 @@ func (w *movesWorker) now() time.Time { |
| return time.Now() |
| } |
| -// logEvent records a move event: the LogEvent hook when wired (Plan 6's |
| -// moves.LogEvent), else a log line. |
| -func (w *movesWorker) logEvent(ctx context.Context, rec provision.Record, event string) { |
| - if f := w.cfg.LogEvent; f != nil { |
| - f(ctx, rec, event) |
| - return |
| - } |
| - id := "" |
| - if rec.Move != nil { |
| - id = rec.Move.ID |
| - } |
| - log.Printf("moves: %s: %s %s (the move log is Plan 6's)", rec.Host, id, event) |
| -} |
| - |
| // pageStuck pages an operator once a MoveMarkStuck has landed (moves spec |
| // §13: stuck is an incident; K11). role is this box's part in the move, |
| // "source" or "target", and reason the recorded stuck sentence. The page |
| @@ -193,7 +179,8 @@ func (w *movesWorker) rollBackLanding(ctx context.Context, s release.Store, rec |
| return true, err |
| } |
| g := provision.MoveGuard{ID: m.ID, Incarnation: m.Incarnation} |
| - fresh, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MoveRollBack(g, rbID, reason, w.now())) |
| + var pre provision.Record |
| + fresh, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, observedMove(&pre, provision.MoveRollBack(g, rbID, reason, w.now()))) |
| if errors.Is(err, provision.ErrMoveConflict) { |
| // A commit that won (the open is the commit's path), or another |
| // writer's rollback (the participant pass follows it). |
| @@ -204,7 +191,7 @@ func (w *movesWorker) rollBackLanding(ctx context.Context, s release.Store, rec |
| return true, fmt.Errorf("roll the landing back: %w", err) |
| } |
| log.Printf("moves: %s: landing %s rolls back as %s: %s", rec.Host, m.ID, rbID, fresh.Move.Rollback.Reason) |
| - w.logEvent(ctx, *fresh, "rollback") |
| + w.logTransition(ctx, s, pre, *fresh) |
| if _, err := w.rollbackClaim(ctx, s, *fresh); err != nil { |
| log.Printf("moves: %s: the claim half of rollback %s waits for the next pass: %v", rec.Host, rbID, err) |
| } |
| @@ -332,11 +319,14 @@ func (r *rollbackRun) step(ctx context.Context) (bool, error) { |
| return false, nil // lost: the declared loss's (Task 10) |
| } |
| -// phase writes one target phase step, then logs the event, if any, and |
| +// phase writes one target phase step, logs it (movesevents.go names it from |
| +// the record; the last argument, the step's old event name, is unused) and |
| // passes the step's fault point. A conflict is someone else's decision. |
| -func (r *rollbackRun) phase(ctx context.Context, from, to string, stamp func(*provision.MoveRollback), event string) (bool, error) { |
| +func (r *rollbackRun) phase(ctx context.Context, from, to string, stamp func(*provision.MoveRollback), _ string) (bool, error) { |
| host := r.rec.Host |
| - if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveRollbackTargetPhase(r.g, from, to, stamp)); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, observedMove(&pre, provision.MoveRollbackTargetPhase(r.g, from, to, stamp))) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the target's rollback %s to %s refused (%v); the record moved on", host, from, to, err) |
| return false, nil |
| @@ -344,9 +334,7 @@ func (r *rollbackRun) phase(ctx context.Context, from, to string, stamp func(*pr |
| return false, fmt.Errorf("the target's rollback %s to %s: %w", from, to, err) |
| } |
| log.Printf("moves: %s: the target of rollback %s is %s", host, r.rb, to) |
| - if event != "" { |
| - r.w.logEvent(ctx, r.rec, event) |
| - } |
| + r.w.logTransition(ctx, r.s, pre, *after) |
| if err := r.fault("after-record-" + to); err != nil { |
| return true, err |
| } |
| @@ -887,14 +875,16 @@ func databaseGone(err error) bool { |
| func (r *rollbackRun) stuck(ctx context.Context, refused *returnRefusal) (bool, error) { |
| host := r.rec.Host |
| log.Printf("moves: %s: the return of %s cannot be built (%v); it stops for an operator: %s", host, r.rb, refused.err, refused.reason) |
| - if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveMarkStuck(r.g, refused.reason)); err != nil { |
| + var pre provision.Record |
| + after, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, observedMove(&pre, provision.MoveMarkStuck(r.g, refused.reason))) |
| + if err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the stuck mark of %s refused (%v); the record moved on", host, r.rb, err) |
| return false, nil |
| } |
| return false, fmt.Errorf("mark the return stuck: %w", err) |
| } |
| - r.w.logEvent(ctx, r.rec, "target-stuck") |
| + r.w.logTransition(ctx, r.s, pre, *after) |
| r.w.pageStuck(ctx, r.rec, "target", refused.reason) |
| nudgeSerial(ctx, r.s) |
| return true, nil |
diff --git a/internal/host/movesrollback_test.go b/internal/host/movesrollback_test.go| index 43581e789..e38120f9d 100644 |
| --- a/internal/host/movesrollback_test.go |
| +++ b/internal/host/movesrollback_test.go |
| @@ -22,6 +22,7 @@ import ( |
| "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| "github.com/superfly/ltx" |
| ) |
| @@ -135,7 +136,9 @@ func (h *rollbackHarness) rollbackCfg(t *testing.T) moveCfg { |
| h.rowGone = true |
| return true, nil |
| } |
| - c.LogEvent = func(_ context.Context, _ provision.Record, event string) { h.events = append(h.events, event) } |
| + c.LogEvent = func(_ context.Context, _ release.Store, _ provision.Record, ev moves.LogEvent) { |
| + h.events = append(h.events, ev.Event) |
| + } |
| c.Page = h.pages.hook() |
| c.rbFault = func(point string) error { |
| h.rbHits = append(h.rbHits, point) |
| @@ -289,7 +292,7 @@ func TestRollbackDecisionConvertsTheMark(t *testing.T) { |
| cl.Op.Exec != "uk-box" || cl.Op.Journal[hostclaim.JournalTargetStamp] != strconv.FormatUint(f, 10) { |
| t.Fatalf("claim: %+v", cl.Op) |
| } |
| - if len(h.events) != 1 || h.events[0] != "rollback" { |
| + if len(h.events) != 1 || h.events[0] != "rolling_back" { |
| t.Fatalf("events %v", h.events) |
| } |
| }) |
| @@ -431,7 +434,7 @@ func TestReleaseIsCleanWhenNothingChanged(t *testing.T) { |
| if got := h.rb(t); got.TargetPhase != provision.RbTargetReleased { |
| t.Fatalf("a clean release moved on before the source recovered: %+v", got) |
| } |
| - if strings.Join(h.events, " ") != "rollback target-released" { |
| + if strings.Join(h.events, " ") != "rolling_back released" { |
| t.Fatalf("events %v", h.events) |
| } |
| } |
| @@ -525,7 +528,7 @@ func TestReleaseIsDirtyWithoutSrcCovered(t *testing.T) { |
| if h.lease.acquired == 0 || h.lease.acquired != h.lease.released { |
| t.Fatalf("the staging lease: acquired %d released %d", h.lease.acquired, h.lease.released) |
| } |
| - if strings.Join(h.events, " ") != "rollback target-released target-returned" { |
| + if strings.Join(h.events, " ") != "rolling_back released returning returned" { |
| t.Fatalf("events %v", h.events) |
| } |
| } |
| @@ -869,7 +872,7 @@ func TestTargetCleaningLeavesNoLiveCopy(t *testing.T) { |
| if role := movesParticipantRecord(h.rollbackCfg(t), h.read(t, p1Host)); role != roleNone { |
| t.Fatalf("a cleaned target still routes as %s", role) |
| } |
| - if !strings.HasSuffix(strings.Join(h.events, " "), "target-cleaned") { |
| + if !strings.HasSuffix(strings.Join(h.events, " "), "cleaning cleaned") { |
| t.Fatalf("events %v", h.events) |
| } |
| } |
| @@ -1027,7 +1030,10 @@ func (h *rollbackHarness) stuckAtReturning(t *testing.T, reason string) { |
| func (h *rollbackHarness) retry(t *testing.T) { |
| t.Helper() |
| g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc} |
| - if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveRetry(g)); err != nil { |
| + // The claim-backed retry (claim Plan 10): it advances the recovery retry |
| + // epoch, so the move log keys the next stuck as a new entry (Plan 6 |
| + // Task 3, preflight L11). |
| + if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveRetryRecovery(g)); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -1053,7 +1059,7 @@ func TestDirtyReturnGoesStuck(t *testing.T) { |
| h.retry(t) |
| h.rollbackPass(t) |
| h.stuckAtReturning(t, provision.StuckReturnTargetDatabase) |
| - if got := strings.Join(h.events[before:], " "); got != "target-stuck" { |
| + if got := strings.Join(h.events[before:], " "); got != "stuck" { |
| t.Fatalf("the retry did not run the return: events %q", got) |
| } |
| }) |
diff --git a/internal/moves/log.go b/internal/moves/log.go| new file mode 100644 |
| index 000000000..955306272 |
| --- /dev/null |
| +++ b/internal/moves/log.go |
| @@ -0,0 +1,99 @@ |
| +package moves |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The move log (spec §13, Plan 6 Task 3): one object per transition under the |
| +// move's report prefix, log/<identity>.json, in the deployment bucket beside |
| +// the reports and the stop evidence. |
| +// |
| +// The key is the transition's identity, never a counter (round-2 finding |
| +// 23): <state>-<phase> for a phase step (cutting-stopped, landing-probing, |
| +// rolling_back-source-claimed), probe_attempt-<n> for the landing's n-th |
| +// probe, stuck-<epoch> and retry-<epoch> keyed by the record's recovery retry |
| +// epoch (claim Plan 10, preflight L11), and the terminal outcome itself |
| +// (done, rolled_back, aborted). An object is written create-only: the first |
| +// writer of a transition wins, and a second writer, or a replay, meets the |
| +// existing object, which is success with nothing created (preflight L27). |
| +// The log is read in At order (ReadLog), never by key. |
| + |
| +// LogEvent is one move log object. |
| +type LogEvent struct { |
| + V int `json:"v"` |
| + Op string `json:"op"` |
| + Host string `json:"host"` |
| + Event string `json:"event"` |
| + Identity string `json:"identity"` |
| + Actor string `json:"actor"` |
| + At time.Time `json:"at"` |
| + Fields map[string]string `json:"fields,omitempty"` |
| +} |
| + |
| +// ErrLogIdentity refuses an identity that is not one key segment. |
| +var ErrLogIdentity = errors.New("moves: a log identity is one key segment") |
| + |
| +func validLogIdentity(id string) bool { |
| + return id != "" && id != "." && id != ".." && !strings.ContainsAny(id, "/\\\x00") |
| +} |
| + |
| +// WriteLog writes ev at LogKeyFor(prefix, ev.Identity), create-only. created |
| +// is true only for the writer that made the object; an existing object is |
| +// success with created false. |
| +func WriteLog(ctx context.Context, s release.Store, prefix string, ev LogEvent) (created bool, err error) { |
| + if !validLogIdentity(ev.Identity) { |
| + return false, fmt.Errorf("%w: %q", ErrLogIdentity, ev.Identity) |
| + } |
| + body, err := json.Marshal(ev) |
| + if err != nil { |
| + return false, err |
| + } |
| + if _, err := s.Put(ctx, LogKeyFor(prefix, ev.Identity), body, ""); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + return false, nil |
| + } |
| + return false, err |
| + } |
| + return true, nil |
| +} |
| + |
| +// ReadLog reads every object under prefix's log/, ordered by At (then by |
| +// identity, for two at the same instant). An object that went missing |
| +// between the list and the read is skipped. |
| +func ReadLog(ctx context.Context, s release.Store, prefix string) ([]LogEvent, error) { |
| + keys, err := s.List(ctx, prefix+"log/") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]LogEvent, 0, len(keys)) |
| + for _, k := range keys { |
| + body, _, err := s.Get(ctx, k) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var ev LogEvent |
| + if err := json.Unmarshal(body, &ev); err != nil { |
| + return nil, fmt.Errorf("moves: log object %s: %w", k, err) |
| + } |
| + out = append(out, ev) |
| + } |
| + sort.SliceStable(out, func(i, j int) bool { |
| + if !out[i].At.Equal(out[j].At) { |
| + return out[i].At.Before(out[j].At) |
| + } |
| + return out[i].Identity < out[j].Identity |
| + }) |
| + return out, nil |
| +} |
diff --git a/internal/moves/log_test.go b/internal/moves/log_test.go| new file mode 100644 |
| index 000000000..c5c6832ef |
| --- /dev/null |
| +++ b/internal/moves/log_test.go |
| @@ -0,0 +1,97 @@ |
| +package moves |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const tLogPrefix = "moves/bab/app/a.go.example.com/mv_abcdefghijklmnopqrstuvwxyz/" |
| + |
| +func logEvent(identity, event string, at time.Time, actor string) LogEvent { |
| + return LogEvent{V: 1, Op: "mv_abcdefghijklmnopqrstuvwxyz", Host: "a.go.example.com", Event: event, Identity: identity, Actor: actor, At: at} |
| +} |
| + |
| +// Plan 6 Task 3 (preflight L10, L27): a log object's key is the transition's |
| +// identity, never a counter. A key is written create-only: the first writer |
| +// wins and creates it; a second writer of the same transition, or a replay, |
| +// meets the existing object, which is success with nothing created. |
| +func TestLogKeysAreTransitionIdentities(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + t0 := time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC) |
| + |
| + // Two writers of landing-probing (two boxes, or a crash and a replay), |
| + // with different bodies: one object, the first writer's. |
| + created, err := WriteLog(ctx, s, tLogPrefix, logEvent("landing-probing", "probing", t0, "box-uk")) |
| + if err != nil || !created { |
| + t.Fatalf("first writer: created %v, %v", created, err) |
| + } |
| + created, err = WriteLog(ctx, s, tLogPrefix, logEvent("landing-probing", "probing", t0.Add(time.Second), "box-ie")) |
| + if err != nil || created { |
| + t.Fatalf("second writer: created %v, %v; want no object and no error", created, err) |
| + } |
| + // Three probe attempts: three objects. |
| + for n, id := range []string{"probe_attempt-1", "probe_attempt-2", "probe_attempt-3"} { |
| + if created, err := WriteLog(ctx, s, tLogPrefix, logEvent(id, "probe_attempt", t0.Add(time.Duration(n+2)*time.Second), "box-uk")); err != nil || !created { |
| + t.Fatalf("%s: created %v, %v", id, created, err) |
| + } |
| + } |
| + // A replayed cutting-stopped writes nothing new. |
| + if created, err := WriteLog(ctx, s, tLogPrefix, logEvent("cutting-stopped", "stopped", t0.Add(-time.Minute), "box-ie")); err != nil || !created { |
| + t.Fatalf("cutting-stopped: %v %v", created, err) |
| + } |
| + if created, err := WriteLog(ctx, s, tLogPrefix, logEvent("cutting-stopped", "stopped", t0.Add(-time.Minute), "box-ie")); err != nil || created { |
| + t.Fatalf("replayed cutting-stopped: created %v, %v", created, err) |
| + } |
| + |
| + keys, err := s.List(ctx, tLogPrefix+"log/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(keys) != 5 { |
| + t.Fatalf("keys %v, want five objects", keys) |
| + } |
| + for _, id := range []string{"landing-probing", "probe_attempt-1", "probe_attempt-2", "probe_attempt-3", "cutting-stopped"} { |
| + if _, _, err := s.Get(ctx, LogKeyFor(tLogPrefix, id)); err != nil { |
| + t.Fatalf("%s: %v", id, err) |
| + } |
| + } |
| + |
| + // ReadLog orders by At, never by key. |
| + got, err := ReadLog(ctx, s, tLogPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var order []string |
| + for _, ev := range got { |
| + order = append(order, ev.Identity) |
| + } |
| + want := "cutting-stopped landing-probing probe_attempt-1 probe_attempt-2 probe_attempt-3" |
| + if j := joinSpace(order); j != want { |
| + t.Fatalf("ReadLog order %q, want %q", j, want) |
| + } |
| + if got[1].Actor != "box-uk" { |
| + t.Fatalf("landing-probing is %+v, want the first writer's", got[1]) |
| + } |
| + |
| + // An identity is one key segment. |
| + for _, bad := range []string{"", "a/b", "..", "."} { |
| + if _, err := WriteLog(ctx, s, tLogPrefix, logEvent(bad, "x", t0, "box")); err == nil { |
| + t.Errorf("identity %q accepted", bad) |
| + } |
| + } |
| +} |
| + |
| +func joinSpace(xs []string) string { |
| + out := "" |
| + for i, x := range xs { |
| + if i > 0 { |
| + out += " " |
| + } |
| + out += x |
| + } |
| + return out |
| +} |
diff --git a/internal/moves/staging.go b/internal/moves/staging.go| index 6adca63f3..3652a11ef 100644 |
| --- a/internal/moves/staging.go |
| +++ b/internal/moves/staging.go |
| @@ -1,7 +1,5 @@ |
| package moves |
| -import "fmt" |
| - |
| // The staging layout (§7.3): one prefix per operation in the target |
| // bucket, one subprefix per artifact class, so MirrorPrefix on replica/ |
| // can never delete an archive, a snapshot or move.json. |
| @@ -44,8 +42,11 @@ func ReportPrefix(acct, app, host, opID string) string { |
| func SourceReportKey(prefix string) string { return prefix + "source.json" } |
| func TargetReportKey(prefix string) string { return prefix + "target.json" } |
| func StoppedKey(prefix, role string) string { return prefix + "stopped-" + role + ".json" } |
| -func LogKey(prefix string, seq int, event string) string { |
| - return fmt.Sprintf("%slog/%07d-%s.json", prefix, seq, event) |
| + |
| +// LogKeyFor is the move log object of one transition: its key is the |
| +// transition's identity (log.go), never a counter. |
| +func LogKeyFor(prefix, identity string) string { |
| + return prefix + "log/" + identity + ".json" |
| } |
| // ProbeKey is where a target box measures regional S3 by putting and |
diff --git a/internal/moves/staging_test.go b/internal/moves/staging_test.go| index 65fe4e17b..2035864f0 100644 |
| --- a/internal/moves/staging_test.go |
| +++ b/internal/moves/staging_test.go |
| @@ -20,7 +20,7 @@ func TestStagingKeysAreScopedPerOperation(t *testing.T) { |
| t.Fatal("source-bucket keys") |
| } |
| p := ReportPrefix("bab", "app", host, op) |
| - if p != "moves/bab/app/a.go.example.com/mv_abcdefghijklmnopqrstuvwxyz/" || StoppedKey(p, "source") != p+"stopped-source.json" || TargetReportKey(p) != p+"target.json" || SourceReportKey(p) != p+"source.json" || LogKey(p, 7, "flipped") != p+"log/0000007-flipped.json" { |
| + if p != "moves/bab/app/a.go.example.com/mv_abcdefghijklmnopqrstuvwxyz/" || StoppedKey(p, "source") != p+"stopped-source.json" || TargetReportKey(p) != p+"target.json" || SourceReportKey(p) != p+"source.json" || LogKeyFor(p, "cutting-flipped") != p+"log/cutting-flipped.json" { |
| t.Fatal("deployment-bucket keys") |
| } |
| // The park scope grammar has no trailing slash (claim rev 6.23 §2). |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 318fa9e46..3fa6ea9b6 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -516,37 +516,37 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Copies config acknowledgements to moves/<account>/<app>/<host>/<op>/config-ack-source.json. |
| {"internal/host/movesfinal.go", "sourceFinal.configAck", "nonrecord", "sel:Put", "cca53109e6945a73b7f04705d8965b3b0997f2b7d2a82fa6d86cffa4b4174e93"}, |
| // Stamps source_parked_at through SetRecordMove, updateRecord and WriteRecord. |
| - {"internal/host/movesfinal.go", "sourceFinal.stampParked", "record-writer", "key:SetRecordMove", "8af033d29c8e2275d996df9a4e53227da30a15e8fd4c198a4ab40865db7fabb9"}, |
| + {"internal/host/movesfinal.go", "sourceFinal.stampParked", "record-writer", "key:SetRecordMove", "5eeb2386021980ea668123d3acb2d2ccefce9a8561e32be8937504b115dd5ffb"}, |
| // Applies install transitions only through SetRecordMove and its sequenced writer. |
| - {"internal/host/movesinstall.go", "installRun.transition", "record-writer", "key:SetRecordMove", "5679de7898730c831906e5350529a67259d85ad0a94132ac86175a1102e21753"}, |
| + {"internal/host/movesinstall.go", "installRun.transition", "record-writer", "key:SetRecordMove", "1c6d390393de6065acc6d11409a493ac2fc49299b1f18f08c329a8af68f88c20"}, |
| // The refusal callback marks the return stuck through SetRecordMove; other callbacks only read or mutate memory. |
| - {"internal/host/movesinstall.go", "recoveryInstallSource", "record-writer", "key:SetRecordMove", "b4bb0798a032c1599b9063a165656ab76c00b6ea349cb17b5f6a95769432bd5f"}, |
| + {"internal/host/movesinstall.go", "recoveryInstallSource", "record-writer", "key:SetRecordMove", "2b5c592c20612eb5edb58856d05755244ea25bec6aa65632c6644c274a69474e"}, |
| // Commits the landing through SetRecordMove (MoveCommit), its refusal the rollback mark. |
| - {"internal/host/moveslandcommit.go", "landingRun.probed", "record-writer", "key:SetRecordMove", "6f961790d8fe169af68e2f70790c400d37c2388a4f62c26921f22b8b47c859de"}, |
| + {"internal/host/moveslandcommit.go", "landingRun.probed", "record-writer", "key:SetRecordMove", "d914debf1ba5d011767fc06cb430c09e39a469dfb182f5996fa3bda806499952"}, |
| // Stamps opened_at through SetRecordMove (MoveOpened) inside the hold lock, after the proved release. |
| - {"internal/host/moveslandcommit.go", "landingRun.open", "record-writer", "key:SetRecordMove", "65e6908056c3055d5d0df2c27bb88943e31c08691a2173a090a9edb94c8c0b49"}, |
| + {"internal/host/moveslandcommit.go", "landingRun.open", "record-writer", "key:SetRecordMove", "febac996d3bb9caf4021cbc4bcffe1c900bddba4287ff95f083bd5e0751e9bc2"}, |
| // Closes a committed move through SetRecordMove (MoveClose, LastMove done) after the claim's ReleaseMove; never writes the claim after the record. |
| - {"internal/host/movesparks.go", "movesWorker.closeMove", "record-writer", "key:SetRecordMove", "849d114cf80a45004e9d01c66fcf11516381b046fe0f7c40294a628a3b922b90"}, |
| + {"internal/host/movesparks.go", "movesWorker.closeMove", "record-writer", "key:SetRecordMove", "da86a00f3ce1b8a9d885c9b533990beca7f4f9170d8ae3966702383342828984"}, |
| // Closes the rollback record through SetRecordMove; claim release and local journal parking use separate storage. |
| - {"internal/host/movesrecover.go", "recoverRun.finalize", "record-writer", "key:SetRecordMove", "fff2e28c78db68bccb16da6ab70ca877b51243b0aa56b9cb2c513c6f5660f7f7"}, |
| + {"internal/host/movesrecover.go", "recoverRun.finalize", "record-writer", "key:SetRecordMove", "7c22716ee494d5f1c83ed2ae46fe8a08af37e12a10779208d87dc2c00471e347"}, |
| // Advances the source recovery phase through SetRecordMove and its sequenced writer. |
| - {"internal/host/movesrecover.go", "recoverRun.phase", "record-writer", "key:SetRecordMove", "d67132b6e797a88a9677d962c5c7aa30fc7f85fdbdff06ce8318562397a9ee78"}, |
| + {"internal/host/movesrecover.go", "recoverRun.phase", "record-writer", "key:SetRecordMove", "be641eaba77b940ce471696720b637b9dfb9454de0893aca1fcc40750135719d"}, |
| // Records probe inputs through SetRecordMove; phase and stuck helpers use the same writer. |
| {"internal/host/movesrecover.go", "recoverRun.probing", "record-writer", "key:SetRecordMove", "a0144975bf52b29bbc6c2354636d3665487365d24838d3ef4baef1c665ce52ab"}, |
| // Marks recovery stuck through SetRecordMove; event and serial writes are not record objects. |
| - {"internal/host/movesrecover.go", "recoverRun.stuck", "record-writer", "key:SetRecordMove", "4ea620140bbc75b127a893926eefcf58685b40c8362f86ba94f22931a05b449a"}, |
| + {"internal/host/movesrecover.go", "recoverRun.stuck", "record-writer", "key:SetRecordMove", "d634bbd18794aedf081c51f6e3c16b62a9b6b82b21b24f636b949c840c8e5776"}, |
| // Records revert inputs through SetRecordMove; stuck uses the reviewed record helper and journals are local. |
| {"internal/host/movesrevert.go", "revertRun.probing", "record-writer", "key:SetRecordMove", "55e249b195b2325917763cdc87cfcd44563d95095918d696f14fa5cec1859898"}, |
| // Applies revert record mutations only through SetRecordMove and its sequenced writer. |
| - {"internal/host/movesrevert.go", "revertRun.record", "record-writer", "key:SetRecordMove", "843bea75b1a185cb4294e4e1afc65f44182767b489c6e604b96f829ae59909b0"}, |
| + {"internal/host/movesrevert.go", "revertRun.record", "record-writer", "key:SetRecordMove", "f42100f118eb62ceece23b49423bda2ef357f1aa065760b288bf20340e36a920"}, |
| // Starts rollback through SetRecordMove; the companion claim update targets the separate claim namespace. |
| - {"internal/host/movesrollback.go", "movesWorker.rollBackLanding", "record-writer", "key:SetRecordMove", "40ff87900fbfacb5a4dff481db37ae7dbed41e5727f13185ba8623daf3e1681e"}, |
| + {"internal/host/movesrollback.go", "movesWorker.rollBackLanding", "record-writer", "key:SetRecordMove", "c834e9bb708ef05fbdb80d1677bc409ae02aaa8cae93fa5767fde71f446a571e"}, |
| // Writes only moves/<host>/<rollback>/move.json in the guarded staging bucket. |
| {"internal/host/movesrollback.go", "rollbackRun.laneMoveJSON", "nonrecord", "sel:PutIfAbsentVersioned", "5fd541f4a1daf2600ff266583fc9627cc0cab35e276f1d9c8f097f128c2c4e53"}, |
| // Advances the target rollback phase through SetRecordMove and its sequenced writer. |
| - {"internal/host/movesrollback.go", "rollbackRun.phase", "record-writer", "key:SetRecordMove", "76f1efb1076029e5da4c0cc0d750fbac5e1c94a6014c2b1e4af241797350827b"}, |
| + {"internal/host/movesrollback.go", "rollbackRun.phase", "record-writer", "key:SetRecordMove", "72369c7aafffb3df49f9b6e0f493e21967dd7262e32ddcb6eaff0dc9ff255d33"}, |
| // Marks the return stuck through SetRecordMove; event and serial writes are not record objects. |
| - {"internal/host/movesrollback.go", "rollbackRun.stuck", "record-writer", "key:SetRecordMove", "84fcea399a55d77ceee17e35652ef269e5811453ffff62dff43208b68538b1ed"}, |
| + {"internal/host/movesrollback.go", "rollbackRun.stuck", "record-writer", "key:SetRecordMove", "ba42217c6ce7fee0afc051f2a34565e6e0af7a6d97ec7c1a983391414ef03b35"}, |
| // Validates the pointer-digest marker key and Put/Delete method names without writing. |
| {"internal/hostops/mutation_classifier.go", "classifyPointerDigestMarker", "reference", "str:Put", "08111b5db5d98e788d1eef5861ad9c861cf926c5e1d5d54717fd650d2dcf9fa8"}, |
| // Validates the promotion-record key (releases/<acct>/<app>/promoted/<version>, a release object, not a |
| @@ -660,16 +660,16 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/fleetbind.go", "WriteFleetBinding", "nonrecord", "sel:Put", "44ab984c2c9b30abcc938132118f61a6922ccc0cd40ed2b6cb61c7654aa0a1c3"}, |
| {"internal/host/logship.go", "logshipFlush", "nonrecord", "sel:Put", "a2fa594c1206028af170a2d0cbedef55ba00a2f1c698a6853e1214cc3b172dd9"}, |
| {"internal/host/metrics_loop.go", "putAlertState", "nonrecord", "sel:Put", "d5b6e8b34dc5a731d6bbafeac0b7d0c5c20706d6f413dff38088b3321b35a26c"}, |
| - {"internal/host/movesabort.go", "movesWorker.abortSource", "record-writer", "key:SetRecordMove", "70f2cd7ec7709045043081c078f1abe63ef822325eef7016ab9f595a16cabad5"}, |
| + {"internal/host/movesabort.go", "movesWorker.abortSource", "record-writer", "key:SetRecordMove", "07fe8eeb9224485c313929549234e429d5a7bf62f060e07560e0072cb3d5e667"}, |
| {"internal/host/movesarchive.go", "cutRun.publishCutArchive", "nonrecord", "sel:CopyVersioned,sel:PutIfAbsent", "6c7fcfd2d4b2a0319ac15ecbfe003ed0292fe160457fd5704be508c2ab302a1b"}, |
| - {"internal/host/movescut.go", "cutRun.cutTransition", "record-writer", "key:SetRecordMove", "d0d67399aa784036de43b3c143e88c3d85f8b3d9e6345f84908d3b58ff56191e"}, |
| + {"internal/host/movescut.go", "cutRun.cutTransition", "record-writer", "key:SetRecordMove", "3ec34659c98aa9ec12221b30363d0e79a3b2d77b99051e0484f2a923c884c0af"}, |
| {"internal/host/movescut.go", "publishStoppedReport", "nonrecord", "sel:Put", "c710682f31d99127ad76524add246fda9f640c63934940e45c79ea25ba36eb3c"}, |
| {"internal/host/movescut.go", "cutRun.snapshotTimers", "nonrecord", "sel:PutIfAbsent", "9137042d3c269f8cfc94bd9d04cde24423e8824986521906c73159cd0a552ca4"}, |
| {"internal/host/movesguard.go", "guardedMirror.CopyVersioned", "versioned-transport", "def:CopyVersioned,sel:CopyVersionedAt", "069edf385ccc13d50452bd97eae222c0312a629a48d619c922f9985aa1c99a7b"}, |
| {"internal/host/movesguard.go", "guardedMirror.Copy", "versioned-transport", "sel:CopyVersioned", "1689745fdd642c0dbe855b12b8d1ef220f3a9c3daed7ca49bdeddfd6ff9d5ade"}, |
| {"internal/host/movesguard.go", "guardedMirror.PutIfAbsentVersioned", "versioned-transport", "def:PutIfAbsentVersioned,sel:PutIfAbsentVersionedAt", "68457755ed3c8b470064e9ccbefe42c819f877d596352669d7126d6032a7726f"}, |
| {"internal/host/movesguard.go", "guardedMirror.Put", "versioned-transport", "def:Put,sel:Put", "a3168524604a41ea42e8fcf6e5a1335e4ea7be77fde089aca6ad74dc64b43649"}, |
| - {"internal/host/movesland.go", "landingRun.transition", "record-writer", "key:SetRecordMove", "c943a81b6ddba9cba138710de340bc238689e0122d12953d1bb4c84e95735f8e"}, |
| + {"internal/host/movesland.go", "landingRun.transition", "record-writer", "key:SetRecordMove", "0b197834835dc9afad77f89358d2bcab9512128dcf43070a3765e430526dbfb1"}, |
| {"internal/host/moveslandpark.go", "leasedStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "a8b33c9f61f59477b76fa50472250622c717ab2883f32837c601da5eeb497e9d"}, |
| {"internal/host/moveslandpark.go", "leasedStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "33542589bf2a336b181f685bd0dc5faa0285449e5ca9e850e9f4e0e7e4164c6b"}, |
| {"internal/host/movesmeasure.go", "measureS3", "nonrecord", "sel:Put", "3ae9fc093b453e19a5b19be0929533b30a0e2a3e998ba8dc766f41287db2caca"}, |
| @@ -678,7 +678,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "c5bc2e6cb57b819ae1da485d37093c9a9ed3cdc9e9e16e8a8e312fd5ad2e2fe3"}, |
| {"internal/host/movesphase1.go", "movesWorker.refreshCrossRate", "nonrecord", "sel:CopyVersioned", "7e03e680bd61be26dddcf168cacd2427bb535571dfd326c4a3130ebe65ea1968"}, |
| {"internal/host/movesphase1.go", "movesWorker.writeSourceReport", "nonrecord", "sel:PutReplace", "370407cca2c1b91f6e5e7f135238f04c0dce9d7986e6e1ee9bebbf88cad0b9e0"}, |
| - {"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "c228e6f38eca6ed10501538031d3260f016a3f26ce501d8544f1581ae7668d8f"}, |
| + {"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "927e1d864c3232ef17dfa7e9329ee26e870db75da128a8101234610a762e6f9a"}, |
| {"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "3f6aec125f1bd40271adf78833ac3268e536cbc5e8af3731b16f382669cd955b"}, |
| {"internal/host/movesstore.go", "laneProbe", "nonrecord", "sel:PutIfAbsent", "f4a17a1143e7370b9039b2e66e736606015c46619bab92c553ce7b1ad6b7918b"}, |
| {"internal/host/snapshot_loop.go", "registrySnapshotPass", "nonrecord", "sel:Put", "6b01b46b361c3a2d1d432659a0d234c01975849b9a8ac5571858fdd78d376849"}, |
| @@ -803,6 +803,9 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/moves/build.go", "putOrReuse", "nonrecord", "sel:PutIfAbsent", "932e5852c1d7c747db1013954352bbeb00364329d3515ae723a144b074567562"}, |
| {"internal/moves/build.go", "putOrReadIntent", "nonrecord", "sel:PutIfAbsent", "d53112f8f9c0c48844f930d2b9ae5e58b75588ef31b0a3740b65a61c83fba613"}, |
| {"internal/moves/build.go", "putOrReadManifest", "nonrecord", "sel:PutIfAbsent", "3187b04355f4ec75b86edf311a732c6cfecd722322b509f0731479f738b52a85"}, |
| + // Plan 6 Task 3: the move log, create-only objects under the report |
| + // prefix; no record. |
| + {"internal/moves/log.go", "WriteLog", "nonrecord", "sel:Put", "a3803e1ac4a43da86fb60d7fc5395ee2b0e9e8f86f33bf13bc251795dc3529cf"}, |
| {"internal/moves/readiness.go", "PutReplace", "nonrecord", "def:PutReplace,sel:Put", "3ec4d72cb252b34a402175cb7866dee4ab4f5b2441958f9fc455f687ffb2eafd"}, |
| {"internal/objstore/entitlement.go", "PutEntitlement", "nonrecord", "sel:Put", "35b3734ff8587089c913ba7151f17955e9b1f7456a2f5ae1b3fb0e6917104005"}, |
| {"internal/objstore/memscanner.go", "MemScanner.Put", "nonrecord", "def:Put", "bef5afd48d145dafdcbda197b4ad221766833d1c8ba8223f81df98495c860a4b"}, |