Skip to content

Add a persistent, size-bounded rasterization cache - #131

Closed
philips-clanker wants to merge 8 commits into
mainfrom
rasterization-cache
Closed

Add a persistent, size-bounded rasterization cache#131
philips-clanker wants to merge 8 commits into
mainfrom
rasterization-cache

Conversation

@philips-clanker

@philips-clanker philips-clanker commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds RasterCache, a content-addressable cache of rasterized page PNGs keyed by (hash of the .note file's own bytes, page number), with two tiers:
    • Disk tier: persistent, capped at 100MB, LRU eviction. Storage is a minimal CacheStorage structural interface satisfied by Obsidian's app.vault.adapter, so the module has no dependency on the obsidian package and is unit-testable with a plain mock.
    • Memory tier: a smaller (20MB default), purely in-memory LRU layer in front of the disk tier, checked first. Never persisted — starts empty every plugin reload — but skips disk I/O + base64 decode entirely for pages already served earlier in the same session.
  • Wires the cache into ImageConverter.convertToImages: pages already cached are served without touching the worker pool; only missing pages are rendered and written back.
  • Three perf bugs found and fixed while validating this against real, larger notes (the raster cache alone wasn't moving the needle on perceived open/close speed):
    1. ImageConverter used to own and tear down its own WorkerPool on every rasterization call, and WorkerPool's constructor eagerly spawns hardwareConcurrency new Web Workers — so even a 100%-cache-hit open (zero pages rendered) still paid full multi-worker spin-up/teardown for nothing. Now a lazily-created, module-level singleton reused for the plugin's lifetime, torn down once in onunload().
    2. SupernoteView.onLoadFile awaited createImageBitmap() one page at a time inside its per-page DOM-building loop — a page-count-scaling cost paid on every open regardless of how the PNG was obtained. Now decoded up front via Promise.all.
    3. The big one: SupernoteView builds an in-memory PDF purely so pdf.js can extract a text layer for search/selection (pdfPage.render() is never called against it — the embedded image is never actually shown). It was still unconditionally embedding every page's real image into that PDF. pdf-lib's PNG embedding always fully decodes and recompresses the source PNG at .save() time regardless of it already being compressed — real, size-proportional cost. Profiled against an actual user-provided 12-page note with several user-uploaded-background pages: ~2.9s / ~4.5MB → ~40ms / ~43KB, same extracted text either way. Fixed by bumping the supernote-typescript submodule to add addTextOnlyPdfPage() (philips/supernote-typescript#34) — a PDF page with the recognized-text layer but no image — and using it for this internal-only pipeline. The user-facing "export/attach as PDF" commands are untouched and still embed real images, since those PDFs are genuinely opened by the user.
  • Reuses the existing non-cryptographic hashBytes fingerprint from deviceSync.ts rather than adding a new hashing dependency.
  • One shared RasterCache instance is created in SupernotePlugin.onload(), stored under the plugin's own data folder (<plugin dir>/rasterCache/) — never as visible vault content.
  • Adds a "Rasterization cache" row to the settings tab (disk + memory tier stats, "Clear cache" button) and debug-level console logging (cache init, per-open hit/miss/timing, page-render phase timing, background PDF pipeline timing) — this instrumentation is what let the above three bugs get root-caused with real numbers instead of guesswork.
  • Every storage operation is fail-open: a read/write/parse error is logged and treated as a cache miss or no-op, so a broken/unavailable cache degrades to "always re-rasterize" instead of breaking rendering.

Design notes:

  • Cache granularity: keyed by the whole note's content hash + page number, not a per-page hash — editing any page invalidates cache entries for every page in that note. Chosen for simplicity; stale entries just age out via eviction.
  • Eviction policy: LRU on both cache tiers via Map insertion-order reordering on hit; the disk tier's reordering is kept in memory only, persisted on the next put()/clear() rather than on every read.

Closes #129.

Test plan

  • npx tsc -noEmit -skipLibCheck — clean
  • npm run lint — clean (pre-existing unrelated warnings only)
  • npx vitest run — 109 passed, including 14 rasterCache.test.ts cases (roundtrip, per-key isolation, LRU eviction on both tiers, memory tier avoiding repeat disk reads, independent per-tier eviction budgets, index persistence, fail-open behavior, clear())
  • supernote-typescript's own test suite (26 tests, vitest) passes with the new addTextOnlyPdfPage, including a new test asserting identical extracted text vs. addPdfPage at <1/10th the byte size
  • ./scripts/build — full production build succeeds
  • Manually verified real cache population/persistence, and the three perf fixes, against actual user-provided notes during testing

Rasterizing a .note file's pages is the most expensive step in every
view/embed/export path, and re-renders from scratch every time even when
the file hasn't changed. Add a content-addressable, per-page PNG cache
(keyed by a hash of the note's own bytes + page number) stored under the
plugin's data folder, capped at 100MB with oldest-inserted-first eviction.
ImageConverter checks the cache before dispatching pages to the worker
pool, and only rasterizes+caches whatever's missing.

Adds a settings-tab entry showing cache size and a manual "clear cache"
button.

Closes #129.
get() now moves a hit to the most-recently-used end of the entries Map
(delete+re-insert), so eviction removes the actual least-recently-used
entry instead of just the oldest-inserted one. Reordering is kept in
memory only, persisted to disk on the next put()/clear() rather than on
every read, so cache hits stay cheap.

Also fixes a latent accounting bug found while touching this code: the
self-heal path in get() (an indexed entry whose blob is missing on disk)
dropped the entry from the index but never subtracted its size from
totalBytes, so totalCachedBytes could drift upward after cache corruption.
No visibility currently into whether the cache actually initialized or
is doing anything useful — since it's designed to fail open, a silent
init failure looks identical to "the cache just isn't helping." Logs
(console.debug, so they only show up with dev tools open) report cache
init success/failure and, per rasterization call, how many pages were
served from cache vs. rendered fresh and how long it took.
… call

ImageConverter previously owned its own WorkerPool, constructed and
terminated on every single call — WorkerPool's constructor eagerly spawns
hardwareConcurrency new Web Workers, so this happened even on a full
raster-cache hit (zero pages actually rendered), paying full multi-worker
startup/teardown cost for nothing on every note open. Make the pool a
lazily-created (so plugin activation itself doesn't spin up workers),
module-level singleton reused for the plugin's lifetime and torn down
once in onunload().

Also ignore the runtime rasterCache/ directory: it lands at the repo root
during manual testing (this repo symlinked in as a test vault's plugin
folder), not something that belongs in git.
Every cache hit still paid a vault.adapter.readBinary + manual base64
decode, even for a page served moments ago in the same session (e.g.
closing a note and reopening it). Add a smaller (20MB default), purely
in-memory LRU layer that get()/put() populate and check first; it's
never persisted and starts empty on every plugin reload, but skips disk
I/O entirely for anything still warm. Eviction is independent per tier
(memory can evict a page while its disk copy, and a much larger budget,
stays put) and clear() empties both.

Settings tab and debug logging updated to surface memory-tier stats
alongside the existing disk-tier ones.
SupernoteView.onLoadFile awaited createImageBitmap() inside the per-page
loop, so pages were decoded strictly sequentially regardless of whether
the rasterized PNG for each came from the worker pool or either raster
cache tier — a real, page-count-scaling cost paid on every single note
open, cache-warm or not. Decode all pages' bitmaps up front via
Promise.all, then just index into the results inside the (now purely
DOM-building) loop. Also adds a debug-log timing line for this phase so
it's measurable going forward.
…und PDF pipeline

12 cached pages took 3s to decode+build DOM for, which is far too slow
for what should be cheap work — split the existing timing log into a
decode-only and DOM-build-only number to see which one is actually
responsible, and add timing to the background pdfDocPromise pipeline
(assemblePdfFromImages/loadPdfJs/getDocument). That pipeline isn't
awaited, but it still runs on the same single JS thread concurrently
with the page-render loop, so it's a real candidate for stealing time
from what looked like a pure decode/DOM cost.
The PDF built for pdf.js's getTextContent()/getViewport() (search and
text selection) was unconditionally paying the full cost of embedding
every page's real image, even though pdf.js's render() is never called
against it — the image is never actually shown. pdf-lib's PNG embedding
always fully decodes and recompresses the source PNG at save() time
regardless of it already being compressed, which is real,
size-proportional cost.

Bump the supernote-typescript submodule to pick up
addTextOnlyPdfPage() (philips/supernote-typescript#34) and use it here
instead of assemblePdfFromImages(). Profiled against a real 12-page note
with several user-uploaded-background pages: ~2.9s/~4.5MB before, down
to ~40ms/~43KB after, with identical extracted/searchable text. This was
the dominant cost behind "opening an already-cached note still feels
slow" — confirmed by splitting the earlier timing logs into per-phase
numbers and finding the "decode" and "assemble" timings were nearly
identical, i.e. competing for the same main thread.

assemblePdfFromImages() (real images) is unchanged and still used by the
user-facing "export/attach as PDF" commands, which need actual page
content in their output.
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.

Explore a limited rasterization cache

2 participants