Skip to content

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

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

Fix #1629: resolve memory leaks in LazilyTransformingAstService and UndoRedo#1638
marcin-kordas-hoc wants to merge 15 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 engine mutation paths (lazy transformation versioning/compaction, column index maintenance, and undo/redo bookkeeping), so regressions could affect formula updates or undo correctness under heavy structural edits.

Overview
Fixes two long-running memory leaks by adding threshold-based compaction to LazilyTransformingAstService (new compactionThreshold config, versionOffset, and compaction triggered during recomputation after forcing all formula vertices and column indexes to apply pending transformations).

Refactors UndoRedo to track and garbage-collect oldData by having each UndoEntry report referenced LTAS versions, cleaning up on stack eviction/clear, skipping storage when undoLimit is 0, and pruning orphaned oldData after compaction; column-search strategies gain forceApplyPostponedTransformations() to support safe compaction.

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

@github-actions
Copy link

github-actions bot commented Mar 17, 2026

Performance comparison of head (8e0ebec) vs base (266089d)

                                     testName |   base |   head | change
------------------------------------------------------------------------
                                      Sheet A | 493.35 | 490.54 | -0.57%
                                      Sheet B | 157.06 |  158.2 | +0.73%
                                      Sheet T | 138.18 | 140.34 | +1.56%
                                Column ranges | 470.39 | 472.36 | +0.42%
Sheet A:  change value, add/remove row/column |  16.54 |  16.18 | -2.18%
 Sheet B: change value, add/remove row/column | 135.88 | 136.43 | +0.40%
                   Column ranges - add column | 146.57 | 150.77 | +2.87%
                Column ranges - without batch |  445.9 | 470.58 | +5.53%
                        Column ranges - batch | 111.57 | 118.24 | +5.98%

@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.

Copy link
Contributor

@sequba sequba left a comment

Choose a reason for hiding this comment

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

It seems that you came up with an elegant and efficient solution even though it was a complex issue. Good job! I need more dev-oriented in-code documentation so I and the future contributors understand the optimization mechanisms introduced with this PR.

Also, please add 2 changelog entries describing both fixes.

…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
@codecov
Copy link

codecov bot commented Mar 25, 2026

Codecov Report

❌ Patch coverage is 98.66667% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 97.18%. Comparing base (266089d) to head (0933a14).

Files with missing lines Patch % Lines
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.18%   -0.01%     
===========================================
  Files          172      172              
  Lines        14845    14912      +67     
  Branches      3264     3276      +12     
===========================================
+ Hits         14427    14492      +65     
- Misses         418      420       +2     
Files with missing lines Coverage Δ
src/BuildEngineFactory.ts 100.00% <100.00%> (ø)
src/Config.ts 94.05% <100.00%> (+0.12%) ⬆️
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/UndoRedo.ts 100.00% <100.00%> (ø)
src/Lookup/ColumnIndex.ts 95.76% <85.71%> (-0.39%) ⬇️

... and 1 file with indirect coverage changes

🚀 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.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

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

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.

2 participants