carlos / platform Public

moves-phase1-6 Task 3 fix round 3 (2): claim_released only from a successful release

The release's entry is queued unconfirmed before g.Claim and confirmed after
it succeeds; the drainer delivers confirmed rows only and settles an
unconfirmed one from the claim: released for that move, confirmed and
delivered; still the move's, left for the release; otherwise dropped with a
journal line. moveReleaseFault is a test seam for a release that fails after
its entry is queued.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 20968908b4c0aef21aed6c912f3ee4781934df0d parent afb2d79
3 files changed, +155 −5
  • internal/console/movesrecovery.go +58 −2
  • internal/console/movesrecovery_test.go +90 −0
  • internal/console/server.go +7 −3
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go
index 4c5e57641..4d5c605f9 100644
--- a/internal/console/movesrecovery.go
+++ b/internal/console/movesrecovery.go
@@ -334,12 +334,26 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant
}
return nil
case moveActClaim:
+ if d.Claim == hostops.RecoveryMoveRelease {
+ // claim_released is published only from a release that
+ // happened: queued unconfirmed now, confirmed once the release
+ // succeeds (a crash between the two is settled by the drainer,
+ // from the claim).
+ s.queueMoveLogAs(o, claimTransition(o, rec, "claim_released", "claim-released"), now, false)
+ if f := s.moveReleaseFault; f != nil {
+ if err := f(); err != nil {
+ return err
+ }
+ }
+ }
if err := g.Claim(ctx, d.Claim); err != nil {
return err
}
switch d.Claim {
case hostops.RecoveryMoveRelease:
- s.queueMoveLog(o, claimTransition(o, rec, "claim_released", "claim-released"), now)
+ if err := s.store.ConfirmMoveLog(o.Host, o.OpID, "claim-released"); err != nil {
+ log.Printf("console: moves: %s: the release of move %s is not confirmed in its log outbox (the drainer settles it from the claim): %v", o.Host, o.OpID, err)
+ }
case hostops.RecoveryMoveRollback:
s.queueMoveLog(o, claimTransition(o, rec, "claim_rollback", "claim-rollback"), now)
}
@@ -545,6 +559,11 @@ func moveAbortOverduePage(host, id, to, box string) (subject, body string) {
// 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 release's own entry (claim_released) is published only from a release
+// that happened (ruling 2): queued unconfirmed before g.Claim and confirmed
+// after it succeeds; the drainer settles a row left unconfirmed (a failed
+// release, or a crash before the confirm) from the claim.
+//
// An entry is queued in the console's outbox (move_log_outbox) with its
// exact bytes and its report prefix, and the recovery pass's drainer
// (drainMoveLog) delivers it, once a pass, whatever its move has done since:
@@ -577,9 +596,16 @@ func moveLogEntry(o hostops.ConsoleMoveObservation, t moves.Transition, now time
// queueMoveLog queues the entry for t, for the drainer.
func (s *Server) queueMoveLog(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time) {
+ s.queueMoveLogAs(o, t, now, true)
+}
+
+// queueMoveLogAs queues the entry for t, confirmed or not: the drainer
+// delivers a confirmed entry, and settles an unconfirmed one from the claim
+// (settleUnconfirmed).
+func (s *Server) queueMoveLogAs(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time, confirmed bool) {
body, err := moveLogEntry(o, t, now)
if err == nil {
- err = s.store.QueueMoveLog(o.Host, o.OpID, t.Identity, moves.ReportPrefix(o.Account, o.App, o.Host, o.OpID), body, true, now)
+ err = s.store.QueueMoveLog(o.Host, o.OpID, t.Identity, moves.ReportPrefix(o.Account, o.App, o.Host, o.OpID), body, confirmed, 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)
@@ -608,11 +634,41 @@ func (s *Server) drainMoveLog(ctx context.Context, lw *hostops.MoveLogWriter) er
if err := ctx.Err(); err != nil {
return err
}
+ if !r.Confirmed && !s.settleUnconfirmed(ctx, r) {
+ continue
+ }
s.deliverMoveLog(ctx, lw, r, now)
}
return nil
}
+// settleUnconfirmed settles a row queued for a release that was not yet
+// confirmed, from the host's claim: released for the row's move, it is
+// confirmed (true: deliver it); still the move's, it is left for the
+// release; anything else, it is dropped with a journal line.
+func (s *Server) settleUnconfirmed(ctx context.Context, r MoveLogPendingEntry) bool {
+ o, err := hostclaim.Read(ctx, s.cfg.Releases, r.Host)
+ if err != nil {
+ log.Printf("console: moves: %s: the unconfirmed log of move %s at %s waits: the claim: %v", r.Host, r.Op, r.Name, err)
+ return false
+ }
+ switch {
+ case !o.Absent && o.Claim != nil && hostclaim.RequireReleasedMove(r.Op)(o.Claim) == nil:
+ if err := s.store.ConfirmMoveLog(r.Host, r.Op, r.Name); err != nil {
+ log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err)
+ return false
+ }
+ return true
+ case !o.Absent && o.Claim != nil && o.Claim.Op.ID == r.Op:
+ return false
+ }
+ log.Printf("console: moves: %s: the log of move %s at %s was never confirmed and the claim no longer holds the move; dropped", r.Host, r.Op, r.Name)
+ if err := s.store.SettleMoveLog(r.Host, r.Op, r.Name); err != nil {
+ log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err)
+ }
+ return false
+}
+
// deliverMoveLog writes one row, and settles it or schedules its retry.
func (s *Server) deliverMoveLog(ctx context.Context, lw *hostops.MoveLogWriter, r MoveLogPendingEntry, now time.Time) {
settle := func() {
diff --git a/internal/console/movesrecovery_test.go b/internal/console/movesrecovery_test.go
index c4e3e1241..d4367ac27 100644
--- a/internal/console/movesrecovery_test.go
+++ b/internal/console/movesrecovery_test.go
@@ -800,6 +800,22 @@ func blockMoveLog(t *testing.T, w *movePassWorld) func() {
}
}
+// blockMoveLogEntry makes the write of one entry fail (a directory where its
+// object goes) until the returned func is called.
+func blockMoveLogEntry(t *testing.T, w *movePassWorld, name string) func() {
+ t.Helper()
+ root := w.fx.rel.(*deletableStore).root
+ p := filepath.Join(root, filepath.FromSlash(moves.LogKeyFor(moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", w.id), name)))
+ if err := os.MkdirAll(p, 0o755); 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)
@@ -867,3 +883,77 @@ func TestRecoveryPassLogOutboxRetries(t *testing.T) {
}
noPendingLog(t, w)
}
+
+// Fix round 3, ruling 2: claim_released is published only from a successful
+// release. The pass queues it unconfirmed before the release and confirms it
+// after; the drainer delivers confirmed rows only, and settles an unconfirmed
+// one by the claim.
+func TestClaimReleasedIsPublishedOnlyAfterTheRelease(t *testing.T) {
+ closed := func(t *testing.T, code string) *movePassWorld {
+ w := landingMovePass(t, code)
+ committedAndOpened(t, w)
+ w.pass(t)
+ w.advance(moveCloseGrace + time.Minute)
+ w.pass(t) // the close
+ return w
+ }
+ t.Run("a release that fails publishes nothing", func(t *testing.T) {
+ w := closed(t, "code-mvpass-release-fails")
+ w.fx.srv.moveReleaseFault = func() error { return hostops.ErrPending }
+ w.pass(t)
+ if c := w.claim(t); c.Op.Kind == hostclaim.KindServing {
+ t.Fatal("released despite the fault")
+ }
+ if _, ok := moveLogOf(t, w)["claim-released"]; ok {
+ t.Fatal("claim_released published for a release that failed")
+ }
+ if p := pendingLog(t, w); strings.Join(p, " ") != "claim-released(unconfirmed)" {
+ t.Fatalf("pending %v", p)
+ }
+ w.pass(t) // the claim is still held: the row is left for the release
+ if _, ok := moveLogOf(t, w)["claim-released"]; ok {
+ t.Fatal("claim_released published while the claim is held")
+ }
+ w.fx.srv.moveReleaseFault = nil
+ w.pass(t)
+ if ev := moveLogOf(t, w)["claim-released"]; ev.Event != "claim_released" {
+ t.Fatalf("not published after the release: %+v", moveLogOf(t, w))
+ }
+ noPendingLog(t, w)
+ })
+ t.Run("a crash between the release and its confirmation", func(t *testing.T) {
+ w := closed(t, "code-mvpass-release-crash")
+ unblock := blockMoveLogEntry(t, w, "claim-released")
+ w.pass(t) // the release; its entry is confirmed, its delivery fails
+ if c := w.claim(t); c.Op.Kind != hostclaim.KindServing {
+ t.Fatalf("not released: %+v", c.Op)
+ }
+ // As if the console stopped after the release and before the confirm.
+ if _, err := w.fx.st.db.Exec(`UPDATE move_log_outbox SET confirmed = 0 WHERE op = ? AND name = 'claim-released'`, w.id); err != nil {
+ t.Fatal(err)
+ }
+ unblock()
+ w.advance(2 * time.Hour)
+ w.pass(t)
+ if ev := moveLogOf(t, w)["claim-released"]; ev.Event != "claim_released" {
+ t.Fatalf("the released claim's entry was not confirmed and published: %+v", moveLogOf(t, w))
+ }
+ noPendingLog(t, w)
+ })
+ t.Run("an unconfirmed row whose claim another move holds", func(t *testing.T) {
+ journal := captureLog(t)
+ w := closed(t, "code-mvpass-release-other")
+ body := []byte(`{"v":1}`)
+ if err := w.fx.st.QueueMoveLog("acme.go.tito.io", "mv_another", "claim-released", moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", "mv_another"), body, false, w.clock); err != nil {
+ t.Fatal(err)
+ }
+ w.pass(t)
+ got, err := w.fx.st.MoveLogPending("acme.go.tito.io", "mv_another")
+ if err != nil || len(got) != 0 {
+ t.Fatalf("pending %+v %v", got, err)
+ }
+ if !strings.Contains(journal.String(), "mv_another") {
+ t.Fatalf("no journal line for the dropped row:\n%s", journal.String())
+ }
+ })
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 1e5c08d1d..efe082076 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -837,9 +837,13 @@ type Server struct {
// conditional record store, bound by ConfigureClaimRuntime with the
// moves switch on; nil otherwise.
stampLostMoveTarget func(ctx context.Context, host string, now time.Time) error
- dbHandleKey []byte
- cfg Config
- store *Store
+ // moveReleaseFault, when set, fails the pass's claim release just before
+ // it is made (a TEST SEAM: a release that fails after its log entry is
+ // queued, movesrecovery.go).
+ moveReleaseFault func() error
+ dbHandleKey []byte
+ cfg Config
+ store *Store
// appRegionOf and beforeClaimInsert are TEST SEAMS (nil in
// production): a store failure on the app-region read (C20), and a
// competing insert landing after the region was resolved.