| 1 | package store |
| 2 | |
| 3 | import ( |
| 4 | "gorm.io/gorm" |
| 5 | |
| 6 | "amadan.net/oficina/sheets/internal/sheets/calc" |
| 7 | ) |
| 8 | |
| 9 | // afterBlankTransactionOneCommits is a test-only seam: called |
| 10 | // immediately after setBlank's first transaction (the delete, the |
| 11 | // dependents' dirty-marker bump, and the input event) commits, before |
| 12 | // anything else runs. It always returns nil in production. A test |
| 13 | // overrides it to return a non-nil error, simulating a process crash |
| 14 | // landing exactly between setBlank's two transactions — see |
| 15 | // TestBlankCrashLeavesDependentsRecoverable. |
| 16 | var afterBlankTransactionOneCommits = func() error { return nil } |
| 17 | |
| 18 | // Set writes a cell's new raw input and recalculates forward from it. |
| 19 | // |
| 20 | // It is SetReport with the report thrown away, and exists at this |
| 21 | // signature because every caller that does not fan out over SSE — XLSX |
| 22 | // import, and most of this package's tests — wants exactly that. The |
| 23 | // crash-safety reasoning that used to live here moved to SetReport |
| 24 | // (handle_report.go), which is now where the work happens. |
| 25 | func (h *Handle) Set(rowID, colID uint64, raw string, actor uint64) error { |
| 26 | _, err := h.SetReport(rowID, colID, raw, actor) |
| 27 | return err |
| 28 | } |
| 29 | |
| 30 | // setBlank deletes a cell's row (cells are sparse — models.go) and |
| 31 | // propagates the change to its dependents. |
| 32 | // |
| 33 | // A crash between "the row is gone" and "dependents have been |
| 34 | // recomputed" is exactly the scenario this whole task exists to close — |
| 35 | // reached here via delete instead of update. The origin row cannot carry |
| 36 | // a dirty marker once it no longer exists, so this durably marks every |
| 37 | // DEPENDENT dirty (bumping its own InputVersion to v, its CacheVersion |
| 38 | // untouched) in the SAME transaction as the delete. If the process never |
| 39 | // reaches the recompute below, each dependent's own InputVersion now |
| 40 | // leads its own CacheVersion, and Open's ordinary per-cell recovery — |
| 41 | // already proven by TestStaleCacheIsRecomputedOnOpen — finds and fixes |
| 42 | // it without needing to know anything about deletes specifically. |
| 43 | // It returns the dependents it actually recomputed, and whether the |
| 44 | // dependent set was too wide to recompute at all — SetReport turns both |
| 45 | // into a report a live client can either paint or resync from. |
| 46 | func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor uint64, prevRaw string) (map[calc.CellKey]struct{}, bool, error) { |
| 47 | // TransitiveDependents reads g.dependents[key], which Clear (called |
| 48 | // below, after this transaction commits) never touches — only key's |
| 49 | // own precedent edges are rewired by Clear — so this is safe to |
| 50 | // compute before the delete. |
| 51 | touched := h.graph.TransitiveDependents(key) |
| 52 | withinCap := len(touched) > 0 && len(touched) <= calc.MaxDirtyPerPass |
| 53 | |
| 54 | if err := h.db.Transaction(func(tx *gorm.DB) error { |
| 55 | if err := h.advanceContentVersion(tx, v); err != nil { |
| 56 | return err |
| 57 | } |
| 58 | if err := tx.Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID). |
| 59 | Delete(&Cell{}).Error; err != nil { |
| 60 | return err |
| 61 | } |
| 62 | // Marking is unconditional on len(touched) > 0 — NOT gated by |
| 63 | // withinCap. MaxDirtyPerPass bounds the RECOMPUTE below (a full |
| 64 | // Eval of every touched cell), not this marker (one version-bump |
| 65 | // UPDATE per touched cell). Past the cap this cell's own row is |
| 66 | // gone, so a durable marker on every dependent is the only |
| 67 | // recovery signal that will ever exist for them; skipping it |
| 68 | // here — as this code once did — left them looking permanently |
| 69 | // consistent (CacheVersion == InputVersion, both untouched) while |
| 70 | // silently reading a cell that no longer exists. See |
| 71 | // TestBlankFeederOverCapDependentsAreRecoverable. |
| 72 | if len(touched) > 0 { |
| 73 | if err := h.markDirty(tx, touched, v); err != nil { |
| 74 | return err |
| 75 | } |
| 76 | } |
| 77 | return AppendInput(tx, h.sealer, InputEvent{ |
| 78 | TabID: h.tabID, RowID: rowID, ColID: colID, ActorID: actor, |
| 79 | OldRaw: prevRaw, NewRaw: "", |
| 80 | }) |
| 81 | }); err != nil { |
| 82 | return nil, false, err |
| 83 | } |
| 84 | h.nextVersion++ |
| 85 | // Test seam: a crash landing exactly here — transaction 1 durably |
| 86 | // committed, nothing after it has run yet — is what |
| 87 | // TestBlankCrashLeavesDependentsRecoverable simulates. Always nil in |
| 88 | // production. |
| 89 | if err := afterBlankTransactionOneCommits(); err != nil { |
| 90 | return nil, false, err |
| 91 | } |
| 92 | delete(h.rawText, key) |
| 93 | delete(h.keyToIDs, key) |
| 94 | h.graph.Clear(key) |
| 95 | |
| 96 | if !withinCap { |
| 97 | // Either no dependents (nothing to do) or too many to recompute |
| 98 | // and persist synchronously — matching recalcAndPersist's own |
| 99 | // refusal past calc.MaxDirtyPerPass, not a separate cap. Every |
| 100 | // dependent was still marked dirty above regardless of this |
| 101 | // cap, so this is not silent: each one's own version gap is |
| 102 | // there for Open's ordinary per-cell recovery to find and fix, |
| 103 | // the same as any other stale cell. |
| 104 | // |
| 105 | // capped is true only in the second of those two cases: no |
| 106 | // dependents at all is nothing to report, while too many is a |
| 107 | // partial truth a live client must not paint. |
| 108 | return nil, len(touched) > 0, nil |
| 109 | } |
| 110 | |
| 111 | // Transaction 2: recompute the real values and persist them, |
| 112 | // clearing the marker transaction 1 just wrote. |
| 113 | h.graph.Recalc(key, newHandleEnv()) |
| 114 | if err := h.persistTouched(touched, v); err != nil { |
| 115 | return nil, false, err |
| 116 | } |
| 117 | return touched, false, nil |
| 118 | } |
| 119 | |
| 120 | // setContent writes a formula or literal cell's new input and |
| 121 | // recalculates forward from it. |
| 122 | // It returns everything the recalculation touched — the edited cell |
| 123 | // included — and whether the pass was too wide to run. |
| 124 | func (h *Handle) setContent(rowID, colID uint64, key calc.CellKey, node calc.Node, raw string, v uint64, actor uint64, prevRaw string) (map[calc.CellKey]struct{}, bool, error) { |
| 125 | sealedInput, err := h.sealer.Seal("cell.input", []byte(raw)) |
| 126 | if err != nil { |
| 127 | return nil, false, err |
| 128 | } |
| 129 | if err := h.db.Transaction(func(tx *gorm.DB) error { |
| 130 | if err := h.advanceContentVersion(tx, v); err != nil { |
| 131 | return err |
| 132 | } |
| 133 | res := tx.Model(&Cell{}). |
| 134 | Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID). |
| 135 | Updates(map[string]any{ |
| 136 | "input_sealed": sealedInput, |
| 137 | "input_version": v, |
| 138 | "updated_by": actor, |
| 139 | }) |
| 140 | if res.Error != nil { |
| 141 | return res.Error |
| 142 | } |
| 143 | if res.RowsAffected == 0 { |
| 144 | if err := tx.Create(&Cell{ |
| 145 | TabID: h.tabID, RowID: rowID, ColID: colID, |
| 146 | InputSealed: sealedInput, InputVersion: v, |
| 147 | UpdatedBy: actor, |
| 148 | }).Error; err != nil { |
| 149 | return err |
| 150 | } |
| 151 | } |
| 152 | return AppendInput(tx, h.sealer, InputEvent{ |
| 153 | TabID: h.tabID, RowID: rowID, ColID: colID, ActorID: actor, |
| 154 | OldRaw: prevRaw, NewRaw: raw, |
| 155 | }) |
| 156 | }); err != nil { |
| 157 | return nil, false, err |
| 158 | } |
| 159 | h.nextVersion++ |
| 160 | h.rawText[key] = raw |
| 161 | h.graph.SetFormula(key, node) |
| 162 | h.keyToIDs[key] = cellIDs{RowID: rowID, ColID: colID} |
| 163 | |
| 164 | touched, ok := h.recalcTouched(key, true) |
| 165 | if !ok { |
| 166 | // recalcTouched refuses only past calc.MaxDirtyPerPass here: |
| 167 | // includeOrigin puts this cell in the set, so the set is never |
| 168 | // empty on this path. |
| 169 | return nil, true, nil |
| 170 | } |
| 171 | if err := h.persistTouched(touched, v); err != nil { |
| 172 | return nil, false, err |
| 173 | } |
| 174 | return touched, false, nil |
| 175 | } |
| 176 | |
| 177 | // markDirty is defined in handle_batch.go, alongside the crash-recovery |
| 178 | // batching it shares its "bounded number of statements, not one per |
| 179 | // touched cell" shape with. |
| 180 | |
| 181 | // recalcTouched computes origin's touched set — TransitiveDependents, |
| 182 | // plus origin itself if includeOrigin — and, if it is within |
| 183 | // calc.MaxDirtyPerPass, runs Graph.Recalc so every touched cell's |
| 184 | // in-memory cache is current, returning that set with ok=true. Past the |
| 185 | // cap (or with nothing touched), it does nothing and returns ok=false — |
| 186 | // the touched set is computed BEFORE Recalc is called specifically so |
| 187 | // this can be decided without running Recalc at all, matching (not |
| 188 | // duplicating) Recalc's own cap. See calc.MaxDirtyPerPass's comment for |
| 189 | // why an over-wide pass is refused rather than run. |
| 190 | // |
| 191 | // Split out from the recompute-and-persist pair so a caller that wants |
| 192 | // to recompute several origins before persisting any of them |
| 193 | // (recoverStale, handle_batch.go) can do the cheap in-memory part here |
| 194 | // and defer the expensive part — a transaction commit — to its own |
| 195 | // batching. setContent and setBlank call it and then persistTouched |
| 196 | // directly, one commit per Set. |
| 197 | func (h *Handle) recalcTouched(origin calc.CellKey, includeOrigin bool) (map[calc.CellKey]struct{}, bool) { |
| 198 | touched := h.graph.TransitiveDependents(origin) |
| 199 | if includeOrigin { |
| 200 | touched[origin] = struct{}{} |
| 201 | } |
| 202 | if len(touched) == 0 || len(touched) > calc.MaxDirtyPerPass { |
| 203 | return nil, false |
| 204 | } |
| 205 | h.graph.Recalc(origin, newHandleEnv()) |
| 206 | return touched, true |
| 207 | } |
| 208 | |
| 209 | // persistTouched writes each cell in touched's current graph-cached |
| 210 | // value — sealed as CachedSealed, stamped CacheVersion=v — in one |
| 211 | // transaction, plus a recalc marker recording how wide the pass was. |
| 212 | // Callers are expected to have already run Graph.Recalc (or otherwise |
| 213 | // ensured every touched key has a current cached value) before calling |
| 214 | // this; a key with nothing cached persists as KindEmpty rather than |
| 215 | // erroring, matching decodeValue's own empty-input convention. |
| 216 | // |
| 217 | // triggerID 0 in the AppendRecalc call below: AppendInput does not hand |
| 218 | // back the id of the event row it just created, and correlating one |
| 219 | // would cost a second query for bookkeeping only — the marker's real |
| 220 | // job, the derived-count, is unaffected. See events.go. |
| 221 | // |
| 222 | // Marker granularity is caller-defined, not fixed by this function: |
| 223 | // setContent/recalcAndPersist call this once per Set, so one marker |
| 224 | // covers one origin's touched set. recoverStale (handle_batch.go) calls |
| 225 | // this once per BATCH of up to openRecoveryBatchSize origins during |
| 226 | // Open's crash recovery, so during batched recovery one marker's |
| 227 | // DerivedCount covers however many cells that whole batch touched, not |
| 228 | // one origin's — marker cardinality no longer matches origin |
| 229 | // cardinality there. This is safe specifically because triggerID is |
| 230 | // already 0 unconditionally, on every call site, not just the batched |
| 231 | // one: it has never claimed to name the one event that caused a given |
| 232 | // marker's cells to be touched, so a marker covering several origins' |
| 233 | // worth of recomputation asserts nothing false about which trigger |
| 234 | // caused it — there was never a single-trigger claim to violate. |
| 235 | func (h *Handle) persistTouched(touched map[calc.CellKey]struct{}, v uint64) error { |
| 236 | return h.db.Transaction(func(tx *gorm.DB) error { |
| 237 | if err := h.advanceContentVersion(tx, v); err != nil { |
| 238 | return err |
| 239 | } |
| 240 | for key := range touched { |
| 241 | ids, ok := h.keyToIDs[key] |
| 242 | if !ok { |
| 243 | continue // defensive: every touched key was registered via SetFormula |
| 244 | } |
| 245 | val, _ := h.graph.CachedValue(key) |
| 246 | sealedCache, err := h.sealer.Seal("cell.cached", encodeValue(val)) |
| 247 | if err != nil { |
| 248 | return err |
| 249 | } |
| 250 | if err := tx.Model(&Cell{}). |
| 251 | Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, ids.RowID, ids.ColID). |
| 252 | Updates(map[string]any{ |
| 253 | "cached_sealed": sealedCache, |
| 254 | "cache_version": v, |
| 255 | }).Error; err != nil { |
| 256 | return err |
| 257 | } |
| 258 | } |
| 259 | return AppendRecalc(tx, 0, len(touched)) |
| 260 | }) |
| 261 | } |
| 262 | |