| 1 | package store_test |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | var 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. |
| 34 | func 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 | |