Skip to content

fix(memoize): release cache with file, reset on re-parse - #1179

Merged
kristijanhusak merged 2 commits into
nvim-orgmode:masterfrom
seflue:fix/memoize-unbounded-cache-growth
Sep 16, 2026
Merged

kristijanhusak merged 2 commits into
nvim-orgmode:masterfrom
seflue:fix/memoize-unbounded-cache-growth

Conversation

@seflue

@seflue seflue commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

In long-running Neovim sessions with org files open, memory use keeps growing as I edit them and never comes back down. I have seen this for a while; one instance was at over 10 GB.

The memory is tree-sitter trees, pinned through the memoize cache, which never lets go of them. It is meant to shrink on its own. It is a module-level table declared __mode = 'k', which reads as "entries go away when their key is collected". They do not, because the keys are filenames and string keys are never weak in Lua (iscleared() in Lua 5.1's lgc.c, 5.4 manual §2.5.4). The obvious repair, keying by the file object, does not work either. A cached value references the file it was built from, and a weak-key table still marks its values (gc_traverse_tab skips marking only when keys and values are weak), so every entry keeps its own key alive. Lua 5.2 added ephemeron tables for exactly this case; LuaJIT has none. This is why the leak was easy to miss. The code looks like it handles the lifetime, and the reasons it does not are two GC details that hold only for strings and only for LuaJIT.

On top of that, an entry grows while its file is edited. Results are bucketed by node id, lookups after a re-parse use the ids of the new tree, and the entry is only reset when the mtime changes, so a file collects one set of dead buckets, and with it one tree, per re-parse between saves.

Two commits:

  • The cache moves from the module-level Memoize.cache to a field on the OrgFile instance, file.memoize_cache, so it lives exactly as long as the instance, with no invalidation call to remember.
  • The entry carries __generation, the root node id, next to __version. When either changes the entry is replaced as a whole, so a re-parse drops the old buckets and with them the tree they pinned.

Measured with a headless harness: load a set, render the week agenda, then rounds of edit a buffer, reload, redraw. Lua heap from collectgarbage('count'), C heap from mallinfo2(), RSS from /proc.

About 1500 files, 4.5 MB of org text, agenda open: 52 MB Lua heap, 209 MB C heap, 360 MB RSS on both versions. After unloading all files:

Lua heap C heap RSS
master 49 MB 196 MB 358 MB
this branch 17 MB 9 MB 164 MB

Ten rounds of edit, reload, redraw on 350 files with 100 of them open in buffers, live Lua + C heap:

this branch master
after the first render 32 MB 32 MB
after 10 rounds 35 MB 52 MB

master gains about 2 MB per round, one more tree of each edited file.

The memoize cache was a module-level table keyed by filename, declared
weak with `__mode = 'k'`. Lua never collects string keys, so entries
were never reclaimed: every file that was ever memoized kept its cached
results alive for the whole session, and with them its headlines and
tree-sitter nodes. `OrgFiles:unload()` did not release them either.

Keying that table by the file object is not enough either, because
LuaJIT has no ephemeron support and cached values reference the file
they were built from, so each entry stays reachable through its own
value.

Store the cache on the file, so that it is released together with it.
Results are cached in buckets keyed by node id, and the cache entry is
only replaced when the file's mtime changes. After a re-parse the
lookups use the ids of the new tree, and the buckets built for the
previous tree stay behind in the entry. A file edited in a buffer
therefore accumulated dead buckets for as long as it stayed loaded,
each one pinning headlines and tree-sitter nodes.

Track the root node id next to the mtime and reset the entry as a whole
when either changes.

@kristijanhusak kristijanhusak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great find! Thanks!

@kristijanhusak
kristijanhusak merged commit 3bfd89b into nvim-orgmode:master Sep 16, 2026
17 checks passed
@seflue
seflue deleted the fix/memoize-unbounded-cache-growth branch September 17, 2026 22:29
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