oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package store_test
2
3import (
4 "context"
5 "path/filepath"
6 "testing"
7
8 rdb "amadan.net/rastrillo/rastrillo/db"
9 "amadan.net/rastrillo/rastrillo/migrate"
10
11 "amadan.net/oficina/sheets/internal/sheets"
12 "amadan.net/oficina/sheets/internal/sheets/calc"
13 "amadan.net/oficina/sheets/internal/sheets/seal"
14 "amadan.net/oficina/sheets/internal/sheets/store"
15)
16
17// TestBlankFeederOverCapDependentsAreRecoverable proves that blanking a
18// feeder cell with MORE dependents than calc.MaxDirtyPerPass still leaves
19// every dependent recoverable on the next store.Open, rather than silently and
20// permanently stale.
21//
22// Before this fix, setBlank (handle_set.go) skipped markDirty entirely
23// once len(touched) > calc.MaxDirtyPerPass. On the UPDATE path that is
24// survivable because the origin cell keeps its own version gap for
25// store.Open's recovery pass to notice. On the BLANK path the origin row is
26// deleted, so nothing is left to notice: every dependent's CacheVersion
27// stays equal to its own (untouched) InputVersion, which store.Open's
28// crash-recovery loop (handle_open.go) reads as "already consistent" and
29// never revisits. Each dependent then keeps serving its old cached value
30// forever, even though the cell it read no longer exists — the exact
31// failure class the crash-recovery mechanism exists to close, surviving
32// past the cap.
33//
34// This lives in package store (not store_test) so it can build its
35// fixture with encodeValue directly — seeding calc.MaxDirtyPerPass+1
36// already-consistent dependent cells through real Set calls would make
37// this test minutes slower for no extra coverage, the same tradeoff
38// handle_test.go's seedOversizedTab makes for TestCellCapIsAnHonestError.
39func TestBlankFeederOverCapDependentsAreRecoverable(t *testing.T) {
40 dir := t.TempDir()
41 d, err := rdb.Open(filepath.Join(dir, "test.db"), nil)
42 if err != nil {
43 t.Fatalf("open db: %v", err)
44 }
45 t.Cleanup(func() {
46 if err := d.Close(); err != nil {
47 t.Errorf("close db: %v", err)
48 }
49 })
50 db := d.G
51 if _, err := migrate.Apply(context.Background(), d, sheets.BootSchema); err != nil {
52 t.Fatalf("migrate: %v", err)
53 }
54 s, err := seal.New(make([]byte, 32))
55 if err != nil {
56 t.Fatal(err)
57 }
58
59 const localTab, feederRow, colA uint64 = 1, 1, 1
60 const n = calc.MaxDirtyPerPass + 1 // strictly over the cap
61
62 if err := db.Create(&store.Tab{ID: localTab, SheetID: 1}).Error; err != nil {
63 t.Fatalf("seed tab: %v", err)
64 }
65 if err := db.Create(&store.Col{ID: colA, TabID: localTab, Position: 0}).Error; err != nil {
66 t.Fatalf("seed col: %v", err)
67 }
68 if err := db.Create(&store.Row{ID: feederRow, TabID: localTab, Position: 0}).Error; err != nil {
69 t.Fatalf("seed feeder row: %v", err)
70 }
71
72 feederInputSealed, err := s.Seal("cell.input", []byte("5"))
73 if err != nil {
74 t.Fatal(err)
75 }
76 feederCachedSealed, err := s.Seal("cell.cached", store.EncodeValueForTest(calc.Value{Kind: calc.KindNum, Num: 5}))
77 if err != nil {
78 t.Fatal(err)
79 }
80 if err := db.Create(&store.Cell{
81 TabID: localTab, RowID: feederRow, ColID: colA,
82 InputSealed: feederInputSealed, InputVersion: 1,
83 CachedSealed: feederCachedSealed, CacheVersion: 1,
84 }).Error; err != nil {
85 t.Fatalf("seed feeder cell: %v", err)
86 }
87
88 // n dependent rows, positions 1..n, each already holding "=A1*2" with
89 // an ALREADY-CONSISTENT cache (CacheVersion == InputVersion, cached
90 // value 10 — the real value of A1*2 with A1 = 5). No AAD binds a
91 // seal to a specific cell (seal.Sealer.Seal takes no per-call
92 // context beyond the purpose string), so the same two sealed blobs
93 // are reused for every dependent rather than sealing n times.
94 depInputSealed, err := s.Seal("cell.input", []byte("=A1*2"))
95 if err != nil {
96 t.Fatal(err)
97 }
98 depCachedSealed, err := s.Seal("cell.cached", store.EncodeValueForTest(calc.Value{Kind: calc.KindNum, Num: 10}))
99 if err != nil {
100 t.Fatal(err)
101 }
102 rows := make([]*store.Row, 0, n)
103 cells := make([]*store.Cell, 0, n)
104 for i := 1; i <= n; i++ {
105 rid := feederRow + uint64(i)
106 rows = append(rows, &store.Row{ID: rid, TabID: localTab, Position: i})
107 cells = append(cells, &store.Cell{
108 TabID: localTab, RowID: rid, ColID: colA,
109 InputSealed: depInputSealed, InputVersion: 1,
110 CachedSealed: depCachedSealed, CacheVersion: 1,
111 })
112 }
113 const batch = 500
114 if err := db.CreateInBatches(&rows, batch).Error; err != nil {
115 t.Fatalf("seed dependent rows: %v", err)
116 }
117 if err := db.CreateInBatches(&cells, batch).Error; err != nil {
118 t.Fatalf("seed dependent cells: %v", err)
119 }
120
121 h, err := store.Open(db, s, localTab)
122 if err != nil {
123 t.Fatalf("store.Open: %v", err)
124 }
125
126 // Sanity: the fixture really is over the cap and dependents really
127 // do read 10 to start, so the assertions below are exercising the
128 // over-cap blank path, not something store.Open's own startup recompute
129 // already fixed for an unrelated reason.
130 sample := []uint64{feederRow + 1, feederRow + n/2, feederRow + n}
131 for _, rid := range sample {
132 if got := h.Value(rid, colA); got.Kind != calc.KindNum || got.Num != 10 {
133 t.Fatalf("dependent row %d before blank = %+v, want KindNum 10", rid, got)
134 }
135 }
136 if touched := store.TransitiveDependentsForTest(h, calc.CellKey{RowID: 1, ColID: 1}); len(touched) <= calc.MaxDirtyPerPass {
137 t.Fatalf("test setup: only %d dependents wired, want more than MaxDirtyPerPass (%d)", len(touched), calc.MaxDirtyPerPass)
138 }
139
140 rep, err := h.SetReport(feederRow, colA, "", 7)
141 if err != nil {
142 t.Fatalf("blank feeder: %v", err)
143 }
144 if !rep.Capped {
145 t.Fatal("blank unexpectedly recalculated")
146 }
147 var watermark uint64
148 if err := db.Raw("SELECT content_version FROM tabs WHERE id = ?", localTab).Scan(&watermark).Error; err != nil {
149 t.Fatal(err)
150 }
151 if watermark < rep.Version {
152 t.Fatalf("capped blank stamp %d not retained by watermark %d", rep.Version, watermark)
153 }
154 h.Release()
155
156 // The real proof: reopen and read the dependents' actual VALUE, not
157 // just a version stamp. A1 is gone, so the correct answer is now 0
158 // (blank reads as 0 in arithmetic). Before this fix they silently
159 // keep serving 10 forever, because nothing ever marked them dirty
160 // and store.Open's recovery pass has nothing to notice.
161 h2, err := store.Open(db, s, localTab)
162 if err != nil {
163 t.Fatalf("reopen: %v", err)
164 }
165 defer h2.Release()
166
167 for _, rid := range sample {
168 if got := h2.Value(rid, colA); got.Kind != calc.KindNum || got.Num != 0 {
169 t.Fatalf("dependent row %d after blank+reopen = %+v, want KindNum 0 (recovered, A1 gone)", rid, got)
170 }
171 }
172}
173