ReviewOS

stacks/stx

serve: the render cache starts empty on every restart; persisting it needs more than the template signature

#2133
Closed glennmichael123 opened this 5 days ago ·
5 days ago

From stacksjs/stacks#3035. The Stacks admin dashboard (128 pages) is served only by serve in dev mode, with renderCache: true and renderCacheVary: 'source'. A page's first render costs 1.2 to 4.7 s; after that it comes from the cache in under 10 ms. The cache lives only in memory (htmlCache, a boundedCache), so every restart pays every page's first render again. Developers restart often: config changes, backend edits through the dashboard's worker restart, buddy dev cycles.

Prewarming isn't the answer here: it ran on the serving thread and made pages take 3 to 28 s while it ran (stacksjs/stacks#2964), so Stacks turned it off.

Why it isn't just "write htmlCache to disk"

An entry is checked by templateSignatureFresh, the mtimes of the entry template plus the dependencies that processDirectives recorded. That is enough within one process only because other inputs are handled by clearing the whole cache in memory:

  • Client bundles. Composables and functions under watchDirs change the emitted HTML. The file watcher clears the cache, but they aren't in an entry's signature.
  • The CSS engine output and the cssCssCache. These are cleared by the watcher too.
  • Process-lifetime inputs. These don't change while the process runs, but can between runs: the stx and bun-plugin-stx versions, the serve options (component, layout and partials dirs, publicDir, domains), the user css config, and production.
  • The image warm-up state. Renders are only cached once placeholdersAreReady.

A disk cache that skipped any of these would serve stale HTML after a change made while the server was down. That's worse than a slow first visit.

Proposal

Persist entries to the stx state dir (next to image-delivery) only under renderCacheVary: 'source', keyed by file path. Store each entry with:

  1. its template signature, as today;
  2. a process fingerprint: stx and bun-plugin-stx versions, a hash of the serve options that affect rendering, and a hash of the user css config;
  3. a watch fingerprint: the mtimes of every file under watchDirs, or a hash of the listing.

Load lazily. An entry is used only if all three still match, and anything that doesn't match is discarded. The in-memory watcher behaviour stays as it is.

Open questions

  • Is there an input missing from the list above? The full set of what goes into a render is the thing to get right.
  • Should it be opt-in (renderCachePersist: true) at first?

Measurements and context: stacksjs/stacks#3035. Related: 1f04af9, which took the image warm-up scope scan from 49.5 s to under 1 s on the same dashboard.

5 days ago

Done on main in three commits: 65a791b (the store), f119fa7 (the option) and 36140a2 (a fingerprint fix).

renderCachePersist: true works only with renderCache and renderCacheVary: 'source'. Each render is kept in <stateDir>/render-cache/, one file per entry, read when a page misses. A restarted server serves the kept entry without running the page's server scripts, as long as the entry's template signature is still fresh and its fingerprint still matches.

The fingerprint covers what the in-memory cache relies on the watcher or the process lifetime for:

  • every file of a type the watcher reacts to, under the watched roots and the page roots;
  • package.json, the lockfiles and the .env files;
  • the stx and css config files, the installed stx and serve module, and Bun's version;
  • production and the rendering-relevant serve options.

Answers to the open questions

  • Opt-in. Anything a server script reads from outside the watched roots would be served stale after a restart, and such inputs belong in watchDirs. Putting them there also fixes the in-memory cache, which today misses them too.
  • Ordering. A render takes its fingerprint before it reads anything, so a change during the render can only produce a miss, never a stale hit. A write whose generation a watcher event invalidated meanwhile is dropped.
  • Build ID. The only per-process difference I found between two processes rendering the same page is <meta name="stx-build">. A kept page gets the reading process's build ID. The HMR client is injected at response time, so it never reaches the cache.

Measured on the Stacks dev dashboard

I swapped only the built serve.js into that repo, enabled the option, then restored both. On the second process, the first visit to each of five pages took 9 to 19 ms, down from 1.0 to 1.24 s. That time includes the fingerprint walk.

Tests

  • 13 store tests and 3 end-to-end tests, the latter on real serve() processes started one after another.
  • Controls: removing the re-stamp, the generation check, the root walk or serve's store read each turns its tests red.
  • 36140a2 fixed a bug found while checking Stacks' inputs: hidden files, and roots under hidden directories, had been left out of the fingerprint.
  • bun-plugin: 388 pass.

Stacks enabling it is tracked in stacksjs/stacks#3035. Its dashboard sidebar reads config/dashboard.ts, the discovered-models manifest and the app's models at render time, so those go into watchDirs first.

5 days ago

Follow-up verification and CI repair on main: 0284399 fingerprints the actual serve working directory instead of reading the nonexistent ServeOptions.root property (which failed typecheck), and fixes the store import ordering required by the formatting gate. The implementation already landed upstream is otherwise preserved.

Revalidated all 16 persistence/store tests, then the entire bun-plugin suite: 388 passed, 0 failed. Typecheck, lint, formatting and release preflight pass. These source changes are after v0.2.736 and have not been included in a new npm release here. Main CI: https://github.com/stacksjs/stx/actions/runs/40710365757

Sign in to comment on this issue.