carlos / platform Public

moves-phase1-6 Task 3 fix round 2: durable log outboxes, assembled recovery attempts, bounded memo, grant Log conditions

- Box: a failed move log write is kept, with its bytes, in
  <DataDir>/.moves/.log-outbox; every pass retries it whatever the record
  says, deleting it only once created or found. Member lines are built
  from the entry alone (cut_budget_s joins the entry's fields).
- Console: entries go to a move_log_outbox table and are written by the
  move's next pass through a fresh grant; the release waits for them (at
  most an hour) and writes its own entry just before it. Entries come
  from the write's own evidence (MoveRecoveryGrant.Transition).
- Recovery probe starts log from the record their binding read, through
  movesWorker.probeAttempts, which wires RecoveryAttempt.
- The dedupe memo is bounded (4096, oldest first) and retires a host's
  moves when a pass sees its record with no move or another one.
- MoveRecoveryGrant.Log(name, body): key from the grant's own move,
  single-segment name equal to the identity, 4 KiB bound, create-only
  with same-bytes replay a no-op and ErrMoveLogTaken otherwise, validity
  through current(); authority and B8 census rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9ab54cf8edee89e3bdc89e3dda207e509941588e parent bb14d6f
16 files changed, +1061 −247
  • approved_moves_copy_test.go +1 −0
  • internal/console/claim_authority_census_test.go +3 −0
  • internal/console/movesrecovery.go +121 −40
  • internal/console/movesrecovery_test.go +131 −6
  • internal/console/store.go +52 −0
  • internal/console/store_gate.go +1 −0
  • internal/console/testdata/claim-authority-sites.txt +2 −0
  • internal/host/movesevents.go +140 −37
  • internal/host/movesevents_test.go +222 −85
  • internal/host/moveslogoutbox.go +169 −0
  • internal/host/movespass.go +9 −3
  • internal/host/movesprobeattempts.go +46 −27
  • internal/hostops/move_recovery_grant.go +99 −21
  • internal/hostops/move_recovery_grant_test.go +52 −23
  • internal/moves/transition.go +8 −4
  • internal/provision/plan9_b8_census_test.go +5 −1
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index cf515305c..4bae046d7 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -593,6 +593,7 @@ var movesScanFiles = []string{
"internal/host/movesfloorgate_linux.go",
"internal/host/movesfloorgate_other.go",
"internal/host/movesevents.go",
+ "internal/host/moveslogoutbox.go",
"internal/host/movesabort.go",
"internal/host/movesarchive.go",
"internal/host/movescut.go",
diff --git a/internal/console/claim_authority_census_test.go b/internal/console/claim_authority_census_test.go
index 455e34e1f..6a88a348c 100644
--- a/internal/console/claim_authority_census_test.go
+++ b/internal/console/claim_authority_census_test.go
@@ -29,6 +29,9 @@ func claimAuthoritySites(t *testing.T) []string {
// The console's move pass (moves Plan 5 Task 7): every write of the
// move-recovery grant, and the lost target's stamp.
"ResolveStart": true, "Convert": true, "Claim": true, "Record": true, "StampLostMoveTargetParked": true, "stampLostMoveTarget": true,
+ // The move log the pass writes through the grant (Plan 6 Task 3, fix
+ // round 2 ruling 4): an entry of the grant's own move, create-only.
+ "Log": true,
"BeginPurge": true, "SavePurgeJournal": true, "RemovePurgeJournal": true,
"AcquireMailGate": true, "ReleaseMailGate": true, "EnrollNewApp": true,
"EnrollBackfillApp": true, "OpenGated": true, "WithAppMutation": true,
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go
index 080fa5570..c35b83150 100644
--- a/internal/console/movesrecovery.go
+++ b/internal/console/movesrecovery.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
+ "encoding/json"
"errors"
"fmt"
"log"
@@ -282,6 +283,9 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant
return err
}
now := s.movesNow()
+ // What earlier passes owe the move's log is written first, while the
+ // grant is unspent (MoveRecoveryGrant.Log).
+ pending, oldest := s.flushMoveLog(ctx, g, o)
in := moveRecoveryInput{Now: now, Claim: c, Rec: rec}
ours := rec.Move != nil && rec.Move.ID == o.OpID
if c.Op.Kind == hostclaim.KindMoving && c.Op.Phase == hostclaim.PhaseMoving && !ours {
@@ -327,19 +331,35 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant
return err
}
log.Printf("console: moves: %s: move %s is still preparing after %s; aborted", o.Host, o.OpID, moves.AbortAfter)
- s.logMoveWrite(ctx, g, o, rec)
+ if t, ok := abortTransition(rec); ok {
+ s.queueMoveLog(o, t, now)
+ }
return nil
case moveActClaim:
+ if d.Claim == hostops.RecoveryMoveRelease {
+ // The release ends what the console can log for the move: its
+ // owed entries go first, for at most moveLogReleaseWait, and the
+ // release's own entry is written just before it.
+ if pending > 0 && now.Sub(oldest) < moveLogReleaseWait {
+ log.Printf("console: moves: %s: the release of move %s waits for %d unwritten log entries", o.Host, o.OpID, pending)
+ return nil
+ }
+ s.logMoveNow(ctx, g, o, claimTransition(o, rec, "claim_released", "claim-released"), now)
+ }
if err := g.Claim(ctx, d.Claim); err != nil {
return err
}
- s.logMoveClaim(ctx, g, o, rec, d.Claim)
+ if d.Claim == hostops.RecoveryMoveRollback {
+ s.queueMoveLog(o, claimTransition(o, rec, "claim_rollback", "claim-rollback"), now)
+ }
return nil
case moveActRecord:
if err := g.Record(ctx, d.Record); err != nil {
return err
}
- s.logMoveWrite(ctx, g, o, rec)
+ if t, ok := g.Transition(); ok {
+ s.queueMoveLog(o, t, now)
+ }
return nil
case moveActStampLost:
if s.stampLostMoveTarget == nil {
@@ -354,7 +374,9 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant
return err
}
log.Printf("console: moves: %s: move %s closed by the console (%s); the release follows", o.Host, o.OpID, d.Record)
- s.logMoveWrite(ctx, g, o, rec)
+ if t, ok := g.Transition(); ok {
+ s.queueMoveLog(o, t, now)
+ }
s.pageMove(ctx, movePageCloseOverdue, o, c, rec, now)
return nil
case moveActAbortOverdue:
@@ -525,51 +547,110 @@ func moveAbortOverduePage(host, id, to, box string) (subject, body string) {
return subject, body
}
-// The move log of the console's own move writes (Plan 6 Task 3, fix round 1,
-// ruling 3): the six-hour abort, the landing deadline's rollback, the closes,
-// and the claim's release and rollback. The console has no member sink; the
-// durable log under the move's report prefix is what counts, written through
-// the grant (MoveRecoveryGrant.Log, moves.WriteLog), which names no store.
-// Each entry is named as a box's write of the same transition would be
-// (moves.TransitionOf) and written create-only, so a box that also logs it
-// changes nothing. A failed write is logged and not retried: the pass that
-// made the write does not see it again.
-
-// logMoveWrite logs the record write this pass just made: rec as the pass
-// read it, against the record now.
-func (s *Server) logMoveWrite(ctx context.Context, g *hostops.MoveRecoveryGrant, o hostops.ConsoleMoveObservation, rec provision.Record) {
- after, err := provision.GetRecord(ctx, s.cfg.Releases, o.Account, o.App, o.Host)
+// The move log of the console's own move writes (Plan 6 Task 3; fix round 2,
+// rulings 1 and 4): the closes, the landing deadline's rollback, the six-hour
+// abort, and the claim's rollback and release. Each entry is named as a box's
+// write of the same transition would be (moves.TransitionOf), from the
+// write's own evidence (the grant's Transition, or the record the pass
+// acted on), and carries the console as its actor; the console has no member
+// sink.
+//
+// The grant writes only while unspent, so an entry a pass's write makes is
+// queued in the console's outbox (move_log_outbox, with its exact bytes) and
+// written by the move's next pass before it acts. An entry stays queued until
+// it is written or found written, whatever the move does meanwhile. The
+// release is the last pass with a grant for the move, so it waits for the
+// move's queued entries, for at most moveLogReleaseWait (a log that cannot be
+// written must not hold a claim forever), and writes its own entry just
+// before it releases. Entries still queued after the release stay in the
+// outbox, logged; no later grant can write them. A release of a start never
+// applied is not logged: the record never named that move.
+
+// moveLogReleaseWait bounds how long the claim's release waits for the
+// move's unwritten log entries.
+const moveLogReleaseWait = time.Hour
+
+// flushMoveLog writes the move's queued entries through the grant: the count
+// still unwritten, and when the oldest of them was queued.
+func (s *Server) flushMoveLog(ctx context.Context, g *hostops.MoveRecoveryGrant, o hostops.ConsoleMoveObservation) (int, time.Time) {
+ queued, err := s.store.MoveLogPending(o.Host, o.OpID)
if err != nil {
- log.Printf("console: moves: %s: the log of move %s: the record: %v", o.Host, o.OpID, err)
- return
+ log.Printf("console: moves: %s: the log outbox of move %s: %v", o.Host, o.OpID, err)
+ return 0, time.Time{}
}
- if e, ok := moves.TransitionOf(rec, *after); ok {
- s.writeMoveLog(ctx, g, o, e)
+ n, oldest := 0, time.Time{}
+ for _, e := range queued {
+ _, err := g.Log(ctx, e.Name, e.Body)
+ switch {
+ case err == nil:
+ case errors.Is(err, hostops.ErrMoveLogTaken):
+ log.Printf("console: moves: %s: the log of move %s at %s was written by another writer first", o.Host, o.OpID, e.Name)
+ case errors.Is(err, hostops.ErrIdentity):
+ log.Printf("console: moves: %s: the log of move %s at %s is refused, and dropped: %v", o.Host, o.OpID, e.Name, err)
+ default:
+ log.Printf("console: moves: %s: the log of move %s at %s waits for the next pass: %v", o.Host, o.OpID, e.Name, err)
+ if n == 0 || e.QueuedAt.Before(oldest) {
+ oldest = e.QueuedAt
+ }
+ n++
+ continue
+ }
+ if err := s.store.SettleMoveLog(o.Host, o.OpID, e.Name); err != nil {
+ log.Printf("console: moves: %s: the log outbox of move %s: %v", o.Host, o.OpID, err)
+ }
}
+ return n, oldest
}
-// logMoveClaim logs a claim write that ends or turns the move: the release
-// after a close (or of a start never applied) and the claim's rollback.
-func (s *Server) logMoveClaim(ctx context.Context, g *hostops.MoveRecoveryGrant, o hostops.ConsoleMoveObservation, rec provision.Record, action hostops.MoveRecoveryClaimAction) {
- var e moves.Transition
- switch action {
- case hostops.RecoveryMoveRelease, hostops.RecoveryMoveReleaseNotApplied:
- e = moves.Transition{Op: o.OpID, Event: "claim_released", Identity: "claim-released"}
- case hostops.RecoveryMoveRollback:
- e = moves.Transition{Op: o.OpID, Event: "claim_rollback", Identity: "claim-rollback"}
- default:
+// moveLogEntry is the console's entry for t.
+func moveLogEntry(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time) ([]byte, error) {
+ return json.Marshal(moves.LogEvent{V: 1, Op: t.Op, Host: o.Host, Event: t.Event, Identity: t.Identity, Actor: "console", At: now.UTC(), Fields: t.Fields})
+}
+
+// queueMoveLog queues the entry for t, which the move's next pass writes.
+func (s *Server) queueMoveLog(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time) {
+ body, err := moveLogEntry(o, t, now)
+ if err == nil {
+ err = s.store.QueueMoveLog(o.Host, o.OpID, t.Identity, body, now)
+ }
+ if err != nil {
+ log.Printf("console: moves: %s: the log of move %s at %s is lost: %v", o.Host, o.OpID, t.Identity, err)
+ }
+}
+
+// logMoveNow writes the entry for t through the grant, or queues it.
+func (s *Server) logMoveNow(ctx context.Context, g *hostops.MoveRecoveryGrant, o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time) {
+ body, err := moveLogEntry(o, t, now)
+ if err != nil {
+ log.Printf("console: moves: %s: the log of move %s at %s is lost: %v", o.Host, o.OpID, t.Identity, err)
return
}
- if m := rec.Move; m != nil {
- e.Fields = map[string]string{"from": m.From, "to": m.To}
+ if _, err := g.Log(ctx, t.Identity, body); err != nil && !errors.Is(err, hostops.ErrMoveLogTaken) {
+ log.Printf("console: moves: %s: the log of move %s at %s is queued: %v", o.Host, o.OpID, t.Identity, err)
+ if err := s.store.QueueMoveLog(o.Host, o.OpID, t.Identity, body, now); err != nil {
+ log.Printf("console: moves: %s: the log of move %s at %s is lost: %v", o.Host, o.OpID, t.Identity, err)
+ }
}
- s.writeMoveLog(ctx, g, o, e)
}
-// writeMoveLog writes one console entry.
-func (s *Server) writeMoveLog(ctx context.Context, g *hostops.MoveRecoveryGrant, o hostops.ConsoleMoveObservation, e moves.Transition) {
- ev := moves.LogEvent{V: 1, Op: e.Op, Host: o.Host, Event: e.Event, Identity: e.Identity, Actor: "console", At: s.movesNow().UTC(), Fields: e.Fields}
- if _, err := g.Log(ctx, ev); err != nil {
- log.Printf("console: moves: %s: the log of move %s at %s: %v", o.Host, o.OpID, e.Identity, err)
+// abortTransition is the six-hour abort's entry: the record the pass acted
+// on, as the abort leaves it (aborting).
+func abortTransition(rec provision.Record) (moves.Transition, bool) {
+ if rec.Move == nil {
+ return moves.Transition{}, false
+ }
+ after, m := rec, *rec.Move
+ m.State = provision.MoveAborting
+ after.Move = &m
+ return moves.TransitionOf(rec, after)
+}
+
+// claimTransition is the entry for a claim write of the console's: the move's
+// regions from the record the pass acted on.
+func claimTransition(o hostops.ConsoleMoveObservation, rec provision.Record, event, identity string) moves.Transition {
+ t := moves.Transition{Op: o.OpID, Event: event, Identity: identity}
+ if m := rec.Move; m != nil {
+ t.Fields = map[string]string{"from": m.From, "to": m.To}
}
+ return t
}
diff --git a/internal/console/movesrecovery_test.go b/internal/console/movesrecovery_test.go
index 3148590e4..99a2dd29b 100644
--- a/internal/console/movesrecovery_test.go
+++ b/internal/console/movesrecovery_test.go
@@ -4,6 +4,8 @@ import (
"context"
"crypto/rand"
"encoding/json"
+ "os"
+ "path/filepath"
"strings"
"sync"
"testing"
@@ -734,10 +736,14 @@ func moveLogOf(t *testing.T, w *movePassWorld) map[string]moves.LogEvent {
return out
}
-// Plan 6 Task 3 fix round 1, ruling 3: the console's move pass writes the
-// durable move log for the transitions it makes: the close and the claim's
-// release after it, the landing deadline's rollback, and the six-hour abort.
-// Each entry is the console's, under the identity the box would give it.
+// Plan 6 Task 3 fix round 1, ruling 3, and fix round 2, ruling 1: the
+// console's move pass logs the transitions it makes: the close and the
+// claim's release after it, the landing deadline's rollback, and the six-hour
+// abort. Each entry is the console's, under the identity the box would give
+// it. The grant logs only while it is unspent, so an entry made by a pass's
+// write is kept in the console's outbox and written by the move's next pass,
+// before that pass acts; the release's own entry is written just before the
+// release.
func TestRecoveryPassWritesTheMoveLog(t *testing.T) {
t.Run("close and release", func(t *testing.T) {
w := landingMovePass(t, "code-mvpass-log-close")
@@ -745,19 +751,24 @@ func TestRecoveryPassWritesTheMoveLog(t *testing.T) {
w.pass(t) // the close's grace runs from the pass's first sighting
w.advance(moveCloseGrace + time.Minute)
w.pass(t) // the close
- w.pass(t) // the claim's release
+ w.pass(t) // the close's entry, then the claim's release
log := moveLogOf(t, w)
done, released := log["done"], log["claim-released"]
if done.Event != "done" || done.Actor != "console" || done.Op != w.id || released.Event != "claim_released" || released.Actor != "console" {
t.Fatalf("log %+v", log)
}
+ if done.Fields["offline_s"] == "" || done.Fields["from"] != "ie" {
+ t.Fatalf("the close's entry lacks its evidence: %+v", done.Fields)
+ }
+ noPendingLog(t, w)
})
t.Run("deadline rollback", func(t *testing.T) {
w := landingMovePass(t, "code-mvpass-log-deadline")
past := time.Now().UTC().Add(-time.Minute)
j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move.LandingDeadline = &past })
w.pass(t)
- if ev := moveLogOf(t, w)["rolling_back"]; ev.Event != "rolling_back" || ev.Actor != "console" {
+ w.pass(t)
+ if ev := moveLogOf(t, w)["rolling_back"]; ev.Event != "rolling_back" || ev.Actor != "console" || ev.Fields["reason"] == "" {
t.Fatalf("log %+v", moveLogOf(t, w))
}
})
@@ -768,8 +779,122 @@ func TestRecoveryPassWritesTheMoveLog(t *testing.T) {
w.clock = requested.Add(moves.AbortAfter + time.Minute)
w.mu.Unlock()
w.pass(t)
+ w.pass(t)
if ev := moveLogOf(t, w)["aborting"]; ev.Event != "aborting" || ev.Actor != "console" {
t.Fatalf("log %+v", moveLogOf(t, w))
}
})
}
+
+// blockMoveLog makes every write under the move's log fail (a file where
+// its directory goes) until the returned func is called.
+func blockMoveLog(t *testing.T, w *movePassWorld) func() {
+ t.Helper()
+ root := w.fx.rel.(*deletableStore).root
+ p := filepath.Join(root, filepath.FromSlash(moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", w.id)+"log"))
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return func() {
+ if err := os.Remove(p); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func pendingLog(t *testing.T, w *movePassWorld) []string {
+ t.Helper()
+ got, err := w.fx.st.MoveLogPending("acme.go.tito.io", w.id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var names []string
+ for _, e := range got {
+ names = append(names, e.Name)
+ }
+ return names
+}
+
+func noPendingLog(t *testing.T, w *movePassWorld) {
+ t.Helper()
+ if p := pendingLog(t, w); len(p) != 0 {
+ t.Fatalf("entries still pending: %v", p)
+ }
+}
+
+// Fix round 2, ruling 1: a console entry whose write fails is kept, with its
+// bytes, in the console's outbox, and written by a later pass after the move
+// has moved on. The claim's release waits for the move's pending entries
+// (at most moveLogReleaseWait), so the close's entry is not stranded by it.
+func TestRecoveryPassLogOutboxRetries(t *testing.T) {
+ t.Run("the move closes, then the retry", func(t *testing.T) {
+ w := landingMovePass(t, "code-mvpass-outbox-close")
+ committedAndOpened(t, w)
+ w.pass(t)
+ w.advance(moveCloseGrace + time.Minute)
+ unblock := blockMoveLog(t, w)
+ w.pass(t) // the close; its entry is pending
+ if rec := w.record(t); rec.Move != nil {
+ t.Fatalf("not closed: %+v", rec.Move)
+ }
+ w.pass(t) // the write fails again: the release waits
+ if p := pendingLog(t, w); strings.Join(p, " ") != "done" {
+ t.Fatalf("pending %v, want the close's entry", p)
+ }
+ if c := w.claim(t); c.Op.Kind != hostclaim.KindLanding {
+ t.Fatalf("released with the close's entry unwritten: %+v", c.Op)
+ }
+ unblock()
+ w.pass(t)
+ log := moveLogOf(t, w)
+ if log["done"].Actor != "console" || log["claim-released"].Actor != "console" {
+ t.Fatalf("log %+v", log)
+ }
+ if c := w.claim(t); c.Op.Kind != hostclaim.KindServing {
+ t.Fatalf("not released once the entry was written: %+v", c.Op)
+ }
+ noPendingLog(t, w)
+ })
+ t.Run("the phase advances, then the retry", func(t *testing.T) {
+ w := landingMovePass(t, "code-mvpass-outbox-deadline")
+ past := time.Now().UTC().Add(-time.Minute)
+ j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move.LandingDeadline = &past })
+ unblock := blockMoveLog(t, w)
+ w.pass(t) // the record's rollback; its entry is pending
+ w.pass(t) // the claim's rollback; the write fails again
+ if p := pendingLog(t, w); len(p) != 2 || p[0] != "rolling_back" {
+ t.Fatalf("pending %v, want the rollback's entries", p)
+ }
+ j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move.Rollback.TargetPhase = provision.RbTargetReleasing })
+ unblock()
+ w.pass(t)
+ log := moveLogOf(t, w)
+ if log["rolling_back"].Actor != "console" || log["claim-rollback"].Actor != "console" {
+ t.Fatalf("log %+v", log)
+ }
+ noPendingLog(t, w)
+ })
+ t.Run("the release does not wait past its bound", func(t *testing.T) {
+ w := landingMovePass(t, "code-mvpass-outbox-bound")
+ committedAndOpened(t, w)
+ w.pass(t)
+ w.advance(moveCloseGrace + time.Minute)
+ blockMoveLog(t, w)
+ w.pass(t) // the close
+ w.pass(t)
+ if c := w.claim(t); c.Op.Kind != hostclaim.KindLanding {
+ t.Fatalf("released early: %+v", c.Op)
+ }
+ w.advance(moveLogReleaseWait + time.Minute)
+ w.pass(t)
+ if c := w.claim(t); c.Op.Kind != hostclaim.KindServing {
+ t.Fatalf("the release waited past its bound: %+v", c.Op)
+ }
+ if p := pendingLog(t, w); len(p) == 0 {
+ t.Fatal("the unwritten entries were dropped")
+ }
+ })
+}
diff --git a/internal/console/store.go b/internal/console/store.go
index 51d0619d6..7e848aada 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -358,6 +358,17 @@ func (s *Store) migrate() error {
`CREATE TABLE IF NOT EXISTS move_pages (
rule TEXT PRIMARY KEY,
attempted_at INTEGER NOT NULL
+);`,
+ // The move pass's outbox (movesrecovery.go, Plan 6 Task 3): move log
+ // entries the pass owes and has not yet written, with their exact
+ // bytes, until a pass's grant writes them or finds them written.
+ `CREATE TABLE IF NOT EXISTS move_log_outbox (
+ host TEXT NOT NULL,
+ op TEXT NOT NULL,
+ name TEXT NOT NULL,
+ body BLOB NOT NULL,
+ queued_at INTEGER NOT NULL,
+ PRIMARY KEY (host, op, name)
);`,
} {
if _, err := s.db.Exec(stmt); err != nil {
@@ -2213,6 +2224,47 @@ ON CONFLICT(rule) DO UPDATE SET attempted_at = excluded.attempted_at`, rule, at.
return err
}
+// MoveLogPendingEntry is one move log entry the move pass owes.
+type MoveLogPendingEntry struct {
+ Name string
+ Body []byte
+ QueuedAt time.Time
+}
+
+// QueueMoveLog keeps an entry the move pass could not write yet. An entry
+// already queued under the name is kept as it was.
+func (s *Store) QueueMoveLog(host, op, name string, body []byte, at time.Time) error {
+ _, err := s.db.Exec(`INSERT INTO move_log_outbox (host, op, name, body, queued_at) VALUES (?, ?, ?, ?, ?)
+ON CONFLICT(host, op, name) DO NOTHING`, host, op, name, body, at.Unix())
+ return err
+}
+
+// MoveLogPending is the move's queued entries, oldest first.
+func (s *Store) MoveLogPending(host, op string) ([]MoveLogPendingEntry, error) {
+ rows, err := s.db.Query(`SELECT name, body, queued_at FROM move_log_outbox WHERE host = ? AND op = ? ORDER BY rowid`, host, op)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []MoveLogPendingEntry
+ for rows.Next() {
+ var e MoveLogPendingEntry
+ var at int64
+ if err := rows.Scan(&e.Name, &e.Body, &at); err != nil {
+ return nil, err
+ }
+ e.QueuedAt = time.Unix(at, 0).UTC()
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
+// SettleMoveLog drops an entry once it is written, or found written.
+func (s *Store) SettleMoveLog(host, op, name string) error {
+ _, err := s.db.Exec(`DELETE FROM move_log_outbox WHERE host = ? AND op = ? AND name = ?`, host, op, name)
+ return err
+}
+
// ---- grants (the console's Keymail access tokens) ---------------------------
// SetGrantToken stores the Keymail access token the user's OAuth approval
diff --git a/internal/console/store_gate.go b/internal/console/store_gate.go
index c2d72d223..f6160901b 100644
--- a/internal/console/store_gate.go
+++ b/internal/console/store_gate.go
@@ -210,6 +210,7 @@ var claimSQLTables = map[string]claimSQLTable{
"invites": {NonApp: "account invites, keyed by account and address; names no app"},
"alert_seen": {NonApp: "operator alert memory, keyed by box, rule and address"},
"move_pages": {NonApp: "the move pass's last page attempt per finding (rule names the host and the move); no app column"},
+ "move_log_outbox": {NonApp: "the move pass's unwritten move log entries, keyed by host, move and entry; the grant that writes one derives its key; no app column"},
"edge_proof_seen": {NonApp: "single-row edge proof marker"},
"cli_auth_requests": {NonApp: "device-code logins in flight, keyed by code hash"},
"step_ups": {NonApp: "passkey step-up marks, keyed by session hash"},
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt
index 8e7d07046..e57d978f5 100644
--- a/internal/console/testdata/claim-authority-sites.txt
+++ b/internal/console/testdata/claim-authority-sites.txt
@@ -106,6 +106,8 @@ geoippublish.go|Commit|os.Open(st.path)
hostclaimbackfill.go|RunClaimRecoveryPass|a.Control.RunRecovery(ctx, s.runClaimRecoveryPassBody)
hostwakereset.go|registerWakeRetryReset|a.Control.RegisterStart(ctx, h.Account, h.App, reg.AppID, id, reg, registeredAt)
hostwakereset.go|registerWakeRetryReset|start.FinishWakeRetryReset(cleanup)
+movesrecovery.go|flushMoveLog|g.Log(ctx, e.Name, e.Body)
+movesrecovery.go|logMoveNow|g.Log(ctx, t.Identity, body)
movesrecovery.go|movesRecovery|a.Control.ConsoleMove(ctx, co, hostops.ConsoleMoveAbort)
movesrecovery.go|movesRecovery|g.Claim(ctx, d.Claim)
movesrecovery.go|movesRecovery|g.Convert(ctx, *in.Fence)
diff --git a/internal/host/movesevents.go b/internal/host/movesevents.go
index 619b61736..9898423b3 100644
--- a/internal/host/movesevents.go
+++ b/internal/host/movesevents.go
@@ -1,12 +1,13 @@
package host
import (
+ "container/list"
"context"
"fmt"
"log"
"math"
"strconv"
- "strings"
+ "sync"
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/hostclaim"
@@ -29,11 +30,18 @@ import (
// only the writer that created the object tells the app's members (preflight
// L9, L27).
//
-// A worker remembers the identities whose objects exist (created, or found
-// there), so a pass that sees the same transition again does not even try;
-// a write that failed is not remembered, so the next sighting tries again.
-// A move's identities are forgotten once its terminal outcome is logged:
-// create-only storage still refuses a replay.
+// A write that fails is kept, with the entry's exact bytes, in the worker's
+// outbox on disk (moveslogoutbox.go), and every pass retries the outbox
+// whatever the record now says, until the entry is created or found there;
+// so neither a phase that moves on nor a close loses it (fix round 2,
+// ruling 1).
+//
+// A worker remembers the identities it has handed off (written, found, or
+// kept in the outbox), so a pass that sees the same transition again does
+// not even try. The memory is bounded (moveLogMemoCap, oldest dropped), and
+// a move is forgotten once its terminal outcome is logged or a pass sees its
+// record with no move or another move (retireMoves): create-only storage
+// still refuses a replay.
// observedMove wraps a record mutation so *pre keeps the record as the write
// found it: the transition's before, for the log.
@@ -81,6 +89,7 @@ func (w *movesWorker) logTransition(ctx context.Context, s release.Store, before
// 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) {
+ w.retireMoves(rec)
m := rec.Move
if m == nil || m.ID == "" {
return
@@ -118,9 +127,10 @@ func (w *movesWorker) logRevPhase(ctx context.Context, s release.Store, rec prov
}
// recoveryAttemptLogger is the attempt journal's RecoveryAttempt hook for
-// this worker: each recovery probe start the journal counts is
-// probe_attempt-<epoch>-<n> (preflight L11), the starts of one wake
-// included. s is the store the log is written to.
+// this worker (set by probeAttempts): each recovery probe start the journal
+// counts is probe_attempt-<epoch>-<n> (preflight L11), the starts of one wake
+// included. s is the store the log is written to. A write that fails goes to
+// the outbox like any other.
func (w *movesWorker) recoveryAttemptLogger(s release.Store) func(ctx context.Context, rec provision.Record, epoch uint64, n int) {
return func(ctx context.Context, rec provision.Record, epoch uint64, n int) {
if rec.Move == nil || rec.Move.ID == "" {
@@ -135,13 +145,13 @@ func (w *movesWorker) recoveryAttemptLogger(s release.Store) func(ctx context.Co
// terminalEvents are the events that end a move's log.
var terminalEvents = map[string]bool{"done": true, "rolled_back": true, "aborted": true}
-// record hands one entry to the LogEvent hook unless this worker already
-// knows its object exists. Only a hook that succeeded (the object created,
-// or found there) is remembered. With no hook (a test's worker) the event is
-// a log line.
+// record hands one entry to the LogEvent hook unless this worker has
+// already handed it off. An entry whose write fails is kept in the outbox,
+// for every later pass to retry; only an entry written, found there or kept
+// is remembered. 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 moves.Transition) {
key := rec.Host + "/" + e.Op + "/" + e.Identity
- if _, seen := w.logged.Load(key); seen {
+ if w.logged.seen(key) {
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}
@@ -150,28 +160,123 @@ func (w *movesWorker) record(ctx context.Context, s release.Store, rec provision
}
if f := w.cfg.LogEvent; f != nil {
if err := f(ctx, s, rec, ev); err != nil {
- log.Printf("moves: %s: the log of %s at %s waits for the next sighting: %v", rec.Host, e.Op, e.Identity, err)
- return
+ if qerr := w.queueLog(rec, ev); qerr != nil {
+ log.Printf("moves: %s: the log of %s at %s waits for the next sighting: %v; the outbox: %v", rec.Host, e.Op, e.Identity, err, qerr)
+ return
+ }
+ log.Printf("moves: %s: the log of %s at %s is kept for the next pass: %v", rec.Host, e.Op, e.Identity, err)
}
} else {
log.Printf("moves: %s: %s %s (%s)", rec.Host, e.Op, e.Event, e.Identity)
}
if terminalEvents[e.Event] {
- w.forgetMove(rec.Host, e.Op)
+ w.logged.forget(rec.Host, e.Op)
return
}
- w.logged.Store(key, true)
+ w.logged.add(rec.Host, e.Op, key)
+}
+
+// retireMoves forgets the moves of rec's host other than the one its record
+// names now: a move closed, or replaced, by another writer.
+func (w *movesWorker) retireMoves(rec provision.Record) {
+ keep := ""
+ if rec.Move != nil {
+ keep = rec.Move.ID
+ }
+ w.logged.retire(rec.Host, keep)
}
-// forgetMove drops a closed move's remembered identities.
-func (w *movesWorker) forgetMove(host, op string) {
- prefix := host + "/" + op + "/"
- w.logged.Range(func(k, _ any) bool {
- if strings.HasPrefix(k.(string), prefix) {
- w.logged.Delete(k)
+// moveLogMemoCap bounds a worker's memory of handed-off identities.
+const moveLogMemoCap = 4096
+
+// moveLogMemo is the identities a worker has handed off: at most
+// moveLogMemoCap, the oldest dropped first, indexed by host and move so a
+// move is forgotten whole. The zero value is ready.
+type moveLogMemo struct {
+ mu sync.Mutex
+ order *list.List // memoEntry, oldest first
+ at map[string]*list.Element // key to its element
+ moves map[string]map[string][]*list.Element // host to move to its elements
+}
+
+type memoEntry struct{ host, op, key string }
+
+func (m *moveLogMemo) seen(key string) bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ _, ok := m.at[key]
+ return ok
+}
+
+func (m *moveLogMemo) len() int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return len(m.at)
+}
+
+func (m *moveLogMemo) add(host, op, key string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.order == nil {
+ m.order, m.at, m.moves = list.New(), map[string]*list.Element{}, map[string]map[string][]*list.Element{}
+ }
+ if _, ok := m.at[key]; ok {
+ return
+ }
+ for len(m.at) >= moveLogMemoCap {
+ m.drop(m.order.Front())
+ }
+ el := m.order.PushBack(memoEntry{host, op, key})
+ m.at[key] = el
+ if m.moves[host] == nil {
+ m.moves[host] = map[string][]*list.Element{}
+ }
+ m.moves[host][op] = append(m.moves[host][op], el)
+}
+
+// drop removes one element from every index.
+func (m *moveLogMemo) drop(el *list.Element) {
+ e := el.Value.(memoEntry)
+ m.order.Remove(el)
+ delete(m.at, e.key)
+ els := m.moves[e.host][e.op]
+ for i, x := range els {
+ if x == el {
+ els = append(els[:i], els[i+1:]...)
+ break
+ }
+ }
+ if len(els) > 0 {
+ m.moves[e.host][e.op] = els
+ return
+ }
+ delete(m.moves[e.host], e.op)
+ if len(m.moves[e.host]) == 0 {
+ delete(m.moves, e.host)
+ }
+}
+
+// forget drops one move's identities.
+func (m *moveLogMemo) forget(host, op string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for _, el := range append([]*list.Element(nil), m.moves[host][op]...) {
+ m.drop(el)
+ }
+}
+
+// retire drops the identities of host's moves other than keep.
+func (m *moveLogMemo) retire(host, keep string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for op, els := range m.moves[host] {
+ if op == keep {
+ continue
+ }
+ for _, el := range append([]*list.Element(nil), els...) {
+ m.drop(el)
}
- return true
- })
+ }
}
// moveLogWriter is the production LogEvent: the object, create-only, under
@@ -190,7 +295,7 @@ func moveLogWriter(sink activator.EventSink) func(context.Context, release.Store
if !created || sink == nil {
return nil
}
- if line, ok := memberLine(ev, rec); ok {
+ if line, ok := memberLine(ev, rec.Host); ok {
sink.Event(rec.Account, rec.App, rec.Host, memberLevel(ev.Event), line)
}
return nil
@@ -200,20 +305,21 @@ func moveLogWriter(sink activator.EventSink) func(context.Context, release.Store
// 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 MV-N380, a line with no
// operator command in it), from the shared templates (moves/operatorcopy.go,
-// L12). A region the record names as the home's "" is moves.HomeRegionText.
-// ok is false for an event members are not told of. Its level is
-// memberLevel's.
-func memberLine(ev moves.LogEvent, rec provision.Record) (line string, ok bool) {
- host := rec.Host
+// L12), built from the entry alone, so an entry the outbox writes later says
+// the same. A region the record names as the home's "" is
+// moves.HomeRegionText. ok is false for an event members are not told of.
+// Its level is memberLevel's.
+func memberLine(ev moves.LogEvent, host string) (line string, ok bool) {
from, to := moves.RegionText(ev.Fields["from"]), moves.RegionText(ev.Fields["to"])
switch ev.Event {
case "requested":
return moves.MoveStartingLine(host, from, to), true
case "admitted":
- if rec.Move == nil || rec.Move.CutBudgetS <= 0 {
+ budget, err := strconv.Atoi(ev.Fields["cut_budget_s"])
+ if err != nil || budget <= 0 {
return "", false
}
- return moves.CutStartingLine(to, host, moves.CutBudgetText(rec.Move.CutBudgetS)), true
+ return moves.CutStartingLine(to, host, moves.CutBudgetText(budget)), true
case "flipped":
return moves.FlippedLine(from, to), true
case "done":
@@ -224,9 +330,6 @@ func memberLine(ev moves.LogEvent, rec provision.Record) (line string, ok bool)
return moves.MovedLine(host, to, int(math.Round(offline))), true
case "rolling_back":
reason := ev.Fields["reason"]
- if reason == "" && rec.Move != nil && rec.Move.Rollback != nil {
- reason = rec.Move.Rollback.Reason
- }
if reason == "" {
return "", false
}
diff --git a/internal/host/movesevents_test.go b/internal/host/movesevents_test.go
index 631d30b10..549cb2663 100644
--- a/internal/host/movesevents_test.go
+++ b/internal/host/movesevents_test.go
@@ -2,8 +2,13 @@ package host
import (
"context"
+ "encoding/json"
"errors"
- "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
"strings"
"testing"
"time"
@@ -266,7 +271,20 @@ func TestMemberLines(t *testing.T) {
{"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)
+ // The entry's own fields, as the writer names the transition (the
+ // admitted cut's budget, the rollback's reason): the line needs
+ // nothing else, so an entry the outbox writes later says the same.
+ ev := moves.LogEvent{Event: c.event, Fields: map[string]string{"from": "ie", "to": "uk"}}
+ if before := (provision.Record{Host: c.rec.Host}); c.rec.Move != nil {
+ before.Move = &provision.MoveState{ID: c.rec.Move.ID, From: "ie", To: "uk", State: provision.MoveLanding}
+ if c.event == "admitted" {
+ before = flow[2]
+ }
+ if tr, ok := moves.TransitionOf(before, c.rec); ok && tr.Event == c.event {
+ ev.Fields = tr.Fields
+ }
+ }
+ line, ok := memberLine(ev, c.rec.Host)
level := ""
if ok {
level = memberLevel(c.event)
@@ -279,47 +297,6 @@ func TestMemberLines(t *testing.T) {
// ---- Task 3 fix round 1 ----
-// Ruling 1: an identity is done only once its object exists. A write that
-// fails stays pending, and the next sighting tries again.
-func TestFailedLogWriteStaysPending(t *testing.T) {
- ctx := context.Background()
- calls := 0
- w := &movesWorker{mirrors: map[string]lease.MirrorStore{}}
- w.cfg.Now = time.Now
- w.cfg.LogEvent = func(context.Context, release.Store, provision.Record, moves.LogEvent) error {
- calls++
- if calls == 1 {
- return errors.New("the bucket is down")
- }
- return nil
- }
- rec := mvEvRec(provision.MovePreparing, nil)
- w.observeMove(ctx, nil, rec)
- w.observeMove(ctx, nil, rec)
- w.observeMove(ctx, nil, rec)
- if calls != 2 {
- t.Fatalf("the hook was called %d times, want a failure, a retry that succeeds, and nothing after", calls)
- }
-}
-
-// Ruling 7: a move's identities are forgotten once its terminal outcome is
-// logged (create-only storage still refuses a replay).
-func TestLogMemoForgetsAClosedMove(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])
- }
- n := 0
- w.logged.Range(func(k, _ any) bool { n++; return true })
- if n != 0 {
- t.Fatalf("%d identities remembered after the close", n)
- }
-}
-
// Ruling 4: the box knows the home region only as "", so the member line
// names it with the reviewed phrase (MV-N381), never dropping the line.
func TestHomeRegionMemberLines(t *testing.T) {
@@ -335,7 +312,7 @@ func TestHomeRegionMemberLines(t *testing.T) {
{toHome, "flipped", "Stopped in ie. Starting in the home region."},
} {
ev := moves.LogEvent{Event: c.event, Fields: map[string]string{"from": c.rec.Move.From, "to": c.rec.Move.To}}
- if line, ok := memberLine(ev, c.rec); !ok || line != c.want {
+ if line, ok := memberLine(ev, c.rec.Host); !ok || line != c.want {
t.Errorf("%s: %q %v, want %q", c.event, line, ok, c.want)
}
}
@@ -404,47 +381,6 @@ func TestEventsCarryTheirEvidence(t *testing.T) {
}
}
-// Ruling 2: each recovery probe start the attempt journal counts is a log
-// event, probe_attempt-<epoch>-<n>, the starts of one wake included; a
-// reservation that only validates writes none.
-func TestRecoveryProbeAttemptsAreLogged(t *testing.T) {
- f := recoveryPAFixture(t, hostclaim.KindRestoring)
- var got []string
- hook := func(_ context.Context, rec provision.Record, epoch uint64, n int) {
- got = append(got, fmt.Sprintf("%s %d-%d", rec.Host, epoch, n))
- }
- f.ops = f.attempts()
- f.ops.RecoveryAttempt = hook
- for n := 0; n < 3; n++ {
- if _, err := f.ops.OpenProbe(t.Context(), f.start, time.Now()); err != nil {
- t.Fatal(err)
- }
- if err := f.ops.Reserve(t.Context(), f.start, n); err != nil {
- t.Fatalf("start %d: %v", n, err)
- }
- if n == 0 {
- if err := f.ops.Reserve(t.Context(), f.start, 0); err != nil {
- t.Fatal(err)
- }
- }
- if err := f.ops.Failed(t.Context(), f.start, n); err != nil {
- t.Fatal(err)
- }
- }
- want := []string{f.rec.Host + " 0-1", f.rec.Host + " 0-2", f.rec.Host + " 0-3"}
- if strings.Join(got, ",") != strings.Join(want, ",") {
- t.Fatalf("attempts %v, want %v", got, want)
- }
-
- // The worker's hook names them probe_attempt-<epoch>-<n>.
- var evs []moves.LogEvent
- w := eventWorker(&evs)
- w.recoveryAttemptLogger(nil)(context.Background(), *f.rec, 2, 4)
- if len(evs) != 1 || evs[0].Event != "probe_attempt" || evs[0].Identity != "probe_attempt-2-4" {
- t.Fatalf("logged %+v", evs)
- }
-}
-
// Ruling 7: the real writers log, through the move harness. The landing
// harness drives the source's readiness, the console's cut, the cut and the
// flip, then the landing to installed; the close harness the probe, the
@@ -511,3 +447,204 @@ func wantSubsequence(t *testing.T, got []moves.LogEvent, want ...string) {
t.Fatalf("events %v lack %v in order (matched %d)", events, want, i)
}
}
+
+// flakyLogStore fails every write under a move's log while fail is set.
+type flakyLogStore struct {
+ release.Store
+ fail bool
+}
+
+func (f *flakyLogStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if f.fail && strings.Contains(key, "/log/") {
+ return "", errors.New("the bucket is down")
+ }
+ return f.Store.Put(ctx, key, body, ifMatch)
+}
+
+// outboxWorker is a worker with the production log writer over s and an
+// outbox under dataDir.
+func outboxWorker(dataDir string) *movesWorker {
+ w := &movesWorker{mirrors: map[string]lease.MirrorStore{}}
+ w.cfg.Box = "box-test"
+ w.cfg.DataDir = dataDir
+ w.cfg.Now = func() time.Time { return time.Unix(1_800_000_100, 0).UTC() }
+ w.cfg.LogEvent = moveLogWriter(nil)
+ return w
+}
+
+func outboxFiles(t *testing.T, dataDir string) []string {
+ t.Helper()
+ var out []string
+ root := moveLogOutboxDir(dataDir)
+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
+ if errors.Is(err, fs.ErrNotExist) {
+ return fs.SkipAll
+ }
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() {
+ rel, _ := filepath.Rel(root, p)
+ out = append(out, filepath.ToSlash(rel))
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+// Fix round 2, ruling 1: a log write that fails is kept, with the entry's
+// bytes, in the worker's outbox on disk, and every pass retries the outbox,
+// whatever the record now says. Here the write fails at the request and
+// again at the close; the move is gone from the record by the time the
+// bucket answers, and a fresh worker (an agent restart) writes both.
+func TestLogOutboxOutlivesTheMove(t *testing.T) {
+ ctx := context.Background()
+ dataDir := t.TempDir()
+ s := &flakyLogStore{Store: release.NewDirStore(t.TempDir()), fail: true}
+ w := outboxWorker(dataDir)
+ flow := doneFlow()
+ w.observeMove(ctx, s, flow[0])
+ last := flow[len(flow)-1]
+ w.logTransition(ctx, s, flow[len(flow)-2], last)
+ if last.Move != nil {
+ t.Fatal("the fixture's last record still has its move")
+ }
+ files := outboxFiles(t, dataDir)
+ if strings.Join(files, " ") != "acme.go.tito.io/"+mvEvMoveID+"/done.json acme.go.tito.io/"+mvEvMoveID+"/requested.json" {
+ t.Fatalf("outbox %v", files)
+ }
+ w.flushLogOutbox(ctx, s) // still failing: kept
+ if len(outboxFiles(t, dataDir)) != 2 {
+ t.Fatal("a failed retry dropped an entry")
+ }
+ pending, err := os.ReadFile(filepath.Join(moveLogOutboxDir(dataDir), "acme.go.tito.io", mvEvMoveID, "requested.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var kept moveLogPending
+ if err := json.Unmarshal(pending, &kept); err != nil {
+ t.Fatal(err)
+ }
+ s.fail = false
+ outboxWorker(dataDir).flushLogOutbox(ctx, s)
+ if f := outboxFiles(t, dataDir); len(f) != 0 {
+ t.Fatalf("written entries left in the outbox: %v", f)
+ }
+ prefix := moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", mvEvMoveID)
+ got, err := moves.ReadLog(ctx, s, prefix)
+ if err != nil || len(got) != 2 {
+ t.Fatalf("log %+v %v", got, err)
+ }
+ body, _, err := s.Get(ctx, moves.LogKeyFor(prefix, "requested"))
+ if err != nil || string(body) != string(kept.Entry) {
+ t.Fatalf("the object is not the kept bytes:\n%s\n%s", body, kept.Entry)
+ }
+ // Another sighting of the closed move writes nothing more.
+ w.observeMove(ctx, s, last)
+ if f := outboxFiles(t, dataDir); len(f) != 0 {
+ t.Fatalf("outbox %v", f)
+ }
+}
+
+// Fix round 2, ruling 2: recovery probe starts are logged through the
+// journal as a box builds it (movesWorker.probeAttempts), and a start whose
+// log write fails is kept in the outbox.
+func TestRecoveryProbeAttemptsAreLogged(t *testing.T) {
+ ctx := context.Background()
+ f := recoveryPAFixture(t, hostclaim.KindRestoring)
+ s := &flakyLogStore{Store: f.claims, fail: true}
+ w := outboxWorker(f.dataDir)
+ ids := 0
+ ops := w.probeAttempts(s, func(context.Context, string, string, string) (*provision.Record, error) { return f.rec, nil },
+ func() string { ids++; return "id-" + strings.Repeat("x", ids) })
+ for n := 0; n < 3; n++ {
+ if _, err := ops.OpenProbe(ctx, f.start, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := ops.Reserve(ctx, f.start, n); err != nil {
+ t.Fatalf("start %d: %v", n, err)
+ }
+ if err := ops.Failed(ctx, f.start, n); err != nil {
+ t.Fatal(err)
+ }
+ s.fail = false // only the first start's write fails
+ }
+ if files := outboxFiles(t, f.dataDir); len(files) != 1 || !strings.HasSuffix(files[0], "/probe_attempt-0-1.json") {
+ t.Fatalf("outbox %v, want the first start's entry", files)
+ }
+ w.flushLogOutbox(ctx, s)
+ got, err := moves.ReadLog(ctx, s, moves.ReportPrefix(f.rec.Account, f.rec.App, f.rec.Host, f.rec.Move.ID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var names []string
+ for _, ev := range got {
+ if ev.Event != "probe_attempt" {
+ t.Fatalf("event %+v", ev)
+ }
+ names = append(names, ev.Identity)
+ }
+ sort.Strings(names)
+ if strings.Join(names, " ") != "probe_attempt-0-1 probe_attempt-0-2 probe_attempt-0-3" {
+ t.Fatalf("logged %v", names)
+ }
+}
+
+// Fix round 2, ruling 3: the memo forgets a move as soon as a pass sees its
+// record with no move or another move: here a source box whose move was
+// closed on the target, then the same box when the next move replaced it.
+// The memo is also bounded, dropping its oldest identities.
+func TestLogMemoRetiresAMoveClosedElsewhere(t *testing.T) {
+ ctx := context.Background()
+ var got []moves.LogEvent
+ w := eventWorker(&got)
+ flow := doneFlow()
+ w.observeMove(ctx, nil, flow[0])
+ w.logTransition(ctx, nil, flow[0], flow[1])
+ if w.logged.len() == 0 {
+ t.Fatal("nothing remembered")
+ }
+ s := release.NewDirStore(t.TempDir())
+ closed := flow[len(flow)-1]
+ if err := provision.CreateRecord(ctx, s, closed); err != nil {
+ t.Fatal(err)
+ }
+ if err := movesPass(ctx, s, w); err != nil {
+ t.Fatal(err)
+ }
+ if n := w.logged.len(); n != 0 {
+ t.Fatalf("%d identities remembered after the pass saw the move closed elsewhere", n)
+ }
+ w.observeMove(ctx, nil, flow[0])
+ next := mvEvRec(provision.MovePreparing, func(m *provision.MoveState) { m.ID = "mv_next" })
+ s = release.NewDirStore(t.TempDir())
+ if err := provision.CreateRecord(ctx, s, next); err != nil {
+ t.Fatal(err)
+ }
+ if err := movesPass(ctx, s, w); err != nil {
+ t.Fatal(err)
+ }
+ if w.logged.seen(next.Host + "/" + mvEvMoveID + "/requested") {
+ t.Fatal("the replaced move is still remembered")
+ }
+}
+
+func TestLogMemoIsBounded(t *testing.T) {
+ var m moveLogMemo
+ for i := 0; i < moveLogMemoCap+10; i++ {
+ m.add("h", "op", "h/op/"+strconv.Itoa(i))
+ }
+ if m.len() != moveLogMemoCap {
+ t.Fatalf("%d entries, want %d", m.len(), moveLogMemoCap)
+ }
+ if m.seen("h/op/0") || !m.seen("h/op/"+strconv.Itoa(moveLogMemoCap+9)) {
+ t.Fatal("not the oldest dropped")
+ }
+ m.forget("h", "op")
+ if m.len() != 0 {
+ t.Fatalf("%d entries after forget", m.len())
+ }
+}
diff --git a/internal/host/moveslogoutbox.go b/internal/host/moveslogoutbox.go
new file mode 100644
index 000000000..ef969b67a
--- /dev/null
+++ b/internal/host/moveslogoutbox.go
@@ -0,0 +1,169 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The move log's outbox (Plan 6 Task 3, fix round 2, ruling 1). An entry
+// whose write fails is kept on the box's disk, under
+// <DataDir>/.moves/.log-outbox/<host>/<op>/<identity>.json, with the entry's
+// exact bytes and the app it belongs to. Every pass retries every kept entry
+// (flushLogOutbox), whatever its move has done since, and deletes the file
+// only once the entry is created or found there. The directory's leading dot
+// keeps it apart from the moves journal's host directories.
+
+// moveLogOutboxDir is the outbox's root.
+func moveLogOutboxDir(dataDir string) string {
+ return filepath.Join(dataDir, ".moves", ".log-outbox")
+}
+
+// moveLogPending is one kept entry: Entry is the bytes the log object holds
+// (json.Marshal of the moves.LogEvent, as moves.WriteLog writes it).
+type moveLogPending struct {
+ V int `json:"v"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Host string `json:"host"`
+ Entry json.RawMessage `json:"entry"`
+}
+
+// outboxSegment refuses a path segment that is empty or could leave its
+// directory.
+func outboxSegment(s string) bool {
+ return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00")
+}
+
+// queueLog keeps ev, for rec's app and host, in the outbox: written to a
+// temporary file, synced, and renamed into place.
+func (w *movesWorker) queueLog(rec provision.Record, ev moves.LogEvent) error {
+ if w.cfg.DataDir == "" {
+ return errors.New("no data directory for the move log's outbox")
+ }
+ if !outboxSegment(rec.Host) || !outboxSegment(ev.Op) || !outboxSegment(ev.Identity) {
+ return fmt.Errorf("an unusable move log entry name: %s/%s/%s", rec.Host, ev.Op, ev.Identity)
+ }
+ entry, err := json.Marshal(ev)
+ if err != nil {
+ return err
+ }
+ body, err := json.Marshal(moveLogPending{V: 1, Account: rec.Account, App: rec.App, Host: rec.Host, Entry: entry})
+ if err != nil {
+ return err
+ }
+ dir := filepath.Join(moveLogOutboxDir(w.cfg.DataDir), rec.Host, ev.Op)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(dir, ".pending-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if _, err := tmp.Write(body); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmp.Name(), filepath.Join(dir, ev.Identity+".json")); err != nil {
+ return err
+ }
+ if d, err := os.Open(dir); err == nil {
+ _ = d.Sync()
+ d.Close()
+ }
+ return nil
+}
+
+// flushLogOutbox retries every kept entry through the LogEvent hook, into s.
+// A written entry's file is deleted; one that fails again stays. A file that
+// cannot be read as an entry is logged and deleted: it can never be written.
+func (w *movesWorker) flushLogOutbox(ctx context.Context, s release.Store) {
+ f := w.cfg.LogEvent
+ if w.cfg.DataDir == "" || f == nil || s == nil {
+ return
+ }
+ root := moveLogOutboxDir(w.cfg.DataDir)
+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
+ if errors.Is(err, fs.ErrNotExist) && p == root {
+ return fs.SkipAll
+ }
+ if err != nil {
+ return err
+ }
+ if d.IsDir() || !strings.HasSuffix(d.Name(), ".json") || strings.HasPrefix(d.Name(), ".") {
+ return nil
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ w.flushLogFile(ctx, s, f, p)
+ return nil
+ })
+ if err != nil {
+ log.Printf("moves: the move log's outbox: %v", err)
+ }
+ pruneEmptyDirs(root)
+}
+
+// flushLogFile retries one kept entry.
+func (w *movesWorker) flushLogFile(ctx context.Context, s release.Store, f func(context.Context, release.Store, provision.Record, moves.LogEvent) error, p string) {
+ body, err := os.ReadFile(p)
+ if err != nil {
+ log.Printf("moves: the move log's outbox: %v", err)
+ return
+ }
+ var kept moveLogPending
+ var ev moves.LogEvent
+ if err := json.Unmarshal(body, &kept); err != nil || kept.V != 1 || json.Unmarshal(kept.Entry, &ev) != nil ||
+ ev.Identity+".json" != filepath.Base(p) || ev.Op != filepath.Base(filepath.Dir(p)) || kept.Host != filepath.Base(filepath.Dir(filepath.Dir(p))) {
+ log.Printf("moves: the move log's outbox: %s is not a kept entry; deleted", p)
+ _ = os.Remove(p)
+ return
+ }
+ if err := f(ctx, s, provision.Record{Account: kept.Account, App: kept.App, Host: kept.Host}, ev); err != nil {
+ log.Printf("moves: %s: the log of %s at %s is kept for the next pass: %v", kept.Host, ev.Op, ev.Identity, err)
+ return
+ }
+ if err := os.Remove(p); err != nil {
+ log.Printf("moves: the move log's outbox: %v", err)
+ }
+}
+
+// pruneEmptyDirs removes the outbox's empty host and move directories.
+func pruneEmptyDirs(root string) {
+ hosts, err := os.ReadDir(root)
+ if err != nil {
+ return
+ }
+ for _, h := range hosts {
+ if !h.IsDir() {
+ continue
+ }
+ hd := filepath.Join(root, h.Name())
+ ops, _ := os.ReadDir(hd)
+ for _, o := range ops {
+ if o.IsDir() {
+ _ = os.Remove(filepath.Join(hd, o.Name())) // only if empty
+ }
+ }
+ _ = os.Remove(hd)
+ }
+}
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index adb7e8555..1fd81240d 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -90,9 +90,9 @@ type movesWorker struct {
archives map[string]verifiedArchive
measured moves.Measurements
notices sync.Map // key → time.Time of the last line
- // logged is the move log identities this worker has handed to LogEvent
- // (movesevents.go): host/op/identity → true.
- logged sync.Map
+ // logged is the move log identities this worker has handed off
+ // (movesevents.go): host/op/identity, bounded.
+ logged moveLogMemo
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
@@ -707,6 +707,9 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error {
if cfg.claimRecords {
ctx = provision.WithRecordSequence(ctx)
}
+ // The move log's kept entries are retried first, whatever their moves
+ // now are (moveslogoutbox.go).
+ w.flushLogOutbox(ctx, s)
recs, err := provision.ListAllRecords(ctx, s)
if err != nil {
return fmt.Errorf("moves: list records: %w", err)
@@ -731,6 +734,9 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error {
}
}
for _, rec := range recs {
+ // A move closed or replaced by another writer is forgotten by the
+ // move log's memory (movesevents.go).
+ w.retireMoves(rec)
if rec.Move != nil && !rec.Move.Legacy() {
// A Phase 1 record. With the switch off it is never touched
// (none exists unless the switch was on somewhere).
diff --git a/internal/host/movesprobeattempts.go b/internal/host/movesprobeattempts.go
index 826dda6bb..6a1c71225 100644
--- a/internal/host/movesprobeattempts.go
+++ b/internal/host/movesprobeattempts.go
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
- "log"
"os/exec"
"strconv"
"sync"
@@ -71,7 +70,7 @@ type movesProbeAttempts struct {
// RecoveryAttempt, when set, is told of each recovery probe start this
// journal counts: the record, its recovery retry epoch and the start's
// number (the move log's probe_attempt-<epoch>-<n>, Plan 6 Task 3;
- // movesWorker.recoveryAttemptLogger). Whoever builds the journal sets it.
+ // movesWorker.recoveryAttemptLogger, set by movesWorker.probeAttempts).
RecoveryAttempt func(ctx context.Context, rec provision.Record, epoch uint64, n int)
mu sync.Mutex
@@ -115,14 +114,28 @@ func (p *movesProbeAttempts) valid() bool {
// or -1 for a recovery probe. Every case validates the admitted writer.
// Recovery also returns the budget key from the same record read.
func (p *movesProbeAttempts) bound(ctx context.Context, s OperationStart) (int, string, error) {
+ k, key, _, err := p.boundRecord(ctx, s)
+ return k, key, err
+}
+
+// boundRecord is bound, with the record a recovery probe was bound against
+// (nil otherwise): Reserve logs a recovery start from it.
+func (p *movesProbeAttempts) boundRecord(ctx context.Context, s OperationStart) (int, string, *provision.Record, error) {
if !p.valid() {
- return 0, "", hostwrite.ErrUnavailable
+ return 0, "", nil, hostwrite.ErrUnavailable
}
a := s.Admitted
if a.Claim != nil && claimRecoveryKind(a.Claim.Op.Kind) {
- key, err := p.boundRecovery(ctx, s)
- return -1, key, err
+ key, rec, err := p.boundRecovery(ctx, s)
+ return -1, key, rec, err
}
+ k, key, err := p.boundLanding(ctx, s)
+ return k, key, nil, err
+}
+
+// boundLanding is bound for a landing's (or a committed wake's) probe.
+func (p *movesProbeAttempts) boundLanding(ctx context.Context, s OperationStart) (int, string, error) {
+ a := s.Admitted
if a.Claim == nil || a.Claim.Holder == nil || a.Claim.Op.Kind != hostclaim.KindLanding {
return 0, "", hostbox.ErrUnsupported
}
@@ -303,7 +316,7 @@ func sameStartOwner(a hostclaim.WakeRetryAttempt, s OperationStart, digest strin
// reserved by the first Reserve(0) of the admission and validated by every
// later one; start n > 0 only after start n-1 was settled failed.
func (p *movesProbeAttempts) Reserve(ctx context.Context, s OperationStart, n int) (retErr error) {
- k, recoveryKey, err := p.bound(ctx, s)
+ k, recoveryKey, bindRec, err := p.boundRecord(ctx, s)
if err != nil {
return err
}
@@ -364,7 +377,7 @@ func (p *movesProbeAttempts) Reserve(ctx context.Context, s OperationStart, n in
}
defer func() {
if counted > 0 && retErr == nil {
- p.recoveryAttempted(ctx, s, counted)
+ p.recoveryAttempted(ctx, bindRec, counted)
}
}()
o := s.Owner
@@ -516,38 +529,38 @@ func probeAlive(cmd *exec.Cmd) bool {
// RecoveryProbeAttemptsReady is checked before the adapter records inputs.
func (p *movesProbeAttempts) RecoveryProbeAttemptsReady() bool { return p.valid() }
-func (p *movesProbeAttempts) boundRecovery(ctx context.Context, s OperationStart) (string, error) {
+func (p *movesProbeAttempts) boundRecovery(ctx context.Context, s OperationStart) (string, *provision.Record, error) {
a := s.Admitted
if a.Claim.Holder == nil || !s.Policy.Probe {
- return "", hostbox.ErrChanged
+ return "", nil, hostbox.ErrChanged
}
h := a.Claim.Holder
if s.Owner.Host != a.Host || s.Owner.Incarnation != h.Incarnation || s.Owner.Stamp != h.Stamp || s.Owner.Session != h.Session || s.Owner.Box != a.Claim.Op.Exec {
- return "", hostbox.ErrChanged
+ return "", nil, hostbox.ErrChanged
}
fresh, err := hostclaim.Read(ctx, p.Claims, a.Host)
if err != nil {
- return "", err
+ return "", nil, err
}
if !claimSameWriter(a, fresh, hostclaim.SessionContinues) || fresh.Claim.Op.Phase != hostclaim.PhaseProbing || fresh.Claim.Op.Exec != s.Owner.Box {
- return "", hostbox.ErrChanged
+ return "", nil, hostbox.ErrChanged
}
rec, err := p.Records(ctx, h.Account, h.App, a.Host)
if err != nil {
- return "", err
+ return "", nil, err
}
if rec == nil || rec.Host != a.Host || rec.Move == nil || rec.Move.ID != a.Claim.Op.ID || rec.Move.Incarnation != h.Incarnation {
- return "", hostbox.ErrChanged
+ return "", nil, hostbox.ErrChanged
}
m := rec.Move
if a.Claim.Op.Kind == hostclaim.KindReverting {
if m.State != provision.MoveReverting || m.ReopenedAt != nil || fresh.Claim.Op.Journal[hostclaim.JournalRevPhase] != hostclaim.PhaseProbing {
- return "", hostbox.ErrChanged
+ return "", nil, hostbox.ErrChanged
}
} else if m.State != provision.MoveRollingBack || m.Rollback == nil || m.Rollback.ID == "" || m.Rollback.ID != a.Claim.Op.Intent["rollback"] || m.Rollback.ID != fresh.Claim.Op.Intent["rollback"] || m.Rollback.SourcePhase != provision.RbSourceProbing {
- return "", hostbox.ErrChanged
+ return "", nil, hostbox.ErrChanged
}
- return recoveryAttemptKey(s, m.RecoveryRetryEpoch), nil
+ return recoveryAttemptKey(s, m.RecoveryRetryEpoch), rec, nil
}
// The move has its own journal directory. JSON preserves the rollback and
@@ -580,17 +593,23 @@ func recoveryAttemptCount(j *moves.Journal, key string) (int, error) {
}
// recoveryAttempted tells RecoveryAttempt of start n of a recovery probe,
-// read against the record its admission names (for the retry epoch). A
-// record that cannot be read is logged, and the start is not reported.
-func (p *movesProbeAttempts) recoveryAttempted(ctx context.Context, s OperationStart, n int) {
- if p.RecoveryAttempt == nil || p.Records == nil || s.Admitted.Claim == nil || s.Admitted.Claim.Holder == nil {
- return
- }
- h := s.Admitted.Claim.Holder
- rec, err := p.Records(ctx, h.Account, h.App, s.Admitted.Host)
- if err != nil || rec == nil || rec.Move == nil {
- log.Printf("moves: %s: the log of recovery probe start %d: the record: %v", s.Admitted.Host, n, err)
+// with the record the start was bound against (its retry epoch): the read
+// the binding already made, so a counted start is never lost to a second
+// read. The hook's own failed write goes to the outbox.
+func (p *movesProbeAttempts) recoveryAttempted(ctx context.Context, rec *provision.Record, n int) {
+ if p.RecoveryAttempt == nil || rec == nil || rec.Move == nil {
return
}
p.RecoveryAttempt(ctx, *rec, rec.Move.RecoveryRetryEpoch, n)
}
+
+// probeAttempts is the attempt journal a box builds for its probe wakes
+// (Plan 3 Task 5), over the deployment store s, its records and newID, with
+// this worker's data directory and clock. Its RecoveryAttempt is the
+// worker's move log hook (recoveryAttemptLogger), so every recovery probe
+// start it counts is logged (fix round 2, ruling 2). Production builds the
+// journal here once Plan 9b registers the wake source.
+func (w *movesWorker) probeAttempts(s release.Store, records MovesRecordReader, newID func() string) *movesProbeAttempts {
+ return &movesProbeAttempts{Claims: s, Records: records, DataDir: w.cfg.DataDir, Admitted: MovesRecordAdmittedAttempts,
+ NewID: newID, Now: w.cfg.Now, RecoveryAttempt: w.recoveryAttemptLogger(s)}
+}
diff --git a/internal/hostops/move_recovery_grant.go b/internal/hostops/move_recovery_grant.go
index b0dd28552..b65e5033b 100644
--- a/internal/hostops/move_recovery_grant.go
+++ b/internal/hostops/move_recovery_grant.go
@@ -1,6 +1,7 @@
package hostops
import (
+ "bytes"
"context"
"crypto/rand"
"encoding/json"
@@ -27,14 +28,14 @@ type MoveRecoveryGrant struct {
o ConsoleMoveObservation
authority hostclaim.FenceAuthority
scope *moveRecoveryPassScope
+ // wrote is what the grant's record write did (Transition), once it has
+ // made one.
+ wrote *moves.Transition
}
type moveRecoveryGrantState struct {
mu sync.Mutex
active bool
- // running is true while the hook that holds the grant runs: the life of
- // Log, which outlives the one mutation active allows.
- running bool
}
func moveRecoveryKind(kind string) bool {
@@ -90,9 +91,9 @@ func runMoveRecovery(ctx context.Context, obs hostclaim.Observed, hook func(cont
if len(authorities) == 1 {
authority = authorities[0]
}
- g := &MoveRecoveryGrant{state: &moveRecoveryGrantState{active: true, running: true}, w: w, scope: scope, authority: authority, o: ConsoleMoveObservation{Account: h.Account, App: h.App, AppID: c.Op.Intent["app_id"], Host: obs.Host, Incarnation: h.Incarnation, OpID: c.Op.ID, ClaimETag: obs.ETag, RecordETag: r.ETag, RecordSeq: seq}}
+ g := &MoveRecoveryGrant{state: &moveRecoveryGrantState{active: true}, w: w, scope: scope, authority: authority, o: ConsoleMoveObservation{Account: h.Account, App: h.App, AppID: c.Op.Intent["app_id"], Host: obs.Host, Incarnation: h.Incarnation, OpID: c.Op.ID, ClaimETag: obs.ETag, RecordETag: r.ETag, RecordSeq: seq}}
state := g.state
- defer func() { state.mu.Lock(); state.active, state.running = false, false; state.mu.Unlock() }()
+ defer func() { state.mu.Lock(); state.active = false; state.mu.Unlock() }()
// The owner receives only this capability, never the minting context.
ctx = context.WithValue(ctx, lifecycleGrantKey{}, []lifecycleGrant(nil))
ctx = context.WithValue(ctx, moveRecoveryPassKey{}, (*moveRecoveryPassScope)(nil))
@@ -125,26 +126,96 @@ func (g *MoveRecoveryGrant) current(ctx context.Context) (hostclaim.Observed, Re
return o, r, rec, err
}
-// Log writes one entry of this move's log (Plan 6 Task 3): create-only
-// (moves.WriteLog), under this grant's move's own report prefix, while the
-// hook that holds the grant runs, after its mutation as before it. It is the
-// pass's one write beside the record and the claim; an entry is a record of
-// a transition, never an input to one, so it needs no fresh observation.
-// created is false when the entry was already there.
-func (g *MoveRecoveryGrant) Log(ctx context.Context, ev moves.LogEvent) (created bool, err error) {
- if g == nil || g.state == nil || g.w == nil {
+// MoveLogEntryMax bounds one entry the grant's Log writes, in bytes.
+const MoveLogEntryMax = 4096
+
+// ErrMoveLogTaken is a Log whose entry name already holds other bytes: the
+// same transition, logged first by another writer. Nothing is written.
+var ErrMoveLogTaken = errors.New("hostops: the move log entry was written by another writer")
+
+// Log writes one entry of this grant's move's log (Plan 6 Task 3; the
+// conditions are platform-1e's, fix round 2 ruling 4):
+//
+// - The key is the grant's alone: name under the report prefix of the
+// grant's move (its account, app, host and move id), whose record still
+// names that move at the grant's incarnation, live or closed as its
+// last. The caller gives only name, one segment, which must be the
+// entry's identity.
+// - body is at most MoveLogEntryMax bytes and decodes, strictly, as a
+// moves.LogEvent of this move and host.
+// - The write is create-only. A replay of the same bytes is a no-op
+// (created false); other bytes under the name are ErrMoveLogTaken.
+// - It answers only while the grant is valid, by the check every other
+// method makes (current): never after the grant's one mutation, after
+// its hook, or across a changed claim or record. A pass logs what it
+// owes before it acts; an entry its own write makes waits in the
+// caller's outbox for the next pass's grant.
+//
+// An entry records a transition and is never an input to one.
+func (g *MoveRecoveryGrant) Log(ctx context.Context, name string, body []byte) (created bool, err error) {
+ if g == nil || g.state == nil {
return false, ErrPending
}
g.state.mu.Lock()
- running := g.state.running
- g.state.mu.Unlock()
- if !running {
- return false, ErrPending
+ defer g.state.mu.Unlock()
+ _, _, rec, err := g.current(ctx)
+ if err != nil {
+ return false, err
+ }
+ if !moveLogName(name) || len(body) > MoveLogEntryMax {
+ return false, ErrIdentity
+ }
+ var ev moves.LogEvent
+ if err := strictJSON(body, &ev); err != nil {
+ return false, ErrIdentity
}
- if ev.Op != g.o.OpID || ev.Host != g.o.Host {
+ if ev.V != 1 || ev.Op != g.o.OpID || ev.Host != g.o.Host || ev.Identity != name {
return false, ErrIdentity
}
- return moves.WriteLog(ctx, g.w.raw, moves.ReportPrefix(g.o.Account, g.o.App, g.o.Host, g.o.OpID), ev)
+ live := rec.Move != nil && rec.Move.ID == g.o.OpID && rec.Move.Incarnation == g.o.Incarnation
+ closed := rec.Move == nil && rec.LastMove != nil && rec.LastMove.ID == g.o.OpID
+ if !live && !closed {
+ return false, ErrIdentity
+ }
+ key := moves.LogKeyFor(moves.ReportPrefix(g.o.Account, g.o.App, g.o.Host, g.o.OpID), name)
+ if _, err := g.w.raw.Put(ctx, key, body, ""); err == nil {
+ return true, nil
+ } else if !errors.Is(err, release.ErrCASConflict) {
+ return false, err
+ }
+ have, _, err := g.w.raw.Get(ctx, key)
+ if err != nil {
+ return false, err
+ }
+ if !bytes.Equal(have, body) {
+ return false, ErrMoveLogTaken
+ }
+ return false, nil
+}
+
+// moveLogName is one path segment of letters, digits, '_' and '-', at most
+// 128 bytes: every identity the move log names (moves.TransitionOf) fits.
+func moveLogName(name string) bool {
+ if name == "" || len(name) > 128 {
+ return false
+ }
+ for _, r := range name {
+ if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') {
+ return false
+ }
+ }
+ return true
+}
+
+// Transition is what the grant's record write did, named as the move log
+// names it (moves.TransitionOf over the record before and after the write),
+// once Record has written. It is a value, never authority: the caller's log
+// entry is built from the write's own evidence, not from a later read.
+func (g *MoveRecoveryGrant) Transition() (moves.Transition, bool) {
+ if g == nil || g.wrote == nil {
+ return moves.Transition{}, false
+ }
+ return *g.wrote, true
}
// Observation contains values, never authority.
@@ -231,8 +302,15 @@ func (g *MoveRecoveryGrant) Record(ctx context.Context, action MoveRecoveryRecor
if err != nil {
return err
}
- _, _, err = provision.WriteRecord(provision.WithRecordSequence(ctx), g.w.raw, r.Holder.Key, r.Body, body, r.ETag)
- return err
+ if _, _, err = provision.WriteRecord(provision.WithRecordSequence(ctx), g.w.raw, r.Holder.Key, r.Body, body, r.ETag); err != nil {
+ return err
+ }
+ if before, err := provision.DecodeRecord(r.Body); err == nil {
+ if t, ok := moves.TransitionOf(before, rec); ok {
+ g.wrote = &t
+ }
+ }
+ return nil
}
func (g *MoveRecoveryGrant) prepareRecord(ctx context.Context, rec provision.Record) ([]byte, error) {
diff --git a/internal/hostops/move_recovery_grant_test.go b/internal/hostops/move_recovery_grant_test.go
index ab7898158..827032803 100644
--- a/internal/hostops/move_recovery_grant_test.go
+++ b/internal/hostops/move_recovery_grant_test.go
@@ -2,8 +2,10 @@ package hostops
import (
"context"
+ "encoding/json"
"errors"
"fmt"
+ "strings"
"testing"
"time"
@@ -183,9 +185,15 @@ func TestMoveRecoveryRecordCloseThenRelease(t *testing.T) {
}
}
-// Plan 6 Task 3 fix round 1, ruling 3: the grant's Log writes this move's
-// log, create-only, under its own report prefix, after the grant's one
-// mutation as before it, and only while its hook runs.
+// Plan 6 Task 3 fix round 2, ruling 4 (platform-1e's conditions): the
+// grant's Log writes one entry of its own move's log. The key is derived
+// from the grant alone (its move's report prefix); the caller names only a
+// single-segment entry. The entry is at most MoveLogEntryMax bytes, is this
+// move's and this host's, and is named by its identity. The write is
+// create-only: a replay of the same bytes is a no-op, other bytes under the
+// name are ErrMoveLogTaken. Log answers only while the grant is valid, by
+// the check every other grant method uses: not after its one mutation, and
+// not after its hook.
func TestMoveRecoveryGrantLogsItsOwnMove(t *testing.T) {
w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindLanding, hostclaim.PhaseCommitted, "target"
@@ -198,29 +206,41 @@ func TestMoveRecoveryGrantLogsItsOwnMove(t *testing.T) {
r.Move.SourceParkedAt = &now
})
ctx := context.Background()
- ev := moves.LogEvent{V: 1, Op: "move", Host: w.host, Event: "done", Identity: "done", Actor: "console", At: w.now}
+ entry := func(op, host, id string) []byte {
+ b, err := json.Marshal(moves.LogEvent{V: 1, Op: op, Host: host, Event: "x", Identity: id, Actor: "console", At: w.now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+ }
+ body := entry("move", w.host, "opened")
var saved *MoveRecoveryGrant
+ got := map[string]error{}
var created, again bool
- var other, wrongHost error
err := w.control.RunRecovery(ctx, func(ctx context.Context) error {
return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error {
saved = g
- if err := g.Record(ctx, RecoveryMoveClose); err != nil {
+ var err error
+ if created, err = g.Log(ctx, "opened", body); err != nil {
return err
}
- var err error
- if created, err = g.Log(ctx, ev); err != nil {
+ if again, err = g.Log(ctx, "opened", body); err != nil {
return err
}
- if again, err = g.Log(ctx, ev); err != nil {
+ other, _ := json.Marshal(moves.LogEvent{V: 1, Op: "move", Host: w.host, Event: "x", Identity: "opened", Actor: "target", At: w.now})
+ _, got["other bytes"] = g.Log(ctx, "opened", other)
+ _, got["another move"] = g.Log(ctx, "other", entry("other", w.host, "other"))
+ _, got["another host"] = g.Log(ctx, "elsewhere", entry("move", "elsewhere.example", "elsewhere"))
+ _, got["a name that is not the identity"] = g.Log(ctx, "done", entry("move", w.host, "opened"))
+ _, got["a path"] = g.Log(ctx, "../source", entry("move", w.host, "../source"))
+ _, got["an empty name"] = g.Log(ctx, "", entry("move", w.host, ""))
+ big := moves.LogEvent{V: 1, Op: "move", Host: w.host, Event: "x", Identity: "big", Fields: map[string]string{"pad": strings.Repeat("a", MoveLogEntryMax)}}
+ bb, _ := json.Marshal(big)
+ _, got["too big"] = g.Log(ctx, "big", bb)
+ if err := g.Record(ctx, RecoveryMoveClose); err != nil {
return err
}
- o := ev
- o.Op = "other"
- _, other = g.Log(ctx, o)
- h := ev
- h.Host = "elsewhere.example"
- _, wrongHost = g.Log(ctx, h)
+ _, got["after the mutation"] = g.Log(ctx, "done", entry("move", w.host, "done"))
return nil
})
})
@@ -228,17 +248,26 @@ func TestMoveRecoveryGrantLogsItsOwnMove(t *testing.T) {
t.Fatal(err)
}
if !created || again {
- t.Fatalf("created=%v, a replay created=%v", created, again)
+ t.Fatalf("created=%v, a replay of the same bytes created=%v", created, again)
+ }
+ for what, want := range map[string]error{
+ "other bytes": ErrMoveLogTaken, "another move": ErrIdentity, "another host": ErrIdentity,
+ "a name that is not the identity": ErrIdentity, "a path": ErrIdentity, "an empty name": ErrIdentity,
+ "too big": ErrIdentity, "after the mutation": ErrPending,
+ } {
+ if !errors.Is(got[what], want) {
+ t.Errorf("%s: %v, want %v", what, got[what], want)
+ }
}
- if !errors.Is(other, ErrIdentity) || !errors.Is(wrongHost, ErrIdentity) {
- t.Fatalf("another move's log: %v, another host's: %v", other, wrongHost)
+ logged, err := moves.ReadLog(ctx, w.raw, moves.ReportPrefix("bab", "app", w.host, "move"))
+ if err != nil || len(logged) != 1 || logged[0].Identity != "opened" {
+ t.Fatalf("log %+v %v", logged, err)
}
- got, err := moves.ReadLog(ctx, w.raw, moves.ReportPrefix("bab", "app", w.host, "move"))
- if err != nil || len(got) != 1 || got[0].Identity != "done" || got[0].Actor != "console" {
- t.Fatalf("log %+v %v", got, err)
+ raw, _, err := w.raw.Get(ctx, moves.LogKeyFor(moves.ReportPrefix("bab", "app", w.host, "move"), "opened"))
+ if err != nil || string(raw) != string(body) {
+ t.Fatalf("the object is not the caller's bytes: %s %v", raw, err)
}
- ev.Identity, ev.Event = "claim-released", "claim_released"
- if _, err := saved.Log(ctx, ev); !errors.Is(err, ErrPending) {
+ if _, err := saved.Log(ctx, "late", entry("move", w.host, "late")); !errors.Is(err, ErrPending) {
t.Fatalf("a grant kept past its hook logged: %v", err)
}
}
diff --git a/internal/moves/transition.go b/internal/moves/transition.go
index db7e27c9c..934ff6e49 100644
--- a/internal/moves/transition.go
+++ b/internal/moves/transition.go
@@ -32,10 +32,10 @@ func phaseOf(m *provision.MoveState) (phase, target, source string) {
return "", "", ""
}
-// transitionFields are a move's regions and the digests its evidence has
-// recorded so far: the source's stop (final TXID, database, files, timers),
-// the landing's installed database, the pinned inputs (release, env
-// report).
+// transitionFields are a move's regions, the digests its evidence has
+// recorded so far (the source's stop: final TXID, database, files, timers;
+// the landing's installed database; the pinned inputs: release, env report)
+// and its admitted cut budget.
func transitionFields(m *provision.MoveState) map[string]string {
f := map[string]string{"from": m.From, "to": m.To}
set := func(k, v string) {
@@ -60,6 +60,10 @@ func transitionFields(m *provision.MoveState) map[string]string {
set("release", in.Release)
set("env_report", in.EnvReport)
}
+ if m.CutBudgetS > 0 {
+ // The admitted cut's budget, which the member line for it states.
+ f["cut_budget_s"] = strconv.Itoa(m.CutBudgetS)
+ }
return f
}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index e1b4e0592..b65ef0067 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -473,7 +473,11 @@ func Reviewed(ctx context.Context, s Store, k string) error {
// BEGIN REVIEWED INVENTORY
var plan9B8CensusInventory = []censusEntry{
{"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "794963a24a0a50d980505f83ac912973a10267b21f74bd41c56584ab9b0b25a1"},
- {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Record", "record-writer", "key:WriteRecord", "6b8bd4131842d8f22a6ab55aaae736329df40e34abb348d3d68cbd0a713503ad"},
+ // Writes one entry of the grant's own move's log, create-only, under its
+ // report prefix (moves/<acct>/<app>/<host>/<op>/log/); never a record
+ // (Plan 6 Task 3 fix round 2, ruling 4).
+ {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Log", "nonrecord", "sel:Put", "9f90ff153382fc6278ed0c17dffa5e0bfda01287716af1fa3149fef9772213c2"},
+ {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Record", "record-writer", "key:WriteRecord", "f7f04a9e2c579eaa7d254ce6310e2726d95855307d61f3f442ee9c1fa2a5d825"},
// Plan 10: reviewed typed console move and loss writers.
{"internal/hostops/console_move_grant.go", "ControlWriter.ConsoleMove", "record-writer", "key:WriteRecord", "21e7a6ddc4dbe5b3bf06e65b39a228e2eb588af1edc43beb09d91dd72570dd44"},
{"internal/hostops/console_move_grant.go", "ControlWriter.consoleMoveClaim", "key-site", "key:RecordKey", "265291bd356de061663d89e5ebe33b74ea32c086f8238736d6f971a5afcab722"},