Skip to content

Share templates and parsed sources within a cache digest computation - #2769

Merged
joelhawksley merged 3 commits into
ViewComponent:mainfrom
erikaxel:cache-digest-memoize-templates
Nov 3, 2026
Merged

joelhawksley merged 3 commits into
ViewComponent:mainfrom
erikaxel:cache-digest-memoize-templates

Conversation

@erikaxel

@erikaxel erikaxel commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

What are you trying to accomplish?

Since 4.20, a component's cache digest covers the modules it includes and every component constant they name. That fixed a real gap, but it made digests very slow in apps where most components include a shared helper module that renders other components. In our app (1,500 components, most of them including one such module), on 4.20 and 4.21:

4.19.0 4.21.0 This branch
One cold cache_digest of a typical page component (ActionView::LookupContext::DetailsKey.clear before each call) 0.04 s 70–76 s 0.34–0.46 s
Boot under eager loading: __vc_compile and CacheDigest.precompute! for every component 8.3 s 144 s 13 s
A 232-example slice of our request and component specs 26 s 14 min 39 s 30 s

All component digests the app computes at boot are byte-identical between 4.21.0 and this branch.

What approach did you choose and why?

The profile was dominated by Prism.parse and CacheDigest.each_node. One cold digest made 86,624 Prism.parse calls on 263 distinct sources. The tree held only 79 components, but Resolver#source_for ran 4,125 times. There were two causes:

  1. Templates are looked up once per edge, not once per node. ActionView::Digestor.tree calls find_template before it checks seen. Action View's file resolvers cache their templates, so for partials that costs nothing. CacheDigest::Resolver builds a fresh synthetic template every time, reading and parsing the component's Ruby, its templates and its included modules. A component rendered from 50 places was built 50 times.
  2. Every boot-time digest rebuilt the whole tree. precompute! digests every component, and each Digestor.digest call builds a new tree. Child digests are cached in finder.digest_cache, but the tree is still walked below them. With a shared helper module, each component's tree is most of the app, so precompute walked 5.2 million edges.

The changes:

  • CacheDigest.memoizing keeps a thread-local memo for the length of one Digestor.digest call, or a whole precompute! run. It's thread-local like retaining_compiled_sources, so nothing is written to module state that other Ractors would need. Within that memo:

    • DigestorLookup#find_template memoizes the synthetic template per name, prefix, formats and view paths;
    • DependencyTracking#find_dependencies memoizes per template;
    • CacheDigest.renders_in memoizes per source, so a module or superclass shared by many components is parsed once.

    The memo is discarded when the computation ends, so a file edited between requests is still picked up. That matches the resolver's existing guarantee that it never goes stale.

  • Pruning cached dependencies. DigestorLookup#tree turns a dependency whose digest is already in finder.digest_cache into a leaf. Node#dependency_digest reads finder.digest_cache[node.name] before it walks a node's children, so those children never affect the result. That's why the digests come out identical.

  • Expiry guard. Registering a new component while a tree is being built calls expire_digests, which can clear a digest a pruned node was standing in for. expire_digests now increments a counter. If it changed during a digest that pruned anything, the digest caches are cleared again and the digest is recomputed. The memoized templates make the retry cheap, and it only happens when a component registers for the first time mid-digest.

Alternatives I considered:

  • Caching templates in the resolver across requests. I rejected this because the resolver deliberately reads from disk on every call so it can't go stale in development.
  • Memoizing only Prism.parse. That brought one digest from 70 s to 13 s, but per-edge file reads, globbing, Slim compilation and constant resolution remained. Precompute also stayed slow because of the tree walk.

Anything you want to highlight for special attention from reviewers?

  • The pruning in DigestorLookup#tree depends on Action View's Node#dependency_digest reading the cache before it walks a node's children. That has been true from Rails 7.1 through main. If Action View changes it, the digests would still be correct, just slower. I'd welcome a second pair of eyes on the expiry guard in DigestorLookup#digest.
  • Tests. test_digest_builds_each_component_once fails without the template memo. test_digest_recomputes_when_digests_expire_while_the_tree_is_built fails without the expiry guard. CacheableDiamondComponent gives both tests a component reached along two paths.
  • Where I ran things. The full suite passes on Rails 8.1 and Ruby 4.0. I couldn't run the Rails 7.1 appraisal locally because it needs Ruby 3.2, so I checked by reading the source that 7.1's Digestor has the same tree, TemplatePath and Node.create the patch relies on.
  • Your benchmarks. I haven't run the repository's own benchmarks. The CI performance check should show whether the extra memo lookups cost anything on small apps.

erikaxel and others added 3 commits November 3, 2026 12:24
The Digestor looks a template up once per edge of the tree, before it checks
whether it has seen the template, so each synthetic component template was
rebuilt, and its Ruby and templates re-parsed, once per place it was rendered
from. Boot-time precomputation also rebuilt the whole tree for every component,
even below dependencies whose digest was already cached.

Memoize synthetic templates, tracked dependencies and `renders_in` results for
one digest computation (or one `precompute!` run), and turn a dependency whose
digest is already cached into a leaf. A registration that expires digests while
a tree is built restarts the digest, so a pruned node never stands in for a
digest that was dropped.
Audition flags the `@expirations` counter on `CacheDigest` as class-level
state that non-main Ractors can't write. Record the cached digest each pruned
node stood in for instead, and recompute when any of them is no longer the one
in the cache. This also catches an expiry from another thread.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@joelhawksley
joelhawksley merged commit 00042b8 into ViewComponent:main Nov 3, 2026
25 checks passed
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