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 "fmt"
5
6 "gorm.io/gorm"
7
8 "amadan.net/oficina/sheets/internal/sheets/calc"
9 "amadan.net/oficina/sheets/internal/sheets/seal"
10)
11
12// The binder, and a deliberate choice about it (task 10 carry-forward
13// item 2): calc.CellKey is generic — {RowID, ColID uint64} — but every
14// reference calc itself resolves (refKey, rangeCells, and the lookup
15// functions built directly on RowPos/ColPos) computes it as position+1,
16// unconditionally, deep inside Parse/Eval, not just at the one placeholder
17// site Task 7 flagged. Migrating that to real Row.ID/Col.ID would mean
18// rewriting calc's own evaluator, not just wiring a translation layer in
19// front of it — out of reach for a task scoped to the store package, and
20// a much larger, riskier change than "bind ids" suggests on its own.
21//
22// So this handle PRESERVES the position+1 convention rather than
23// migrating it: cellKey (handle.go) maps a cell's real, stable
24// (row_id, col_id) — the Cell table's actual primary key, and what Set's
25// and Format's callers pass in — to the position-derived CellKey the
26// graph and every parsed formula already use internally. rowPos/colPos
27// below are that binding table, built fresh from the tab's Row/Col
28// records every time a handle is built.
29//
30// The consequence to carry forward: because the graph's key space is
31// POSITION, not identity, it is only valid for as long as positions do
32// not move. Nothing in this task inserts, deletes or reorders rows or
33// columns — Set only ever edits the content of a row/col pair that
34// already exists — so the graph never goes stale against reality within
35// this task's scope. But calc's evaluator has no notion of "the row that
36// used to be at position 4" once something IS inserted at position 2 and
37// old position 4 becomes 5: refKey and rangeCells will silently compute
38// the WRONG CellKey for that formula after such an edit. Whichever task
39// next adds row/col insert, delete or reorder must not patch around this;
40// it must either (a) rebuild this handle's graph from scratch after every
41// structural edit (discard and re-run buildHandle), which is correct but
42// means a structural edit is as expensive as a cold open, or (b) migrate
43// calc's evaluator to real ids, which is the more invasive but more
44// durable fix. This handle does neither because it needs neither yet.
45func buildHandle(db *gorm.DB, sealer *seal.Sealer, tabID uint64) (*Handle, error) {
46 var tab Tab
47 if err := db.Select("id", "content_version").First(&tab, tabID).Error; err != nil {
48 return nil, fmt.Errorf("store: load tab version: %w", err)
49 }
50 var rows []Row
51 if err := db.Where("tab_id = ?", tabID).Find(&rows).Error; err != nil {
52 return nil, fmt.Errorf("store: load rows: %w", err)
53 }
54 var cols []Col
55 if err := db.Where("tab_id = ?", tabID).Find(&cols).Error; err != nil {
56 return nil, fmt.Errorf("store: load cols: %w", err)
57 }
58 var cells []Cell
59 if err := db.Where("tab_id = ?", tabID).Find(&cells).Error; err != nil {
60 return nil, fmt.Errorf("store: load cells: %w", err)
61 }
62
63 h := &Handle{
64 db: db,
65 sealer: sealer,
66 tabID: tabID,
67 graph: calc.NewGraph(),
68 rowPos: make(map[uint64]int, len(rows)),
69 colPos: make(map[uint64]int, len(cols)),
70 rowByPos: make(map[int]uint64, len(rows)),
71 colByPos: make(map[int]uint64, len(cols)),
72 rawText: make(map[calc.CellKey]string, len(cells)),
73 keyToIDs: make(map[calc.CellKey]cellIDs, len(cells)),
74 }
75 for _, r := range rows {
76 h.rowPos[r.ID] = r.Position
77 h.rowByPos[r.Position] = r.ID
78 }
79 for _, c := range cols {
80 h.colPos[c.ID] = c.Position
81 h.colByPos[c.Position] = c.ID
82 }
83
84 var stale []calc.CellKey
85 maxVersion := tab.ContentVersion
86
87 for _, c := range cells {
88 key, ok := h.cellKey(c.RowID, c.ColID)
89 if !ok {
90 continue // orphaned: its row or col record is missing from this tab
91 }
92 plain, err := sealer.Open("cell.input", c.InputSealed)
93 if err != nil {
94 return nil, fmt.Errorf("store: decrypt input at row %d col %d: %w", c.RowID, c.ColID, err)
95 }
96 raw := string(plain)
97 node, blank, perr := contentNode(raw)
98 if perr != nil {
99 return nil, fmt.Errorf("store: parse stored formula at row %d col %d: %w", c.RowID, c.ColID, perr)
100 }
101 h.rawText[key] = raw
102 if blank {
103 continue // sparse cells should never be blank, but tolerate it rather than crash
104 }
105 h.graph.SetFormula(key, node)
106 h.keyToIDs[key] = cellIDs{RowID: c.RowID, ColID: c.ColID}
107
108 // Restore the pre-open cache so a clean cell need not be recomputed
109 // merely because the process restarted. A decrypt or decode
110 // failure here is treated as "not cached" rather than aborting
111 // Open outright: the cache is a derived optimization, always
112 // reconstructable from the input, and is itself flagged stale
113 // below so it gets a real recompute rather than serving nothing.
114 cacheOK := false
115 if cachedPlain, err := sealer.Open("cell.cached", c.CachedSealed); err == nil {
116 if v, err := decodeValue(cachedPlain); err == nil {
117 h.graph.SeedCache(key, v)
118 cacheOK = true
119 }
120 }
121
122 if c.InputVersion > maxVersion {
123 maxVersion = c.InputVersion
124 }
125 if c.CacheVersion > maxVersion {
126 maxVersion = c.CacheVersion
127 }
128 if !cacheOK || c.CacheVersion < c.InputVersion {
129 stale = append(stale, key)
130 }
131 }
132 if maxVersion >= maxContentVersion-1 {
133 return nil, errVersionExhausted
134 }
135 h.nextVersion = maxVersion + 1
136
137 // Crash recovery: any cell whose CacheVersion trails its InputVersion
138 // (or whose cache failed to restore above) is recomputed — origin and
139 // every transitive dependent — and persisted BEFORE this function
140 // returns, so no caller of Value ever observes what a crash left
141 // behind.
142 //
143 // Every pass in this loop shares ONE version stamp: h.nextVersion, the
144 // same value the very next Set on this handle would otherwise hand
145 // out. That is deliberate, not merely simpler than stamping each pass
146 // with the origin cell's own InputVersion: this loop can touch a cell
147 // as a DEPENDENT of more than one stale origin, or as a dependent of
148 // one stale origin despite being perfectly consistent on its own
149 // (CacheVersion already equal to its own InputVersion). Stamping such
150 // a cell with an origin's InputVersion — which can be OLDER than the
151 // dependent's own InputVersion — would silently regress its
152 // CacheVersion below a value it already legitimately holds, manufacturing
153 // a fresh (if self-correcting) false positive on the very next Open.
154 // A single stamp taken from h.nextVersion is, by construction, greater
155 // than every InputVersion loaded above, so it can never regress
156 // anything it touches.
157 //
158 // Only when something is stale. Taken unconditionally, every Open
159 // consumed a stamp nothing was written under, and because a handle
160 // is evicted at refcount zero (Release), sequential requests each
161 // re-open it: consecutive writes then jumped by two, and a client
162 // could not tell that from a write it missed (discussion #35).
163 var recoveryVersion uint64
164 if len(stale) > 0 {
165 recoveryVersion = h.nextVersion
166 h.nextVersion++
167 }
168 // recoverStale (handle_batch.go) is what used to be a plain loop
169 // recomputing and persisting once per stale key — one transaction (one
170 // WAL fsync) per key. That is fine at the 5,001-cell scale the
171 // original correctness fix was measured at, but stale is bounded by
172 // MaxCellsPerTab (200,000), not calc.MaxDirtyPerPass, so the real
173 // worst case was tens of minutes of Open blocking on commits alone.
174 // recoverStale groups many origins' recomputation into bounded
175 // batches of commits instead — see its comment and
176 // openRecoveryBatchSize for what stays true across the change.
177 if err := h.recoverStale(stale, recoveryVersion); err != nil {
178 return nil, fmt.Errorf("store: crash recovery: %w", err)
179 }
180
181 return h, nil
182}
183