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 "errors"
6 "path/filepath"
7 "testing"
8
9 rdb "amadan.net/rastrillo/rastrillo/db"
10 "amadan.net/rastrillo/rastrillo/migrate"
11
12 "amadan.net/oficina/sheets/internal/sheets"
13 "amadan.net/oficina/sheets/internal/sheets/calc"
14 "amadan.net/oficina/sheets/internal/sheets/seal"
15 "amadan.net/oficina/sheets/internal/sheets/store"
16)
17
18// crashSentinel is what afterBlankTransactionOneCommits returns while
19// this test is simulating an interrupted setBlank.
20var crashSentinel = errors.New("store: simulated crash for test")
21
22// TestBlankCrashLeavesDependentsRecoverable proves that a process crash
23// landing between setBlank's two transactions — the delete commits, the
24// recompute that would give a dependent its real new value never runs —
25// leaves that dependent recoverable rather than silently wrong forever.
26//
27// This lives in package store (not store_test) because it needs
28// afterBlankTransactionOneCommits, the test-only seam that lets it
29// interrupt REAL Set/setBlank code exactly between the two transactions
30// — the only way to prove this fix's actual code path writes the
31// dependents' dirty marker atomically with the delete, as opposed to a
32// hand-fabricated DB state that store.Open's pre-existing per-cell recovery
33// would already know how to fix regardless of whether Set ever wrote it.
34func TestBlankCrashLeavesDependentsRecoverable(t *testing.T) {
35 dir := t.TempDir()
36 d, err := rdb.Open(filepath.Join(dir, "test.db"), nil)
37 if err != nil {
38 t.Fatalf("open db: %v", err)
39 }
40 t.Cleanup(func() {
41 if err := d.Close(); err != nil {
42 t.Errorf("close db: %v", err)
43 }
44 })
45 db := d.G
46 if _, err := migrate.Apply(context.Background(), d, sheets.BootSchema); err != nil {
47 t.Fatalf("migrate: %v", err)
48 }
49 s, err := seal.New(make([]byte, 32))
50 if err != nil {
51 t.Fatal(err)
52 }
53
54 const localTab, rowA, rowB, colA uint64 = 1, 1, 2, 1
55 if err := db.Create(&store.Tab{ID: localTab, SheetID: 1}).Error; err != nil {
56 t.Fatalf("seed tab: %v", err)
57 }
58 if err := db.Create([]*store.Row{
59 {ID: rowA, TabID: localTab, Position: 0},
60 {ID: rowB, TabID: localTab, Position: 1},
61 }).Error; err != nil {
62 t.Fatalf("seed rows: %v", err)
63 }
64 if err := db.Create(&store.Col{ID: colA, TabID: localTab, Position: 0}).Error; err != nil {
65 t.Fatalf("seed col: %v", err)
66 }
67
68 h, err := store.Open(db, s, localTab)
69 if err != nil {
70 t.Fatalf("store.Open: %v", err)
71 }
72 if err := h.Set(rowA, colA, "5", 7); err != nil {
73 t.Fatalf("Set A1: %v", err)
74 }
75 if err := h.Set(rowB, colA, "=A1*2", 7); err != nil {
76 t.Fatalf("Set B1: %v", err)
77 }
78 h.Release()
79
80 h, err = store.Open(db, s, localTab)
81 if err != nil {
82 t.Fatalf("reopen: %v", err)
83 }
84 if got := h.Value(rowB, colA); got.Kind != calc.KindNum || got.Num != 10 {
85 t.Fatalf("B1 before blanking = %+v, want 10", got)
86 }
87
88 // Interrupt setBlank exactly between its two transactions.
89 restore := store.InterruptBlankAfterFirstTransaction(
90 func() error { return crashSentinel })
91 err = h.Set(rowA, colA, "", 7)
92 restore()
93 if !errors.Is(err, crashSentinel) {
94 t.Fatalf("Set A1 blank (interrupted): got %v, want the simulated crash sentinel", err)
95 }
96 h.Release()
97
98 // Transaction 1 must have really committed: A1's row is gone.
99 var count int64
100 db.Model(&store.Cell{}).Where("row_id = ? AND col_id = ?", rowA, colA).Count(&count)
101 if count != 0 {
102 t.Fatal("A1's row survived the interrupted blank; transaction 1 should have deleted it")
103 }
104
105 // B1 must have been left with a durable dirty marker: its own
106 // InputVersion leading its own CacheVersion, written atomically with
107 // the delete in transaction 1, per this fix.
108 var b store.Cell
109 db.Where("row_id = ? AND col_id = ?", rowB, colA).First(&b)
110 if b.CacheVersion >= b.InputVersion {
111 t.Fatalf("B1 was not left with a durable dirty marker: CacheVersion %d, InputVersion %d (want CacheVersion < InputVersion)", b.CacheVersion, b.InputVersion)
112 }
113
114 // The tab must be recoverable on the very next store.Open, exactly as
115 // TestStaleCacheIsRecomputedOnOpen requires for any dirty marker.
116 h2, err := store.Open(db, s, localTab)
117 if err != nil {
118 t.Fatalf("store.Open after interrupted blank: %v", err)
119 }
120 defer h2.Release()
121
122 if got := h2.Value(rowB, colA); got.Kind != calc.KindNum || got.Num != 0 {
123 t.Fatalf("B1 after recovery = %+v, want KindNum 0 (A1 is gone; blank reads as 0 in arithmetic)", got)
124 }
125 var b2 store.Cell
126 db.Where("row_id = ? AND col_id = ?", rowB, colA).First(&b2)
127 if b2.CacheVersion < b2.InputVersion {
128 t.Fatalf("B1 still stale after store.Open's recovery: CacheVersion %d < InputVersion %d", b2.CacheVersion, b2.InputVersion)
129 }
130}
131