oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package store
2
3import (
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.
16var 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.
25func (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.
46func (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.
124func (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.
197func (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.
235func (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