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 "strings"
5
6 "gorm.io/gorm"
7
8 "amadan.net/oficina/sheets/internal/sheets/calc"
9)
10
11// markDirtyBatchSize bounds how many (row_id, col_id) pairs go into one
12// `UPDATE cells SET input_version = ? WHERE tab_id = ? AND (row_id,
13// col_id) IN (...)` statement inside markDirty.
14//
15// Before this, markDirty ran one UPDATE per touched cell — under h.mu,
16// the same mutex Value and every other Set take — and touched is bounded
17// by MaxCellsPerTab (200,000), not calc.MaxDirtyPerPass (5,000; that cap
18// only bounds the RECOMPUTE below it, see setBlank's own comment). One
19// statement per cell was measured at ~103ms for 5,001 cells, which
20// extrapolates to multiple seconds of exclusive-lock hold at the real
21// 200,000-cell ceiling — see batching-report.md.
22//
23// 1,000 pairs costs 1,000*2 = 2,000 bound parameters plus the 2 fixed
24// ones (v, tab_id): comfortably under SQLite's default
25// SQLITE_MAX_VARIABLE_NUMBER (32,766 on the bundled modernc.org/sqlite
26// build), while cutting a 200,000-cell blank's marking from 200,000
27// statement executions to 200.
28const markDirtyBatchSize = 1000
29
30// markDirty bumps every cell in touched's own InputVersion to v, in
31// batches of at most markDirtyBatchSize, leaving CacheVersion untouched —
32// the same "input advances without yet touching cache" shape setContent
33// uses on the one cell it directly edits, applied here to every cell
34// whose precedent is about to disappear rather than to the cell whose
35// content actually changed.
36//
37// Must run inside the SAME transaction as setBlank's row delete — that
38// atomicity is what makes an interrupted blank's dependents recoverable
39// at all (see setBlank's doc comment and
40// TestBlankCrashLeavesDependentsRecoverable). Batching the statements
41// does not change that: every batch still executes against tx, the
42// caller's transaction, never one of its own.
43func (h *Handle) markDirty(tx *gorm.DB, touched map[calc.CellKey]struct{}, v uint64) error {
44 ids := make([]cellIDs, 0, len(touched))
45 for key := range touched {
46 cid, ok := h.keyToIDs[key]
47 if !ok {
48 continue // defensive: every touched key was registered via SetFormula
49 }
50 ids = append(ids, cid)
51 }
52 for start := 0; start < len(ids); start += markDirtyBatchSize {
53 end := start + markDirtyBatchSize
54 if end > len(ids) {
55 end = len(ids)
56 }
57 if err := markDirtyBatch(tx, h.tabID, ids[start:end], v); err != nil {
58 return err
59 }
60 }
61 return nil
62}
63
64// markDirtyBatch runs the single batched UPDATE for one chunk of ids.
65// Raw SQL rather than GORM's query builder because GORM has no row-value
66// `IN ((?,?),(?,?),...)` construct — the same reason
67// handle_test.go's seedOversizedTab already reaches for tx.Exec with
68// hand-built placeholders.
69func markDirtyBatch(tx *gorm.DB, tabID uint64, batch []cellIDs, v uint64) error {
70 var sql strings.Builder
71 sql.WriteString("UPDATE cells SET input_version = ? WHERE tab_id = ? AND (row_id, col_id) IN (")
72 args := make([]any, 0, 2+2*len(batch))
73 args = append(args, v, tabID)
74 for i, cid := range batch {
75 if i > 0 {
76 sql.WriteByte(',')
77 }
78 sql.WriteString("(?,?)")
79 args = append(args, cid.RowID, cid.ColID)
80 }
81 sql.WriteByte(')')
82 return tx.Exec(sql.String(), args...).Error
83}
84
85// openRecoveryBatchSize bounds how many stale origins' recomputation
86// results are combined into one commit during Open's crash-recovery loop
87// (recoverStale, called from handle_open.go).
88//
89// Graph.Recalc is pure in-memory work — cheap no matter how many origins
90// run before a commit — so the only cost this batches is COMMITS: each
91// is a gorm.DB.Transaction, and on this project's WAL-mode SQLite each
92// commit costs at least one fsync. Recovering 5,001 independently-stale
93// cells one transaction per cell measured ~25s, almost entirely that
94// fsync cost, extrapolating to tens of minutes at the 200,000-cell
95// MaxCellsPerTab ceiling — see batching-report.md. Grouping origins into
96// batches of openRecoveryBatchSize turns len(stale) commits into roughly
97// len(stale)/openRecoveryBatchSize.
98//
99// 500 is not tied to a SQLite limit the way markDirtyBatchSize is — it
100// keeps one transaction's worth of UPDATEs (up to openRecoveryBatchSize
101// origins, each able to contribute up to calc.MaxDirtyPerPass cells via
102// persistTouched) from growing unboundedly large for a single commit,
103// while still cutting commit count by roughly 500x at MaxCellsPerTab
104// scale.
105const openRecoveryBatchSize = 500
106
107// recoverStale runs Open's crash-recovery pass over every stale origin,
108// persisting in batches of at most openRecoveryBatchSize origins per
109// transaction instead of committing once per origin.
110//
111// Recomputation (recalcTouched, handle_set.go) is in-memory only and
112// happens for every origin in a batch before any of that batch is
113// persisted, so a crash mid-batch simply leaves the not-yet-persisted
114// origins in this batch — and every batch after it — exactly as stale as
115// they were before this call, for the next Open to retry. That is the
116// same recoverable state a crash landing between two origins already
117// left under the old one-transaction-per-origin code; batching changes
118// how many origins one commit covers, not what an interrupted commit
119// leaves behind.
120//
121// Each origin still goes through recalcTouched's own
122// calc.MaxDirtyPerPass gate individually — an origin whose own
123// TransitiveDependents exceeds the cap is skipped exactly as
124// the ordinary Set path has always skipped it, left stale for a future Open
125// (or an ordinary Set that touches it directly) to retry. Batching only
126// changes when a successfully-recomputed origin's result is committed,
127// never whether it is recomputed at all.
128//
129// Each flush's persistTouched call also writes one recalc marker
130// (events.go) for the whole batch — up to openRecoveryBatchSize origins
131// — rather than one marker per origin the way setContent's single-origin
132// setContent does. See persistTouched's doc comment for why that
133// granularity change is honest rather than misleading: its triggerID is
134// unconditionally 0 on every call site, so a batch's marker was never
135// claiming to name the one origin that caused it.
136func (h *Handle) recoverStale(stale []calc.CellKey, v uint64) error {
137 combined := make(map[calc.CellKey]struct{})
138 for i, origin := range stale {
139 if touched, ok := h.recalcTouched(origin, true); ok {
140 for k := range touched {
141 combined[k] = struct{}{}
142 }
143 }
144 last := i == len(stale)-1
145 if len(combined) == 0 {
146 continue
147 }
148 if (i+1)%openRecoveryBatchSize == 0 || last {
149 if err := h.persistTouched(combined, v); err != nil {
150 return err
151 }
152 combined = make(map[calc.CellKey]struct{})
153 }
154 }
155 return nil
156}
157