carlos / platform Public

console: a row about to be finished resolves an unconfirmed request by the fence first

The reconciliation branches that finish a row (the record names the
move's outcome, carries no move, or carries another move) no longer wait
on an unconfirmed request whose write can never be replayed there: it is
fenced and confirmed or voided, and the row is decided again next tick.
The internal/console envelope is re-pinned (PROPOSED-self-serve-moves-2).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a905f7c49ce6eed38584cc5b6af128509884af1e parent 37eaf15
3 files changed, +38 −7
  • internal/console/selfserve_job.go +14 −6
  • internal/e2e/selfserve/job_reconcile_test.go +23 −0
  • internal/provision/testdata/plan14-root-reads-self-serve-moves-2.tsv +1 −1

CI failed — run details

CI log
=== gate ===
.amadan/test-gate.sh

amadan: job timed out after 30m0s

amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/internal/console/selfserve_job.go b/internal/console/selfserve_job.go
index 744a55211..6006ac08e 100644
--- a/internal/console/selfserve_job.go
+++ b/internal/console/selfserve_job.go
@@ -327,10 +327,18 @@ func (s *Server) selfServeReconcile(ctx context.Context, t *leaseToken, row *Sel
return true, s.selfServeFinalise(ctx, t, row, provision.OfflineOutcomeAborted, msgSelfServeRecreated, "")
}
mv := rec.Move
+ // A row about to be finished is never finished with a request
+ // outstanding: an unconfirmed one is resolved by the fence first (its
+ // write may otherwise land later, or never be replayable on this
+ // record), and the row is decided again next tick.
+ unresolved := func() bool {
+ out, conf := selfServeOutstanding(row)
+ return out && !conf
+ }
switch {
case mv == nil && rec.LastMove != nil && rec.LastMove.ID == row.ID:
- if out, conf := selfServeOutstanding(row); out && !conf {
- return true, nil // resolved first
+ if unresolved() {
+ return true, s.selfServeResolveFenced(ctx, t, row)
}
return true, s.selfServeFinalise(ctx, t, row, rec.LastMove.Outcome, s.selfServeOutcomeReason(row, rec.LastMove.Outcome), "")
case mv == nil:
@@ -338,13 +346,13 @@ func (s *Server) selfServeReconcile(ctx context.Context, t *leaseToken, row *Sel
case ssRequested, ssReserving:
return s.selfServeQueueFence(ctx, t, row, rec, now)
}
- if out, conf := selfServeOutstanding(row); out && !conf {
- return true, nil
+ if unresolved() {
+ return true, s.selfServeResolveFenced(ctx, t, row)
}
return true, s.selfServeFinalise(ctx, t, row, provision.OfflineOutcomeAborted, msgSelfServeCouldNotStart, "")
case mv.ID != row.ID:
- if out, conf := selfServeOutstanding(row); out && !conf {
- return true, nil
+ if unresolved() {
+ return true, s.selfServeResolveFenced(ctx, t, row)
}
return true, s.selfServeFinalise(ctx, t, row, provision.OfflineOutcomeAborted, msgSelfServeAnotherMove, "")
}
diff --git a/internal/e2e/selfserve/job_reconcile_test.go b/internal/e2e/selfserve/job_reconcile_test.go
index cc5c4f937..de5d2e604 100644
--- a/internal/e2e/selfserve/job_reconcile_test.go
+++ b/internal/e2e/selfserve/job_reconcile_test.go
@@ -201,6 +201,29 @@ WHERE id = ?`, time.Now().UnixNano(), rec.Incarnation, rec.RecordSeq, time.Now()
t.Fatalf("the record was touched: %+v (seq %d, was %d)", after.Move, after.RecordSeq, before.RecordSeq)
}
})
+ t.Run("an unconfirmed queue on a record carrying another move", func(t *testing.T) {
+ // The queue was issued against a record another move had already
+ // taken (a move that landed between the job's checks and its
+ // read): its write can never land and is never replayable, so it
+ // is fenced and voided before the row is finished.
+ w := newJobWorld(t)
+ row := w.admit()
+ other := moveID()
+ w.edit(provision.OfflineStage(other, "", host.E2ETargetRegion, "op@example.com",
+ provision.StageTarget{Stager: host.E2ESourceBox, Bucket: "data-uk", Region: "eu-west-2"}, time.Now(), ""))
+ rec := w.record()
+ w.setPhase(row.ID, "reserving", "")
+ w.sql(`UPDATE self_serve_moves SET request_issued = ?, request_kind = 'queue', request_phase = 'reserving', request_nonce = 'rq_stuckqueue',
+ request_expect_inc = ?, request_expect_seq = ?, request_proposed = ?, request_confirmed = 0, request_consumed = 0, request_voided = 0, request_deadline = 0
+WHERE id = ?`, time.Now().UnixNano(), rec.Incarnation, rec.RecordSeq, time.Now().UnixNano(), row.ID)
+ r := w.finished(row.ID, 6)
+ if r.OutcomeReason != "another move is in flight" || w.hits["queue:fence"] == 0 {
+ t.Fatalf("finished %s, fences %d", describe(r), w.hits["queue:fence"])
+ }
+ if after := w.record(); after.Move == nil || after.Move.ID != other {
+ t.Fatalf("the other move was touched: %+v", after.Move)
+ }
+ })
t.Run("the record is gone", func(t *testing.T) {
w := newJobWorld(t)
row := w.admit()
diff --git a/internal/provision/testdata/plan14-root-reads-self-serve-moves-2.tsv b/internal/provision/testdata/plan14-root-reads-self-serve-moves-2.tsv
index fa2736277..7a8c86d7e 100644
--- a/internal/provision/testdata/plan14-root-reads-self-serve-moves-2.tsv
+++ b/internal/provision/testdata/plan14-root-reads-self-serve-moves-2.tsv
@@ -23,7 +23,7 @@ internal/console/templates/settings.html external legacy accepted-legacy accepte
internal/provision/offline_move.go offlineBegin legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-2;basis=root-input (J4, as OfflineStart); the console (cmd/console serve under console.service, user carlos-console): self-serve moves 1 Task 7's body (OfflineStart's, the state a parameter; a direct start advances move_seq and stamps move.seq, refusing ErrLifetimeExhausted at MaxInt64 before any write), and it now also creates the self-serve move in offline-queued for OfflineQueue (queued_at, staged_at and precheck_at at the caller's stamp, move.seq set to the record's move_seq, no advance) and records return_of; a stage and a queue hold nothing; its one site is still rec.ActiveRun.Open(), a method on the record's run state (RunState.Open), not a file read; self-serve moves 2 Task 2 file:Open@208 0a9b432ddf2f39a977c0651e377258b35c1d05635e9979583eeac6253d6c0ec8
@coverage internal/provision 42588b9f64803b41c4463efb31e69fda0fc66573efd5d6a2d6e709682eeca385
# Task 3 (per-box reservations with a legacy projection and a sentinel, the fenced reclaim and the operator doors' bound admission), rev3=PROPOSED-self-serve-moves-2: no root read, class or task is added and no accepted-legacy unit is re-pinned, so the seal does not move. The new units (moves.ParseOfflineActive, moves.OfflineActiveBody, the console's offlinereserve.go, the doors' offlineApplyBound and offlineAdvanceBound) read no file: their reads are the bucket's control/offline-moves/active.json and the instance record through the judged readers and fenceRecord. Envelopes re-pinned in place (Task 2's rows replaced): internal/console (offlinereserve.go, the start and stage doors, the Server's two evidence fields) and internal/moves (the reservation table in offline.go).
-@coverage internal/console 02de4303cae08253409c954948edd85be6ac28e3526ed1739df2611e2ac59c49
+@coverage internal/console 15d866f96bd8172d527b9eb40c99c327deab70034c207506072d1ea7440ce631
@coverage internal/moves 6a68b01ef865d9356bb19971f4cd4a61f7694769b63cee1d8234f615d5e9c8ba
# Task 4 (the journal, the events table, the console lease and the pass skeleton), rev3=PROPOSED-self-serve-moves-2: no root read, class or task is added. The console SQLite journal (selfserve_store.go) adds twenty-two units, each frozen accepted-legacy with a reviewed basis (console SQL as carlos-console, no bucket or root action), and the lease's console name adds one crypto/rand read (Server.selfServeConsoleID); serve (in place: the paging arm takes the store, the pass is started), NewServer (the row evidence and the recovery-mode scan) and Store.migrate (migrateSelfServe) are re-pinned. The seal moves. The lease and the pass read control/offline-moves/console.json and active.json from the bucket, not a file. Envelopes re-pinned in place: cmd/console, internal/console and internal/provision (WithWriteGuard in WriteRecord).
@legacy a8dd51a494ab3cd2ad371ded2850b6d78492569729527ea82cd8dd0f2b418a78 internal/console/selfserve_lease.go Server.selfServeConsoleID legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-2;basis=console.service user carlos-console, J4 root-input: reads crypto/rand once per process for the random console name the self-serve moves lease carries; randomness grants no root authority file:Read@110 3303b18ec5c77ccaf662064801f94276bfe947c31378cc1ecb8c4fe509d5d4d1