Skip to content

Fix #1629: resolve memory leaks in LazilyTransformingAstService and UndoRedo#1638

Open
marcin-kordas-hoc wants to merge 6 commits intodevelopfrom
fix/1629
Open

Fix #1629: resolve memory leaks in LazilyTransformingAstService and UndoRedo#1638
marcin-kordas-hoc wants to merge 6 commits intodevelopfrom
fix/1629

Conversation

@marcin-kordas-hoc
Copy link
Collaborator

@marcin-kordas-hoc marcin-kordas-hoc commented Mar 17, 2026

Summary

Fixes #1629. Closes #1633. Closes #1634.

Two unbounded memory leaks in long-running HyperFormula instances:

  • LTAS.transformations[] grew linearly with every structural operation (addRows, removeRows, moveCells, etc.) and was never cleaned up. Fixed by introducing threshold-based compaction with versionOffset — once 50+ transformations accumulate, all consumers (FormulaVertex, ColumnIndex) are force-updated, then the array is released while the logical version remains monotonically increasing.

  • UndoRedo.oldData grew linearly even when entries were evicted from the undo stack. Fixed by tracking which LTAS versions each UndoEntry references (getReferencedOldDataVersions()), cleaning up on eviction/clear, guarding against writes when undoLimit === 0, and running orphan cleanup after compaction to handle a race condition where lazy-apply re-inserts already-evicted keys.

Changed files

File Change
LazilyTransformingAstService.ts versionOffset, compact(), needsCompaction() with threshold=50, offset-aware iteration
UndoRedo.ts getReferencedOldDataVersions() on interface + 7 subclasses, eviction cleanup, undoLimit===0 guard, cleanupOrphanedOldData(), forceApply parity in undoMoveRows/undoMoveColumns
HyperFormula.ts Compaction trigger in recomputeIfDependencyGraphNeedsIt()
ColumnIndex.ts forceApplyPostponedTransformations() — iterates all ValueIndex entries
ColumnBinarySearch.ts No-op forceApplyPostponedTransformations()
SearchStrategy.ts New method on ColumnSearchStrategy interface
Operations.ts Added columnSearch.forceApply to undo path (no compact — centralized in HyperFormula.ts)

What is NOT fixed here

Test plan

  • 18 dedicated tests in hyperformula-tests — see companion PR in that repo
  • Full test suite: 480 suites / 5396 tests passed
  • Benchmark validated threshold=50 as optimal (eager compaction is ~18× slower on a 2.5k formula sheet)

Note

Medium Risk
Touches core recalculation/undo internals and introduces forced application/compaction logic; regressions could surface as incorrect formula updates or undo behavior, though changes are targeted at memory cleanup.

Overview
Fixes long-running memory growth by adding threshold-based compaction to LazilyTransformingAstService: versions become offset-aware (versionOffset), transformation iteration is updated, and compact() drops accumulated transformations once they’ve been applied.

Hooks compaction into HyperFormula.recomputeIfDependencyGraphNeedsIt() by forcing postponed transformations on both the dependency graph and column search (ColumnIndex adds forceApplyPostponedTransformations(), ColumnBinarySearch implements a no-op) before compacting.

Prevents UndoRedo.oldData from leaking by tracking referenced LTAS versions per UndoEntry, deleting oldData on stack eviction/clear, skipping oldData writes when undoLimit === 0, and adding cleanupOrphanedOldData() invoked after compaction to remove unreferenced versions.

Written by Cursor Bugbot for commit 30dca8d. This will update automatically on new commits. Configure here.

…ndoRedo

- Add versionOffset compaction to LTAS so transformations array does not
  grow unboundedly; compact() fires automatically in
  recomputeIfDependencyGraphNeedsIt (before partialRun) after all
  consumers (FormulaVertex, ColumnIndex) are synchronized
- Guard storeDataForVersion with early return when undoLimit === 0 to
  prevent oldData map growth when undo is disabled
- Add forceApplyPostponedTransformations to ColumnIndex and
  ColumnSearchStrategy interface to synchronize all consumers before
  compaction
- Coordinate compaction in Operations.forceApplyPostponedTransformations
  called from UndoRedo before irreversible undo operations
Clean up oldData map entries when undo/redo entries are permanently
discarded (eviction from undo stack, clearRedoStack, clearUndoStack).
Each UndoEntry now reports which LTAS version keys it references via
getReferencedOldDataVersions(), enabling targeted cleanup on eviction.

Resolves the secondary memory leak documented in #1629 follow-up:
oldData.size now stays bounded by undoLimit instead of growing linearly
with total structural operations.
…ition

