store: add reproducible benchmark for setBlank/Open batching claim
The 32m02s -> 8.818s and 6.728s -> 2.779s headline figures in batching-report.md were measured with a throwaway benchmark that was deleted after use, so nobody could independently check them. Add TestBlankRecoveryBench (internal/sheets/store), an env-var-gated test (RUN_BLANK_RECOVERY_BENCH=1, N via BLANK_RECOVERY_BENCH_N, default 5,001) that reproduces both timings on the current, fixed code. It is a Test rather than a Benchmark* function so its two phases can be timed once, separately, without testing.B rerunning the whole several-hundred-thousand-row fixture build; it stays out of `make test` because the env var is unset there (testing.Short() alone would not skip it, since CI never passes -short). Does not touch TestBlankCrashLeavesDependentsRecoverable or TestBlankFeederOverCapDependentsAreRecoverable, and does not re-measure or revert the pre-fix code.
1 file changed,
+222
−0
internal/sheets/store/handle_blank_recovery_bench_test.go+222 −0
CI passed — run details
Intent
diff --git a/internal/sheets/store/handle_blank_recovery_bench_test.go b/internal/sheets/store/handle_blank_recovery_bench_test.go| new file mode 100644 |
| index 0000000..f8344fd |
| --- /dev/null |
| +++ b/internal/sheets/store/handle_blank_recovery_bench_test.go |
| @@ -0,0 +1,222 @@ |
| +package store |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "testing" |
| + "time" |
| + |
| + rdb "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets" |
| + "amadan.net/oficina/sheets/internal/sheets/calc" |
| + "amadan.net/oficina/sheets/internal/sheets/seal" |
| +) |
| + |
| +// TestBlankRecoveryBench reproduces, on demand, the two headline timings |
| +// batching-report.md's "Measurements" section reports for this task: the |
| +// setBlank/markDirty lock-hold time and Open's crash-recovery pass, both |
| +// measured over the FIXED code (this file never touches, reverts or |
| +// otherwise reconstructs the pre-fix path — see handle_batch.go for what |
| +// changed). |
| +// |
| +// RECORDED FIGURES (batching-report.md), for comparison against a run of |
| +// this test: |
| +// |
| +// N = 199,999 dependents (calc.MaxCellsPerTab - 1, the real ceiling) |
| +// setBlank (h.mu hold, whole Set call): 2.779s |
| +// Open (crash-recovery pass over the N stale dependents): 8.818s |
| +// |
| +// Machine those were measured on: whatever ran the original task session |
| +// — a single unspecified development machine, WAL-mode SQLite via the |
| +// project's real rdb.Open + migrate.Apply boot path (not :memory:), with |
| +// (per the report) "other unrelated background jobs contending for disk" |
| +// noted as a caveat on a related run. No CPU/disk model, core count or |
| +// load average was recorded, which is itself a gap: a wall-clock number |
| +// with no machine description is only reproducible in shape, not in |
| +// absolute value. Treat the figures above as an order-of-magnitude |
| +// target, not a number this test should assert against — a wall-clock |
| +// benchmark that fails CI because a shared runner was slow that day is a |
| +// worse outcome than a benchmark nobody trusts, so this is a Test that |
| +// logs, not a Test that fails, on timing. |
| +// |
| +// WHAT IT MEASURES: |
| +// - Phase 1 ("setBlank"): one Handle.Set call blanking a feeder cell |
| +// that N dependent cells reference as "=A1*2", timed end to end. Set |
| +// holds h.mu for its entire body (handle_set.go), so this is the |
| +// real exclusive-lock hold time any other Value/Set call on this tab |
| +// would have queued behind. At N above calc.MaxDirtyPerPass (5,000) |
| +// — true by default here, see below — setBlank's withinCap is false, |
| +// so this call does ONLY markDirty's batched marking; the recompute |
| +// and persistTouched steps do not run inside it. That is what makes |
| +// this a clean read on markDirty specifically at the real ceiling, |
| +// matching how the recorded 2.779s figure above was measured. |
| +// - Phase 2 ("Open"): the very next Open on the same tab, timed end to |
| +// end. Phase 1 left every dependent's InputVersion ahead of its own |
| +// CacheVersion (markDirty's job), so buildHandle's stale scan |
| +// (handle_open.go) finds all N of them and this Open drives |
| +// recoverStale (handle_batch.go) — the batched crash-recovery pass — |
| +// over the whole set before returning. |
| +// |
| +// GATING: skipped unless RUN_BLANK_RECOVERY_BENCH is set, and always |
| +// skipped under -short. This is a Test, not a Benchmark* function, |
| +// specifically so its two phases can be timed separately with plain |
| +// time.Since calls around a single deliberate run — go test's |
| +// -bench/testing.B machinery reruns its body b.N times to reach a |
| +// stable per-op estimate, which would mean rebuilding a |
| +// several-hundred-thousand-row SQLite fixture on every iteration; that |
| +// is the wrong shape for a fixture this expensive to build once. The |
| +// env var is what actually keeps this out of `make test`: CI's test-go |
| +// target runs plain `go test -count=1 ./...` with no -short flag (see |
| +// Makefile), so testing.Short() alone would not skip it there — the |
| +// env var being unset is what does. testing.Short() is kept as a second, |
| +// independent gate for anyone who runs `go test -short ./...` by habit. |
| +// |
| +// N: set BLANK_RECOVERY_BENCH_N to override; unset defaults to |
| +// calc.MaxDirtyPerPass+1 (5,001) — just over the recompute cap, so a |
| +// default run still exercises the marking-only path the recorded figures |
| +// above describe, and finishes in well under a second post-fix (the |
| +// sibling correctness test, TestBlankFeederOverCapDependentsAreRecoverable, |
| +// runs the same shape at the same N as part of every `make test`). Push |
| +// BLANK_RECOVERY_BENCH_N=199999 to reach the real MaxCellsPerTab-1 |
| +// ceiling the recorded figures were measured at; expect several minutes |
| +// total, dominated by seeding N rows and cells, not by the two timed |
| +// phases themselves. |
| +func TestBlankRecoveryBench(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("store: skipping blank/recovery benchmark under -short") |
| + } |
| + if os.Getenv("RUN_BLANK_RECOVERY_BENCH") == "" { |
| + t.Skip("store: set RUN_BLANK_RECOVERY_BENCH=1 to run (see this file's doc comment; minutes at large N)") |
| + } |
| + n := blankRecoveryBenchN(t) |
| + |
| + dir := t.TempDir() |
| + d, err := rdb.Open(filepath.Join(dir, "bench.db"), nil) |
| + if err != nil { |
| + t.Fatalf("open db: %v", err) |
| + } |
| + t.Cleanup(func() { |
| + if err := d.Close(); err != nil { |
| + t.Errorf("close db: %v", err) |
| + } |
| + }) |
| + db := d.G |
| + if _, err := migrate.Apply(context.Background(), d, sheets.BootSchema); err != nil { |
| + t.Fatalf("migrate: %v", err) |
| + } |
| + s, err := seal.New(make([]byte, 32)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + const localTab, feederRow, colA uint64 = 1, 1, 1 |
| + seedBlankRecoveryFixture(t, db, s, localTab, feederRow, colA, n) |
| + |
| + h, err := Open(db, s, localTab) |
| + if err != nil { |
| + t.Fatalf("Open (initial): %v", err) |
| + } |
| + |
| + blankStart := time.Now() |
| + if err := h.Set(feederRow, colA, "", 7); err != nil { |
| + t.Fatalf("blank feeder: %v", err) |
| + } |
| + blankElapsed := time.Since(blankStart) |
| + h.Release() |
| + |
| + openStart := time.Now() |
| + h2, err := Open(db, s, localTab) |
| + openElapsed := time.Since(openStart) |
| + if err != nil { |
| + t.Fatalf("Open (recovery): %v", err) |
| + } |
| + defer h2.Release() |
| + |
| + t.Logf("N=%d setBlank(h.mu hold)=%s Open(recovery pass)=%s", n, blankElapsed, openElapsed) |
| + t.Logf("recorded (batching-report.md, N=199999): setBlank=2.779s Open=8.818s") |
| +} |
| + |
| +// blankRecoveryBenchN reads BLANK_RECOVERY_BENCH_N, defaulting to |
| +// calc.MaxDirtyPerPass+1 — see this file's doc comment for why. |
| +func blankRecoveryBenchN(t *testing.T) int { |
| + t.Helper() |
| + raw := os.Getenv("BLANK_RECOVERY_BENCH_N") |
| + if raw == "" { |
| + return calc.MaxDirtyPerPass + 1 |
| + } |
| + n, err := strconv.Atoi(raw) |
| + if err != nil || n <= 0 { |
| + t.Fatalf("store: BLANK_RECOVERY_BENCH_N=%q is not a positive integer", raw) |
| + } |
| + return n |
| +} |
| + |
| +// seedBlankRecoveryFixture seeds one feeder cell holding "5" plus n |
| +// dependent cells each holding "=A1*2" with an already-consistent cache |
| +// (cached value 10) — the exact fixture shape |
| +// TestBlankFeederOverCapDependentsAreRecoverable uses, batched via |
| +// db.CreateInBatches the same way, so seeding N rows stays proportional |
| +// rather than N round trips. The same two sealed blobs are reused for |
| +// every dependent (seal.Sealer.Seal takes no per-call AAD binding it to |
| +// a specific cell), matching that test's own reasoning for doing so. |
| +func seedBlankRecoveryFixture(t *testing.T, db *gorm.DB, s *seal.Sealer, tabID, feederRow, colA uint64, n int) { |
| + t.Helper() |
| + |
| + if err := db.Create(&sheets.Tab{ID: tabID, SheetID: 1}).Error; err != nil { |
| + t.Fatalf("seed tab: %v", err) |
| + } |
| + if err := db.Create(&sheets.Col{ID: colA, TabID: tabID, Position: 0}).Error; err != nil { |
| + t.Fatalf("seed col: %v", err) |
| + } |
| + if err := db.Create(&sheets.Row{ID: feederRow, TabID: tabID, Position: 0}).Error; err != nil { |
| + t.Fatalf("seed feeder row: %v", err) |
| + } |
| + |
| + feederInputSealed, err := s.Seal("cell.input", []byte("5")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + feederCachedSealed, err := s.Seal("cell.cached", encodeValue(calc.Value{Kind: calc.KindNum, Num: 5})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := db.Create(&sheets.Cell{ |
| + TabID: tabID, RowID: feederRow, ColID: colA, |
| + InputSealed: feederInputSealed, InputVersion: 1, |
| + CachedSealed: feederCachedSealed, CacheVersion: 1, |
| + }).Error; err != nil { |
| + t.Fatalf("seed feeder cell: %v", err) |
| + } |
| + |
| + depInputSealed, err := s.Seal("cell.input", []byte("=A1*2")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + depCachedSealed, err := s.Seal("cell.cached", encodeValue(calc.Value{Kind: calc.KindNum, Num: 10})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rows := make([]*sheets.Row, 0, n) |
| + cells := make([]*sheets.Cell, 0, n) |
| + for i := 1; i <= n; i++ { |
| + rid := feederRow + uint64(i) |
| + rows = append(rows, &sheets.Row{ID: rid, TabID: tabID, Position: i}) |
| + cells = append(cells, &sheets.Cell{ |
| + TabID: tabID, RowID: rid, ColID: colA, |
| + InputSealed: depInputSealed, InputVersion: 1, |
| + CachedSealed: depCachedSealed, CacheVersion: 1, |
| + }) |
| + } |
| + const batch = 500 |
| + if err := db.CreateInBatches(&rows, batch).Error; err != nil { |
| + t.Fatalf("seed dependent rows: %v", err) |
| + } |
| + if err := db.CreateInBatches(&cells, batch).Error; err != nil { |
| + t.Fatalf("seed dependent cells: %v", err) |
| + } |
| +} |