Final fix 2: Ready refuses a database grown past the size the target priced
The target's report priced T11's budget and T6's disk check for TargetReport.DBMiB. The source's report now carries ReplicaBytes, the logical size its replica's newest LTX header states (read as the target reads its staged copy; an unreadable header ends the round), beside the local file's DBBytes. Ready refuses while either exceeds DBMiB, until the target's next report prices the new size (MV-N155, pending). The review's example, 100 MiB to 2.5 GiB under a fresh report, is pinned in moves and end to end in host. The host test fake lists an object written with no version as "null", as S3 does, so the source can read its own replica's headers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+228
−14
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+1 −0internal/host/movesgrowth_test.go+82 −0internal/host/movesphase1.go+13 −1internal/host/movesphase1_harness_test.go+12 −2internal/host/movesreport.go+41 −5internal/moves/readiness.go+30 −5internal/moves/readiness_test.go+48 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index b6eecce4d..5ae5a3fe1 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -64,7 +64,7 @@ var movesCopyIDs = []string{ |
| "MV-N133", "MV-N134", "MV-N135", "MV-N136", "MV-N137", "MV-N138", |
| "MV-N139", "MV-N140", "MV-N141", "MV-N142", "MV-N143", "MV-N144", |
| "MV-N145", "MV-N146", "MV-N147", "MV-N148", "MV-N149", "MV-N150", |
| - "MV-N151", "MV-N152", "MV-N153", "MV-N154", |
| + "MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 57468447e..bf18b48c6 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -194,6 +194,7 @@ Columns: id, file, text, status. |
| | MV-N152 | internal/moves/readiness.go | the cut budget could not be priced | pending | |
| | MV-N153 | internal/host/movesreport.go | this box requires a proof for the source's release and has not accepted one | pending | |
| | MV-N154 | internal/host/movesreport.go | the source staged its archive without a fixed version id | pending | |
| +| MV-N155 | internal/moves/readiness.go | T11: the database has grown to <MiB> MiB since the target priced <MiB> MiB. waiting for the target's next report | pending | |
| ## Plan 2 |
diff --git a/internal/host/movesgrowth_test.go b/internal/host/movesgrowth_test.go| new file mode 100644 |
| index 000000000..13825369e |
| --- /dev/null |
| +++ b/internal/host/movesgrowth_test.go |
| @@ -0,0 +1,82 @@ |
| +package host |
| + |
| +import ( |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Final review finding 2, end to end: the target's fresh report priced |
| +// T11 and checked T6 for the database it saw, 16 KiB here. The database |
| +// then grows to 2.5 GiB before the target reports again, as the source's |
| +// local file or as its replica's newest LTX. The source's next round must |
| +// not promote on the old report, and the source's report must carry the |
| +// size it saw. The target's next report prices the new size. |
| +func TestPromotionWaitsForAReportCoveringTheGrownDatabase(t *testing.T) { |
| + const grown = 2560 << 20 |
| + for name, grow := range map[string]func(t *testing.T, h *phase1Harness){ |
| + "local file": func(t *testing.T, h *phase1Harness) { |
| + db, err := os.Create(provision.DefaultDBPath(h.cfg.DataDir, p1Host)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := db.Truncate(grown); err != nil { // sparse |
| + t.Fatal(err) |
| + } |
| + db.Close() |
| + }, |
| + "replica": func(t *testing.T, h *phase1Harness) { |
| + newest := ltxForTest(t, 4096, grown/4096, 3, 3) |
| + key := "instances/" + p1Host + "/" + h.inc + "/g-cur/0000/0000000000000003-0000000000000003.ltx" |
| + h.f.store("data-ie", key, fakeMoveObject{lease.MirrorObject{ETag: "grown", Size: int64(len(newest))}, string(newest)}) |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + h.pass(t) |
| + h.targetPass(t, h.targetCfg()) |
| + tr := h.targetReport(t) |
| + if ok, why := tr.Passing(); !ok || tr.DBMiB*(1<<20) != 4*4096 { |
| + t.Fatalf("the target's first report: %v %s, priced %v MiB", ok, why, tr.DBMiB) |
| + } |
| + grow(t, h) |
| + h.pass(t) |
| + if got := h.read(t, p1Host).Move.State; got != provision.MovePreparing { |
| + t.Fatalf("promoted on a report that priced %v MiB after the database grew to 2.5 GiB: %s", tr.DBMiB, got) |
| + } |
| + sr := h.sourceReport(t) |
| + if max(sr.DBBytes, sr.ReplicaBytes) != grown { |
| + t.Fatalf("the source report carries DBBytes %d and ReplicaBytes %d, want %d", sr.DBBytes, sr.ReplicaBytes, int64(grown)) |
| + } |
| + _, why := moves.Ready(0, h.cfg.ReadyDelta, h.targetReport(t), sr, h.clock.Now()) |
| + if !strings.HasPrefix(why, "T11: the database has grown to 2560 MiB") { |
| + t.Fatalf("Ready's reason: %q", why) |
| + } |
| + h.targetPass(t, h.targetCfg()) |
| + if r := h.targetReport(t); r.DBMiB != 2560 { |
| + t.Fatalf("the target's next report priced %v MiB", r.DBMiB) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Without growth the same sequence promotes: the control for the test |
| +// above, and the replica's size is what the source reports. |
| +func TestSourceReportCarriesTheReplicaSize(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + h.pass(t) |
| + if sr := h.sourceReport(t); sr.ReplicaBytes != 4*4096 { |
| + t.Fatalf("ReplicaBytes %d, want the 16 KiB the newest header names", sr.ReplicaBytes) |
| + } |
| + h.targetPass(t, h.targetCfg()) |
| + h.pass(t) |
| + if got := h.read(t, p1Host).Move.State; got != provision.MoveReady { |
| + t.Fatalf("state %s, want ready", got) |
| + } |
| +} |
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go| index a68c73296..534eb1d00 100644 |
| --- a/internal/host/movesphase1.go |
| +++ b/internal/host/movesphase1.go |
| @@ -190,6 +190,9 @@ type srcRound struct { |
| s1 *moves.Check // a refusal; nil means probe the instance |
| ledger moveLedger |
| deltaBytes int64 |
| + // replicaBytes is the replica's logical size this round |
| + // (replicaLogicalSize), carried as SourceReport.ReplicaBytes. |
| + replicaBytes int64 |
| } |
| // preparingRound is §7.3's source round, in order: no deployment bucket |
| @@ -337,7 +340,15 @@ func (w *movesWorker) preparingRound(ctx context.Context, s release.Store, rec p |
| } |
| } |
| - sr := &srcRound{deltaBytes: round.DeltaBytes()} |
| + // The database's size as this round's replica states it, for Ready to |
| + // hold against the size the target's report priced (final review |
| + // finding 2). An unreadable header ends the round: no report, no |
| + // promotion. |
| + replicaBytes, err := w.replicaLogicalSize(ctx, gm, srcPrefix, keep) |
| + if err != nil { |
| + return err |
| + } |
| + sr := &srcRound{deltaBytes: round.DeltaBytes(), replicaBytes: replicaBytes} |
| // The files archive, rebuilt only when its plaintext digest changes. A |
| // file that changed under the walk ends the round here, before any |
| // report or readiness: the archive the target checked is no longer the |
| @@ -637,6 +648,7 @@ func (w *movesWorker) writeSourceReport(ctx context.Context, s release.Store, re |
| if st, err := os.Stat(provision.DefaultDBPath(cfg.DataDir, rec.Host)); err == nil && st.Mode().IsRegular() { |
| sr.DBBytes = st.Size() |
| } |
| + sr.ReplicaBytes = r.replicaBytes |
| if cfg.OptDir != "" { |
| if v, err := release.LiveVersion(cfg.OptDir, rec.Host); err == nil { |
| sr.Release = v |
diff --git a/internal/host/movesphase1_harness_test.go b/internal/host/movesphase1_harness_test.go| index 67eca74de..97f5d1f94 100644 |
| --- a/internal/host/movesphase1_harness_test.go |
| +++ b/internal/host/movesphase1_harness_test.go |
| @@ -164,18 +164,28 @@ func (v *fakeVersioned) ListVersions(_ context.Context, bucket, prefix string) ( |
| var out []lease.ObjectVersion |
| for k, o := range v.f.buckets[bucket] { |
| if strings.HasPrefix(k, prefix) { |
| - out = append(out, lease.ObjectVersion{Key: k, VersionID: v.f.versionOf(bucket, k), ETag: o.ETag, Size: o.Size, IsLatest: true}) |
| + out = append(out, lease.ObjectVersion{Key: k, VersionID: v.versionID(bucket, k), ETag: o.ETag, Size: o.Size, IsLatest: true}) |
| } |
| } |
| return out, nil |
| } |
| +// versionID is an object's version as S3 lists it: one written with no |
| +// version (the source's replica, stored directly by a test) is "null", |
| +// as S3 lists an object written while a bucket was not versioning. |
| +func (v *fakeVersioned) versionID(bucket, key string) string { |
| + if ver := v.f.versionOf(bucket, key); ver != "" { |
| + return ver |
| + } |
| + return "null" |
| +} |
| + |
| func (v *fakeVersioned) GetVersion(_ context.Context, bucket, key, versionID string) ([]byte, error) { |
| if err := v.call("get-version", bucket, key); err != nil { |
| return nil, err |
| } |
| o, ok := v.f.buckets[bucket][key] |
| - if !ok || versionID == "" || v.f.versionOf(bucket, key) != versionID { |
| + if !ok || versionID == "" || v.versionID(bucket, key) != versionID { |
| return nil, lease.ErrMirrorNotFound |
| } |
| return []byte(o.body), nil |
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go| index fecff8f80..4b2f521da 100644 |
| --- a/internal/host/movesreport.go |
| +++ b/internal/host/movesreport.go |
| @@ -527,11 +527,31 @@ func (w *movesWorker) stagedReplica(ctx context.Context, rec provision.Record) ( |
| if err != nil { |
| return 0, 0, err |
| } |
| + for _, o := range objs { |
| + staged += o.Size |
| + } |
| + newest := newestLTX(objs, prefix, nil) |
| + if newest == "" { |
| + return staged, 0, errNoReplicaStaged |
| + } |
| + hdr, err := w.ltxHeader(ctx, newest) |
| + if err != nil { |
| + return staged, 0, fmt.Errorf("the newest staged LTX file %s: %w", newest, err) |
| + } |
| + return staged, int64(hdr.Commit) * int64(hdr.PageSize), nil |
| +} |
| + |
| +// newestLTX is the key in objs of the newest LTX file under prefix, the one |
| +// with the highest max TXID (ties go to the greater key), or "" when there |
| +// is none. keep, when non-nil, must admit a key relative to prefix. |
| +func newestLTX(objs map[string]lease.MirrorObject, prefix string, keep func(string) bool) string { |
| var newest string |
| var newestTX ltx.TXID |
| - for k, o := range objs { |
| - staged += o.Size |
| + for k := range objs { |
| rel := strings.TrimPrefix(k, prefix) |
| + if keep != nil && !keep(rel) { |
| + continue |
| + } |
| _, name, ok := strings.Cut(rel, "/") |
| if !ok { |
| continue |
| @@ -544,14 +564,30 @@ func (w *movesWorker) stagedReplica(ctx context.Context, rec provision.Record) ( |
| newest, newestTX = k, maxTX |
| } |
| } |
| + return newest |
| +} |
| + |
| +// replicaLogicalSize is the source's half of the size evidence (final |
| +// review finding 2): the database's logical size as its own replica states |
| +// it, from the header of the newest LTX file under the claim's generation |
| +// (srcPrefix, filtered by keep: the files the lane mirrors), read as the |
| +// target reads its staged copy. No LTX file yet is 0; a newest file whose |
| +// header cannot be read is an error, which ends the round before its |
| +// report, so nothing promotes on a size nobody measured. |
| +func (w *movesWorker) replicaLogicalSize(ctx context.Context, m lease.MirrorStore, srcPrefix string, keep func(string) bool) (int64, error) { |
| + objs, err := m.List(ctx, w.cfg.OwnBucket, srcPrefix) |
| + if err != nil { |
| + return 0, fmt.Errorf("list the replica source: %w", err) |
| + } |
| + newest := newestLTX(objs, srcPrefix, keep) |
| if newest == "" { |
| - return staged, 0, errNoReplicaStaged |
| + return 0, nil |
| } |
| hdr, err := w.ltxHeader(ctx, newest) |
| if err != nil { |
| - return staged, 0, fmt.Errorf("the newest staged LTX file %s: %w", newest, err) |
| + return 0, fmt.Errorf("the replica's newest LTX file %s: %w", newest, err) |
| } |
| - return staged, int64(hdr.Commit) * int64(hdr.PageSize), nil |
| + return int64(hdr.Commit) * int64(hdr.PageSize), nil |
| } |
| // ltxHeader reads the header of the current version of key in this box's |
diff --git a/internal/moves/readiness.go b/internal/moves/readiness.go| index 7c6222688..4dfa55cef 100644 |
| --- a/internal/moves/readiness.go |
| +++ b/internal/moves/readiness.go |
| @@ -112,10 +112,16 @@ type SourceReport struct { |
| // DBBytes is the size of the source's local database file when one is |
| // present (a hibernating source may have none). T11 prices the larger |
| // of it and the logical size in the newest staged LTX header. |
| - DBBytes int64 `json:"db_bytes,omitempty"` |
| - Archive *ArchiveRef `json:"archive,omitempty"` |
| - CrossRate float64 `json:"cross_rate"` |
| - CrossRateAt time.Time `json:"cross_rate_at"` |
| + DBBytes int64 `json:"db_bytes,omitempty"` |
| + // ReplicaBytes is the database's logical size as the source's replica |
| + // states it this round: Commit pages of PageSize in the header of the |
| + // newest LTX file under the claim's generation, the one the lane |
| + // mirrors. Ready refuses while either size exceeds the size the |
| + // target's report priced (TargetReport.DBMiB, final review finding 2). |
| + ReplicaBytes int64 `json:"replica_bytes,omitempty"` |
| + Archive *ArchiveRef `json:"archive,omitempty"` |
| + CrossRate float64 `json:"cross_rate"` |
| + CrossRateAt time.Time `json:"cross_rate_at"` |
| } |
| // ArchiveRef names the archive the source published, by exact version, so |
| @@ -180,7 +186,8 @@ func (r TargetReport) Passing() (bool, string) { |
| // must not promote after the source has published archive B. And the budget |
| // must have been priced for a final mirror round as large as the bar allows |
| // (F28(e)), so raising CARLOS_MOVE_READY_DELTA_BYTES cannot promote on an |
| -// estimate made under a smaller one. |
| +// estimate made under a smaller one. Nor can a database that has grown past |
| +// the size the target priced (sizeCovered, final review finding 2). |
| func Ready(roundDelta, bar int64, target *TargetReport, source *SourceReport, now time.Time) (bool, string) { |
| if roundDelta > bar { |
| return false, fmt.Sprintf("the last round moved %d bytes and the bar is %d", roundDelta, bar) |
| @@ -223,6 +230,9 @@ func Ready(roundDelta, bar int64, target *TargetReport, source *SourceReport, no |
| if why := budgetEvidence(target, source, bar, now); why != "" { |
| return false, why |
| } |
| + if why := sizeCovered(target, source); why != "" { |
| + return false, why |
| + } |
| if target.Inputs.FilesPlain != source.FilesPlain { |
| return false, fmt.Sprintf("the target checked files %s and the source now publishes %s", target.Inputs.FilesPlain, source.FilesPlain) |
| } |
| @@ -269,6 +279,21 @@ func budgetEvidence(target *TargetReport, source *SourceReport, bar int64, now t |
| return "" |
| } |
| +// sizeCovered refuses a database that has grown past the size the target's |
| +// report priced (final review finding 2). TargetReport.DBMiB is the size |
| +// both T11's budget and T6's disk check were made for, and the source's |
| +// report carries the size now: its local file (DBBytes) and its replica's |
| +// logical size (ReplicaBytes). A larger one leaves both checks stale even |
| +// while the report is fresh, so promotion waits for the target's next |
| +// report, which prices the new size. Equal or smaller is covered. |
| +func sizeCovered(target *TargetReport, source *SourceReport) string { |
| + size := max(source.DBBytes, source.ReplicaBytes) |
| + if now := float64(size) / (1 << 20); now > target.DBMiB { |
| + return fmt.Sprintf("T11: the database has grown to %g MiB since the target priced %g MiB. waiting for the target's next report", now, target.DBMiB) |
| + } |
| + return "" |
| +} |
| + |
| func reasonOr(c Check, fallback string) string { |
| if c.Reason == "" { |
| return fallback |
diff --git a/internal/moves/readiness_test.go b/internal/moves/readiness_test.go| index 2e2eecdc1..24edb851a 100644 |
| --- a/internal/moves/readiness_test.go |
| +++ b/internal/moves/readiness_test.go |
| @@ -857,3 +857,51 @@ func TestArchiveRefPinnedNeedsImmutableVersions(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// Final review finding 2: the target's report priced the cut (T11) and |
| +// checked the disk (T6) for the database size it saw. When the source's |
| +// report now carries a larger one (its local file, or the logical size of |
| +// its replica's newest LTX), the target's evidence no longer covers it and |
| +// Ready refuses until the target reports again. The review's example: a |
| +// fresh report for 100 MiB, then the database grows to 2.5 GiB, which at |
| +// 10/250/500 MiB/s no longer fits the 120-second window. |
| +func TestReadyRefusesADatabaseGrownSinceTheTargetPriced(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + target := passingTarget(now.Add(-time.Minute)) |
| + m := Measurements{ |
| + Cross: MeasuredRate{MiBPerS: 10, At: now.Add(-time.Minute)}, |
| + S3: MeasuredRate{MiBPerS: 250, At: now.Add(-time.Minute)}, |
| + Disk: MeasuredRate{MiBPerS: 500, At: now.Add(-time.Minute)}, |
| + Fsync: FilesRate{PerS: 1600, At: now.Add(-time.Minute)}, |
| + } |
| + if res := BudgetCheck(2560, 0, m, 16<<20, 120, now); res.Check.OK { |
| + t.Fatalf("the example does not overrun its window: %+v", res) |
| + } |
| + const grown = 2560 << 20 |
| + for name, src := range map[string]func(*SourceReport){ |
| + "local file": func(s *SourceReport) { s.DBBytes = grown }, |
| + "replica": func(s *SourceReport) { s.ReplicaBytes = grown }, |
| + "one byte": func(s *SourceReport) { s.ReplicaBytes = 100<<20 + 1 }, |
| + } { |
| + s := goodSource(now) |
| + src(s) |
| + ok, why := Ready(0, 16<<20, target, s, now) |
| + if ok || !strings.HasPrefix(why, "T11: the database has grown to ") { |
| + t.Errorf("%s: Ready = %v %q, want the grown-database refusal", name, ok, why) |
| + } |
| + } |
| + want := "T11: the database has grown to 2560 MiB since the target priced 100 MiB. waiting for the target's next report" |
| + s := goodSource(now) |
| + s.DBBytes = grown |
| + if _, why := Ready(0, 16<<20, target, s, now); why != want { |
| + t.Errorf("reason %q, want %q", why, want) |
| + } |
| + // The size the target priced, or less, is covered. |
| + for _, size := range []int64{0, 1, 100 << 20} { |
| + s := goodSource(now) |
| + s.DBBytes, s.ReplicaBytes = size, size |
| + if ok, why := Ready(0, 16<<20, target, s, now); !ok { |
| + t.Errorf("size %d: not ready: %s", size, why) |
| + } |
| + } |
| +} |