- Add COMPACTION_THRESHOLD=50 to LazilyTransformingAstService to defer
  compaction until enough transformations accumulate, preserving lazy
  evaluation for small operation batches
- Fix race condition: when compaction forces lazy formula evaluation,
  storeDataForVersion() may insert oldData entries for already-evicted
  undo entries. cleanupOrphanedOldData() removes these orphans after
  each compaction cycle
- Without threshold, every structural op triggered full vertex traversal,
  effectively defeating lazy transformations (~18x slower for 2.5k formulas)
…rmations

Undo path only needs force-apply to bring consumers up-to-date before
inverse operations. Compaction is handled centrally in
HyperFormula.recomputeIfDependencyGraphNeedsIt(), eliminating double
traversal during undo and resolving the DRY duplication.
- Guard version > 0 in getReferencedOldDataVersions() for MoveCellsUndoEntry,
  MoveRowsUndoEntry, MoveColumnsUndoEntry — prevents returning [-1] when no
  transformation was added (e.g. moving empty range)
- Add forceApplyPostponedTransformations() to undoMoveRows/undoMoveColumns,
  matching the pattern already present in undoRemoveRows, undoRemoveColumns,
  undoMoveCells, undoRemoveSheet, undoRenameSheet
- Extend compact() JSDoc to document the cleanupOrphanedOldData() postcondition
@github-actions
Copy link

github-actions bot commented Mar 17, 2026

Performance comparison of head (30dca8d) vs base (4042b04)

                                     testName |   base |   head | change
------------------------------------------------------------------------
                                      Sheet A |  492.8 | 491.48 | -0.27%
                                      Sheet B |  159.8 |  158.3 | -0.94%
                                      Sheet T | 136.57 | 140.38 | +2.79%
                                Column ranges | 474.97 | 473.36 | -0.34%
Sheet A:  change value, add/remove row/column |  15.62 |  15.16 | -2.94%
 Sheet B: change value, add/remove row/column |  135.4 | 131.45 | -2.92%
                   Column ranges - add column | 148.52 | 150.49 | +1.33%
                Column ranges - without batch | 454.75 | 447.56 | -1.58%
                        Column ranges - batch | 113.95 | 111.95 | -1.76%

@marcin-kordas-hoc marcin-kordas-hoc self-assigned this Mar 17, 2026
@claude
Copy link

claude bot commented Mar 18, 2026

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit in Settings → Usage.

Once credits are available, push a new commit or reopen this pull request to trigger a review.

@codecov
Copy link

codecov bot commented Mar 18, 2026

Codecov Report

❌ Patch coverage is 89.39394% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.14%. Comparing base (4042b04) to head (30dca8d).

Files with missing lines Patch % Lines
src/UndoRedo.ts 83.78% 6 Missing ⚠️
src/Lookup/ColumnIndex.ts 85.71% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #1638      +/-   ##
===========================================
- Coverage    97.18%   97.14%   -0.04%     
===========================================
  Files          172      172              
  Lines        14836    14895      +59     
  Branches      3258     3268      +10     
===========================================
+ Hits         14418    14470      +52     
- Misses         418      425       +7     
Files with missing lines Coverage Δ
src/HyperFormula.ts 99.73% <100.00%> (+<0.01%) ⬆️
src/LazilyTransformingAstService.ts 97.91% <100.00%> (+0.48%) ⬆️
src/Lookup/ColumnBinarySearch.ts 100.00% <100.00%> (ø)
src/Lookup/SearchStrategy.ts 100.00% <ø> (ø)
src/Operations.ts 98.93% <100.00%> (+<0.01%) ⬆️
src/Lookup/ColumnIndex.ts 95.76% <85.71%> (-0.39%) ⬇️
src/UndoRedo.ts 98.64% <83.78%> (-1.36%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.


public getReferencedOldDataVersions(): number[] {
return this.rowsRemovals.map(r => r.version - 1)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent version-zero guards across undo entry types

Low Severity

RemoveRowsUndoEntry and RemoveColumnsUndoEntry return version - 1 without guarding against version being zero, while MoveCellsUndoEntry, MoveRowsUndoEntry, and MoveColumnsUndoEntry all guard with this.version > 0. Similarly, RenameSheetUndoEntry only checks !== undefined but not > 0. If a version were ever zero, the unguarded entries would return [-1], which is harmless today but inconsistent with the guarded entries. The seven getReferencedOldDataVersions() implementations across entry types use three different guard styles for the same invariant.

Additional Locations (1)
Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant