store: a write reports what it changed, with the version to order it by
Live sync needs the touched set, and Set returned only an error. Set keeps its signature — XLSX import and most of this package's tests want exactly that — and becomes a wrapper over SetReport, which is now where the work happens. The report is built after both transactions commit, from the graph's now-current cache. Built earlier it would tell a live client about an edit a failed transaction then rolled back, and a client cannot un-paint. Two cases the report has to get right or the other tab is visibly wrong: the cleared cell is added back by hand, because setBlank's touched set is its dependents only and the origin's row is gone; and a capped write reports an empty origin rather than the stale cached number, because a stale value painted as fresh is worse than a blank the client immediately replaces. recalcAndPersist is gone — setContent was its only caller and now inlines the pair.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
5 files changed,
+372
−74
internal/sheets/store/handle_batch.go+2 −2internal/sheets/store/handle_open.go+1 −1internal/sheets/store/handle_report.go+164 −0internal/sheets/store/handle_report_test.go+163 −0internal/sheets/store/handle_set.go+42 −71
diff --git a/internal/sheets/store/handle_batch.go b/internal/sheets/store/handle_batch.go| index 2384759..22a0ca4 100644 |
| --- a/internal/sheets/store/handle_batch.go |
| +++ b/internal/sheets/store/handle_batch.go |
| @@ -121,7 +121,7 @@ const openRecoveryBatchSize = 500 |
| // Each origin still goes through recalcTouched's own |
| // calc.MaxDirtyPerPass gate individually — an origin whose own |
| // TransitiveDependents exceeds the cap is skipped exactly as |
| -// recalcAndPersist has always skipped it, left stale for a future Open |
| +// the ordinary Set path has always skipped it, left stale for a future Open |
| // (or an ordinary Set that touches it directly) to retry. Batching only |
| // changes when a successfully-recomputed origin's result is committed, |
| // never whether it is recomputed at all. |
| @@ -129,7 +129,7 @@ const openRecoveryBatchSize = 500 |
| // Each flush's persistTouched call also writes one recalc marker |
| // (events.go) for the whole batch — up to openRecoveryBatchSize origins |
| // — rather than one marker per origin the way setContent's single-origin |
| -// recalcAndPersist does. See persistTouched's doc comment for why that |
| +// setContent does. See persistTouched's doc comment for why that |
| // granularity change is honest rather than misleading: its triggerID is |
| // unconditionally 0 on every call site, so a batch's marker was never |
| // claiming to name the one origin that caused it. |
diff --git a/internal/sheets/store/handle_open.go b/internal/sheets/store/handle_open.go| index 44bdf7c..11d12d6 100644 |
| --- a/internal/sheets/store/handle_open.go |
| +++ b/internal/sheets/store/handle_open.go |
| @@ -151,7 +151,7 @@ func buildHandle(db *gorm.DB, sealer *seal.Sealer, tabID uint64) (*Handle, error |
| recoveryVersion := h.nextVersion |
| h.nextVersion++ |
| // recoverStale (handle_batch.go) is what used to be a plain loop |
| - // calling recalcAndPersist once per stale key — one transaction (one |
| + // recomputing and persisting once per stale key — one transaction (one |
| // WAL fsync) per key. That is fine at the 5,001-cell scale the |
| // original correctness fix was measured at, but stale is bounded by |
| // MaxCellsPerTab (200,000), not calc.MaxDirtyPerPass, so the real |
diff --git a/internal/sheets/store/handle_report.go b/internal/sheets/store/handle_report.go| new file mode 100644 |
| index 0000000..2b71f5f |
| --- /dev/null |
| +++ b/internal/sheets/store/handle_report.go |
| @@ -0,0 +1,164 @@ |
| +package store |
| + |
| +import ( |
| + "fmt" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/calc" |
| +) |
| + |
| +// ChangedCell is one cell a write altered. |
| +// |
| +// Raw is the cell's input text: what an editor should show when you |
| +// select it. Value is what it computes to: what a grid should paint. |
| +// Keeping both is the whole point — a client that only had Value would |
| +// replace "=A1*2" with "42" in the editor the moment somebody else's |
| +// edit arrived. |
| +// |
| +// Derived distinguishes the cell that was typed into from the cells that |
| +// changed because of it. Spec §7: derived cells carry the actor of the |
| +// edit that caused them, not the owner of the formula, and a derived |
| +// flag — so version history can say "your edit changed 400 cells" rather |
| +// than crediting a formula's output to whoever typed a number into a |
| +// different cell. |
| +type ChangedCell struct { |
| + RowID, ColID uint64 |
| + Raw string |
| + Value calc.Value |
| + Derived bool |
| +} |
| + |
| +// Report is what one write changed. |
| +// |
| +// Version is the single number a client orders paints by (spec §7: |
| +// every message carries the cell's version, and clients discard |
| +// out-of-order paints by version rather than by arrival). Every cell in |
| +// one report shares it, because they were all written by one Set and one |
| +// recalculation pass — a per-cell version would be the same number |
| +// repeated once per cell. |
| +// |
| +// Capped is true when the edit's dependent set was wider than |
| +// calc.MaxDirtyPerPass, so no recalculation ran and Cells covers the |
| +// edited cell alone. Every dependent is still durably marked dirty and |
| +// will be recomputed by the next Open — nothing is lost — but a live |
| +// client must not paint the partial truth. It resyncs instead. This is |
| +// the honest error the cap exists to produce, surfaced rather than |
| +// swallowed. |
| +type Report struct { |
| + Version uint64 |
| + Cells []ChangedCell |
| + Capped bool |
| +} |
| + |
| +// SetReport writes a cell's new raw input, recalculates forward from it, |
| +// and reports every cell that changed. |
| +// |
| +// The write path is unchanged from what Set has always done, and the |
| +// crash-safety reasoning is the load-bearing part of it: |
| +// |
| +// For an edit to a surviving cell, the input write — plus the version |
| +// bump that IS the dirty marker (models.go: CacheVersion records the |
| +// InputVersion its cache was derived from, so bumping InputVersion |
| +// without yet touching CacheVersion is exactly what makes a cell look |
| +// dirty) — lands in one transaction. Recalculation, and persisting what |
| +// it computes, happens afterward in a second. A crash between the two |
| +// leaves precisely the state Open's crash-recovery pass exists to detect |
| +// and fix: no separate marker column is needed because the version gap |
| +// IS the marker, and clearing it is writing a CacheVersion that has |
| +// caught back up. |
| +// |
| +// Blanking a cell cannot use that mechanism on the cell itself — its row |
| +// is deleted, so there is no surviving row to carry a marker — so it |
| +// marks its DEPENDENTS dirty instead, atomically with the delete. See |
| +// setBlank and TestBlankCrashLeavesDependentsRecoverable. |
| +// |
| +// What is new is the report, and it is built AFTER both transactions |
| +// commit, from the graph's now-current cache. That ordering matters: a |
| +// report assembled before the commit would tell a live client about an |
| +// edit that a failed transaction then rolled back, and the client has no |
| +// way to un-paint it. |
| +func (h *Handle) SetReport(rowID, colID uint64, raw string, actor uint64) (Report, error) { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + |
| + key, ok := h.cellKey(rowID, colID) |
| + if !ok { |
| + return Report{}, fmt.Errorf("store: row %d col %d is not part of tab %d", |
| + rowID, colID, h.tabID) |
| + } |
| + |
| + node, blank, err := contentNode(raw) |
| + if err != nil { |
| + return Report{}, err |
| + } |
| + |
| + v := h.nextVersion |
| + prevRaw := h.rawText[key] |
| + |
| + var ( |
| + touched map[calc.CellKey]struct{} |
| + capped bool |
| + ) |
| + if blank { |
| + touched, capped, err = h.setBlank(rowID, colID, key, v, actor, prevRaw) |
| + } else { |
| + touched, capped, err = h.setContent(rowID, colID, key, node, raw, v, actor, prevRaw) |
| + } |
| + if err != nil { |
| + return Report{}, err |
| + } |
| + return h.report(key, rowID, colID, v, touched, capped), nil |
| +} |
| + |
| +// report turns a recomputed touched set into the cells a client paints. |
| +// |
| +// The edited cell is always present, whatever the touched set holds. |
| +// setBlank's touched set is its DEPENDENTS only — the origin's row is |
| +// gone, so it is not in the graph to be recomputed — and without this |
| +// the other tab would never clear the cell you just emptied, which is |
| +// the most visible bug this whole layer could have. |
| +// |
| +// Caller must hold h.mu. |
| +func (h *Handle) report(origin calc.CellKey, rowID, colID, v uint64, |
| + touched map[calc.CellKey]struct{}, capped bool) Report { |
| + |
| + rep := Report{Version: v, Capped: capped, Cells: make([]ChangedCell, 0, len(touched)+1)} |
| + |
| + rep.Cells = append(rep.Cells, ChangedCell{ |
| + RowID: rowID, ColID: colID, |
| + Raw: h.rawText[origin], // absent from the map once blanked, which yields "" |
| + Value: h.originValue(origin, capped), |
| + }) |
| + |
| + for k := range touched { |
| + if k == origin { |
| + continue // already appended, and never as derived |
| + } |
| + ids, ok := h.keyToIDs[k] |
| + if !ok { |
| + continue // defensive: every touched key was registered via SetFormula |
| + } |
| + val, _ := h.graph.CachedValue(k) |
| + rep.Cells = append(rep.Cells, ChangedCell{ |
| + RowID: ids.RowID, ColID: ids.ColID, |
| + Raw: h.rawText[k], |
| + Value: val, |
| + Derived: true, |
| + }) |
| + } |
| + return rep |
| +} |
| + |
| +// originValue reads the edited cell's current value. |
| +// |
| +// A capped write never ran Recalc, so the graph's cache for the origin |
| +// is whatever it held before — stale, and wrong to send. Report an empty |
| +// value instead and let Capped tell the client to resync; a stale number |
| +// painted as fresh is worse than a blank the client immediately |
| +// replaces. |
| +func (h *Handle) originValue(origin calc.CellKey, capped bool) calc.Value { |
| + if capped { |
| + return calc.Value{} |
| + } |
| + v, _ := h.graph.CachedValue(origin) |
| + return v |
| +} |
diff --git a/internal/sheets/store/handle_report_test.go b/internal/sheets/store/handle_report_test.go| new file mode 100644 |
| index 0000000..3fcf99d |
| --- /dev/null |
| +++ b/internal/sheets/store/handle_report_test.go |
| @@ -0,0 +1,163 @@ |
| +package store_test |
| + |
| +import ( |
| + "testing" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/calc" |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// openSeeded is seeded() plus an Open, which is what every report test |
| +// wants and none of them vary. |
| +func openSeeded(t *testing.T) *store.Handle { |
| + t.Helper() |
| + db, sealer, id := seeded(t) |
| + h, err := store.Open(db, sealer, id) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + t.Cleanup(h.Release) |
| + return h |
| +} |
| + |
| +// findCell locates one coordinate in a report, so an assertion does not |
| +// depend on the order a map ranged in. |
| +func findCell(t *testing.T, rep store.Report, rowID, colID uint64) store.ChangedCell { |
| + t.Helper() |
| + for _, c := range rep.Cells { |
| + if c.RowID == rowID && c.ColID == colID { |
| + return c |
| + } |
| + } |
| + t.Fatalf("report does not mention row %d col %d; it has %+v", rowID, colID, rep.Cells) |
| + return store.ChangedCell{} |
| +} |
| + |
| +// The report is what the live layer fans out. If a dependent is missing |
| +// from it, the other tab shows a formula's old result forever. |
| +func TestSetReportNamesTheOriginAndItsDependents(t *testing.T) { |
| + h := openSeeded(t) |
| + |
| + // A2 = A1 * 2. A1 is seeded holding "5", so A2 starts at 10. |
| + if err := h.Set(rowB, colA, "=A1*2", actor); err != nil { |
| + t.Fatalf("Set A2: %v", err) |
| + } |
| + |
| + rep, err := h.SetReport(rowA, colA, "21", actor) |
| + if err != nil { |
| + t.Fatalf("SetReport A1: %v", err) |
| + } |
| + if rep.Capped { |
| + t.Fatal("a two-cell recalculation reported itself capped") |
| + } |
| + if len(rep.Cells) != 2 { |
| + t.Fatalf("report covers %d cells, want 2 (A1 and its dependent A2): %+v", |
| + len(rep.Cells), rep.Cells) |
| + } |
| + |
| + origin := findCell(t, rep, rowA, colA) |
| + if origin.Derived { |
| + t.Error("the cell that was typed into is flagged derived") |
| + } |
| + if origin.Raw != "21" { |
| + t.Errorf("origin raw = %q, want %q — the editor would show the wrong text", origin.Raw, "21") |
| + } |
| + if origin.Value.Kind != calc.KindNum || origin.Value.Num != 21 { |
| + t.Errorf("origin value = %+v, want the number 21", origin.Value) |
| + } |
| + |
| + dep := findCell(t, rep, rowB, colA) |
| + if !dep.Derived { |
| + t.Error("a recalculated dependent is not flagged derived; history would credit the wrong edit") |
| + } |
| + if dep.Value.Kind != calc.KindNum || dep.Value.Num != 42 { |
| + t.Errorf("A2 = %+v, want the number 42", dep.Value) |
| + } |
| + if dep.Raw != "=A1*2" { |
| + t.Errorf("dependent raw = %q, want the formula, not its result", dep.Raw) |
| + } |
| +} |
| + |
| +// The version is the only thing a client can order paints by. If it does |
| +// not advance, two concurrent editors produce a visibly wrong grid — |
| +// the exact bug optimistic painting is meant to hide. |
| +func TestSetReportVersionAdvances(t *testing.T) { |
| + h := openSeeded(t) |
| + first, err := h.SetReport(rowA, colA, "1", actor) |
| + if err != nil { |
| + t.Fatalf("SetReport: %v", err) |
| + } |
| + second, err := h.SetReport(rowA, colA, "2", actor) |
| + if err != nil { |
| + t.Fatalf("SetReport again: %v", err) |
| + } |
| + if second.Version <= first.Version { |
| + t.Fatalf("version did not advance: %d then %d", first.Version, second.Version) |
| + } |
| +} |
| + |
| +// setBlank's touched set is its dependents only — the origin's row is |
| +// deleted, so it is not in the graph to recompute. Without the report |
| +// adding it back, the other tab never clears the cell you just emptied. |
| +func TestSetReportOnBlankReportsTheClearedCell(t *testing.T) { |
| + h := openSeeded(t) |
| + if err := h.Set(rowA, colA, "7", actor); err != nil { |
| + t.Fatalf("Set: %v", err) |
| + } |
| + rep, err := h.SetReport(rowA, colA, "", actor) |
| + if err != nil { |
| + t.Fatalf("SetReport blank: %v", err) |
| + } |
| + cleared := findCell(t, rep, rowA, colA) |
| + if cleared.Raw != "" { |
| + t.Errorf("cleared cell reports raw %q, want empty", cleared.Raw) |
| + } |
| + if cleared.Value.Kind != calc.KindEmpty { |
| + t.Errorf("cleared cell reports value %+v, want empty", cleared.Value) |
| + } |
| + if cleared.Derived { |
| + t.Error("the cell that was cleared is flagged derived") |
| + } |
| +} |
| + |
| +// Blanking a feeder must tell every dependent, or they keep painting a |
| +// number computed from a cell that no longer exists. |
| +func TestSetReportOnBlankReportsDependentsToo(t *testing.T) { |
| + h := openSeeded(t) |
| + if err := h.Set(rowB, colA, "=A1*2", actor); err != nil { |
| + t.Fatalf("Set A2: %v", err) |
| + } |
| + rep, err := h.SetReport(rowA, colA, "", actor) |
| + if err != nil { |
| + t.Fatalf("SetReport blank A1: %v", err) |
| + } |
| + if len(rep.Cells) != 2 { |
| + t.Fatalf("blanking a feeder reported %d cells, want 2: %+v", len(rep.Cells), rep.Cells) |
| + } |
| + dep := findCell(t, rep, rowB, colA) |
| + if !dep.Derived { |
| + t.Error("the dependent of a blanked cell is not flagged derived") |
| + } |
| + if dep.Value.Kind != calc.KindNum || dep.Value.Num != 0 { |
| + t.Errorf("A2 after A1 was blanked = %+v, want 0", dep.Value) |
| + } |
| +} |
| + |
| +// Set is now a wrapper. If it stopped writing, every existing caller — |
| +// XLSX import above all — would silently do nothing. |
| +func TestSetStillWrites(t *testing.T) { |
| + h := openSeeded(t) |
| + if err := h.Set(rowA, colA, "99", actor); err != nil { |
| + t.Fatalf("Set: %v", err) |
| + } |
| + if v := h.Value(rowA, colA); v.Kind != calc.KindNum || v.Num != 99 { |
| + t.Fatalf("after Set, A1 = %+v, want 99", v) |
| + } |
| +} |
| + |
| +func TestSetReportRefusesACellOutsideTheTab(t *testing.T) { |
| + h := openSeeded(t) |
| + if _, err := h.SetReport(999, 999, "1", actor); err == nil { |
| + t.Fatal("SetReport on a row and column that are not in this tab = nil error") |
| + } |
| +} |
diff --git a/internal/sheets/store/handle_set.go b/internal/sheets/store/handle_set.go| index 3d62f17..2805ad6 100644 |
| --- a/internal/sheets/store/handle_set.go |
| +++ b/internal/sheets/store/handle_set.go |
| @@ -1,8 +1,6 @@ |
| package store |
| import ( |
| - "fmt" |
| - |
| "gorm.io/gorm" |
| "amadan.net/oficina/sheets/internal/sheets" |
| @@ -20,42 +18,14 @@ var afterBlankTransactionOneCommits = func() error { return nil } |
| // Set writes a cell's new raw input and recalculates forward from it. |
| // |
| -// For an edit to a surviving cell, the input write — plus the version |
| -// bump that IS the dirty marker (models.go: CacheVersion records the |
| -// InputVersion its cache was derived from — bumping InputVersion here |
| -// without yet touching CacheVersion is exactly what makes this cell look |
| -// dirty) — lands in one transaction. Recalculation, and persisting what |
| -// it computes, happens afterward in a second transaction. A crash |
| -// between the two leaves precisely the state Open's crash-recovery pass |
| -// exists to detect and fix: no separate marker column is needed because |
| -// the version gap IS the marker, and clearing it is writing a |
| -// CacheVersion that has caught back up. |
| -// |
| -// Blanking a cell cannot use that mechanism on the cell itself — its row |
| -// is deleted, so there is no surviving row to carry a marker — so it |
| -// marks its DEPENDENTS dirty instead, atomically with the delete. See |
| -// the blank branch below and TestBlankCrashLeavesDependentsRecoverable. |
| +// It is SetReport with the report thrown away, and exists at this |
| +// signature because every caller that does not fan out over SSE — XLSX |
| +// import, and most of this package's tests — wants exactly that. The |
| +// crash-safety reasoning that used to live here moved to SetReport |
| +// (handle_report.go), which is now where the work happens. |
| func (h *Handle) Set(rowID, colID uint64, raw string, actor uint64) error { |
| - h.mu.Lock() |
| - defer h.mu.Unlock() |
| - |
| - key, ok := h.cellKey(rowID, colID) |
| - if !ok { |
| - return fmt.Errorf("store: row %d col %d is not part of tab %d", rowID, colID, h.tabID) |
| - } |
| - |
| - node, blank, err := contentNode(raw) |
| - if err != nil { |
| - return err |
| - } |
| - |
| - v := h.nextVersion |
| - prevRaw := h.rawText[key] |
| - |
| - if blank { |
| - return h.setBlank(rowID, colID, key, v, actor, prevRaw) |
| - } |
| - return h.setContent(rowID, colID, key, node, raw, v, actor, prevRaw) |
| + _, err := h.SetReport(rowID, colID, raw, actor) |
| + return err |
| } |
| // setBlank deletes a cell's row (cells are sparse — models.go) and |
| @@ -71,7 +41,10 @@ func (h *Handle) Set(rowID, colID uint64, raw string, actor uint64) error { |
| // leads its own CacheVersion, and Open's ordinary per-cell recovery — |
| // already proven by TestStaleCacheIsRecomputedOnOpen — finds and fixes |
| // it without needing to know anything about deletes specifically. |
| -func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor uint64, prevRaw string) error { |
| +// It returns the dependents it actually recomputed, and whether the |
| +// dependent set was too wide to recompute at all — SetReport turns both |
| +// into a report a live client can either paint or resync from. |
| +func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor uint64, prevRaw string) (map[calc.CellKey]struct{}, bool, error) { |
| // TransitiveDependents reads g.dependents[key], which Clear (called |
| // below, after this transaction commits) never touches — only key's |
| // own precedent edges are rewired by Clear — so this is safe to |
| @@ -104,14 +77,14 @@ func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor |
| OldRaw: prevRaw, NewRaw: "", |
| }) |
| }); err != nil { |
| - return err |
| + return nil, false, err |
| } |
| // Test seam: a crash landing exactly here — transaction 1 durably |
| // committed, nothing after it has run yet — is what |
| // TestBlankCrashLeavesDependentsRecoverable simulates. Always nil in |
| // production. |
| if err := afterBlankTransactionOneCommits(); err != nil { |
| - return err |
| + return nil, false, err |
| } |
| h.nextVersion++ |
| delete(h.rawText, key) |
| @@ -126,21 +99,30 @@ func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor |
| // cap, so this is not silent: each one's own version gap is |
| // there for Open's ordinary per-cell recovery to find and fix, |
| // the same as any other stale cell. |
| - return nil |
| + // |
| + // capped is true only in the second of those two cases: no |
| + // dependents at all is nothing to report, while too many is a |
| + // partial truth a live client must not paint. |
| + return nil, len(touched) > 0, nil |
| } |
| // Transaction 2: recompute the real values and persist them, |
| // clearing the marker transaction 1 just wrote. |
| h.graph.Recalc(key, newHandleEnv()) |
| - return h.persistTouched(touched, v) |
| + if err := h.persistTouched(touched, v); err != nil { |
| + return nil, false, err |
| + } |
| + return touched, false, nil |
| } |
| // setContent writes a formula or literal cell's new input and |
| // recalculates forward from it. |
| -func (h *Handle) setContent(rowID, colID uint64, key calc.CellKey, node calc.Node, raw string, v uint64, actor uint64, prevRaw string) error { |
| +// It returns everything the recalculation touched — the edited cell |
| +// included — and whether the pass was too wide to run. |
| +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) { |
| sealedInput, err := h.sealer.Seal("cell.input", []byte(raw)) |
| if err != nil { |
| - return err |
| + return nil, false, err |
| } |
| if err := h.db.Transaction(func(tx *gorm.DB) error { |
| res := tx.Model(&sheets.Cell{}). |
| @@ -167,42 +149,30 @@ func (h *Handle) setContent(rowID, colID uint64, key calc.CellKey, node calc.Nod |
| OldRaw: prevRaw, NewRaw: raw, |
| }) |
| }); err != nil { |
| - return err |
| + return nil, false, err |
| } |
| h.nextVersion++ |
| h.rawText[key] = raw |
| h.graph.SetFormula(key, node) |
| h.keyToIDs[key] = cellIDs{RowID: rowID, ColID: colID} |
| - return h.recalcAndPersist(key, v, true) |
| + touched, ok := h.recalcTouched(key, true) |
| + if !ok { |
| + // recalcTouched refuses only past calc.MaxDirtyPerPass here: |
| + // includeOrigin puts this cell in the set, so the set is never |
| + // empty on this path. |
| + return nil, true, nil |
| + } |
| + if err := h.persistTouched(touched, v); err != nil { |
| + return nil, false, err |
| + } |
| + return touched, false, nil |
| } |
| // markDirty is defined in handle_batch.go, alongside the crash-recovery |
| // batching it shares its "bounded number of statements, not one per |
| // touched cell" shape with. |
| -// recalcAndPersist propagates a change at origin to its transitive |
| -// dependents, evaluates every touched cell exactly once (via |
| -// Graph.Recalc), and persists each one's new CachedSealed/CacheVersion — |
| -// the step that closes the dirty marker setContent (or Open's recovery |
| -// pass, via recoverStale in handle_batch.go) opened. includeOrigin is |
| -// true whenever origin's own row survives to receive a cache write |
| -// (every caller except setBlank, which persists dependents directly once |
| -// its own recompute step is reached). |
| -// |
| -// This is the single-origin, persist-immediately shape setContent needs |
| -// (one Set call, one commit). Open's crash-recovery loop needs to spread |
| -// many origins' recomputation across few commits instead, so it calls |
| -// recalcTouched directly and batches the persistTouched calls itself — |
| -// see recoverStale. |
| -func (h *Handle) recalcAndPersist(origin calc.CellKey, v uint64, includeOrigin bool) error { |
| - touched, ok := h.recalcTouched(origin, includeOrigin) |
| - if !ok { |
| - return nil |
| - } |
| - return h.persistTouched(touched, v) |
| -} |
| - |
| // recalcTouched computes origin's touched set — TransitiveDependents, |
| // plus origin itself if includeOrigin — and, if it is within |
| // calc.MaxDirtyPerPass, runs Graph.Recalc so every touched cell's |
| @@ -213,11 +183,12 @@ func (h *Handle) recalcAndPersist(origin calc.CellKey, v uint64, includeOrigin b |
| // duplicating) Recalc's own cap. See calc.MaxDirtyPerPass's comment for |
| // why an over-wide pass is refused rather than run. |
| // |
| -// Split out of what used to be recalcAndPersist's body so a caller that |
| -// wants to recompute several origins before persisting any of them |
| +// Split out from the recompute-and-persist pair so a caller that wants |
| +// to recompute several origins before persisting any of them |
| // (recoverStale, handle_batch.go) can do the cheap in-memory part here |
| // and defer the expensive part — a transaction commit — to its own |
| -// batching. |
| +// batching. setContent and setBlank call it and then persistTouched |
| +// directly, one commit per Set. |
| func (h *Handle) recalcTouched(origin calc.CellKey, includeOrigin bool) (map[calc.CellKey]struct{}, bool) { |
| touched := h.graph.TransitiveDependents(origin) |
| if includeOrigin { |