| 1 | package store |
| 2 | |
| 3 | import ( |
| 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. |
| 45 | func 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 | |