Repository navigation
Share templates and parsed sources within a cache digest computation - #2769
Merged
joelhawksley merged 3 commits intoNov 3, 2026
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
cache_digestof a typical page component (ActionView::LookupContext::DetailsKey.clearbefore each call)__vc_compileandCacheDigest.precompute!for every componentAll 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.parseandCacheDigest.each_node. One cold digest made 86,624Prism.parsecalls on 263 distinct sources. The tree held only 79 components, butResolver#source_forran 4,125 times. There were two causes:ActionView::Digestor.treecallsfind_templatebefore it checksseen. Action View's file resolvers cache their templates, so for partials that costs nothing.CacheDigest::Resolverbuilds 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.precompute!digests every component, and eachDigestor.digestcall builds a new tree. Child digests are cached infinder.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.memoizingkeeps a thread-local memo for the length of oneDigestor.digestcall, or a wholeprecompute!run. It's thread-local likeretaining_compiled_sources, so nothing is written to module state that other Ractors would need. Within that memo:DigestorLookup#find_templatememoizes the synthetic template per name, prefix, formats and view paths;DependencyTracking#find_dependenciesmemoizes per template;CacheDigest.renders_inmemoizes 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#treeturns a dependency whose digest is already infinder.digest_cacheinto a leaf.Node#dependency_digestreadsfinder.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_digestsnow 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:
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?
DigestorLookup#treedepends on Action View'sNode#dependency_digestreading 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 inDigestorLookup#digest.test_digest_builds_each_component_oncefails without the template memo.test_digest_recomputes_when_digests_expire_while_the_tree_is_builtfails without the expiry guard.CacheableDiamondComponentgives both tests a component reached along two paths.tree,TemplatePathandNode.createthe patch relies on.