Skip to content

[6.x] Add an opt-in Stache cache store that OPcache can hold in shared memory - #15884

Draft
DanielDarrenJones wants to merge 16 commits into
statamic:6.xfrom
DanielDarrenJones:feature/6x-stache-compiled-store
Draft

DanielDarrenJones wants to merge 16 commits into
statamic:6.xfrom
DanielDarrenJones:feature/6x-stache-compiled-store

Conversation

@DanielDarrenJones

@DanielDarrenJones DanielDarrenJones commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Draft. Opt-in, off by default. Up for discussion as much as for review: happy to change the shape of it, or hold it for 7.x.

The Stache keeps entries and indexes in a cache store, serialized. Every request that needs them reads the files and unserializes them, into memory of its own. This adds a second store the Stache can be pointed at, which writes the same things as PHP files that return arrays. OPcache holds those in shared memory, so a request reads them without a file read, without unserializing, and without a copy of its own.

It is the store from Flatpack, a free addon for Statamic 5.11 and up released earlier this month, without the parts of Flatpack that aren't about storage, and with the handling of several processes writing at once tightened up for core.

Turning it on

// config/statamic/stache.php
'cache_store' => 'stache_compiled',

or STATAMIC_STACHE_CACHE_STORE=stache_compiled with a config file that reads it. Nothing changes for a site that doesn't.

How it works

  • Entries go into a number of files per collection, each returning [id => entry as an array]. How many is settled when the collection is built, at about eight entries to a file, so saving an entry rewrites a handful of others rather than a fixed share of the collection.
  • Plain values (indexes, paths, keys) get a file each.
  • Anything else (other items, arrays holding objects, anything with a time to live) goes to an ordinary file store underneath, exactly as now.
  • A file is named after a hash of its contents and never rewritten, so OPcache never has a stale copy, whatever validate_timestamps is set to. A small pointer file maps each name to its current file and is read as JSON.
  • Filling the cache is buffered until the end of the request, job or command. A save or delete is written straight away, and again once the entry's terms and structure have been dealt with.
  • Writes are merged under a lock with whatever other processes wrote in the meantime. Where two processes changed the same entry, or the same key of an index, neither copy is kept: the Stache works it out again from the content files. Different keys of the same index are combined.
  • The same goes for something another process has forgotten since this one read it, including something that wasn't there to begin with: what this process was holding is dropped, not put back. So is everything held for a collection that another process has since built again.
  • A long-running process (Octane, a queue worker) notices another process's writes without being told. It reads the pointer again, not the files: a file never changes, so one it has read is kept until nothing points at it.

Benchmark

Against 6.x at 9d20aef with the file store, PHP 8.5, Apple M1 Pro. A generated site of about 2,500 entries on two sites, three taxonomies (one of 1,000 terms), 2,000 assets, and deliberately awkward content. The machine was busy while I measured; the ranges were wide, the direction was the same in every run.

Pages served from one long-lived worker, so that OPcache is holding the files in memory as PHP-FPM would (medians of 20 requests each):

File store This store
Home 487 ms 384 ms −21%
Article 397 ms 314 ms −21%
Articles index 536 ms 399 ms −26%
Tag page 429 ms 318 ms −26%
Page builder page 580 ms 441 ms −24%
A simple page 206 ms 173 ms −16%
404 225 ms 195 ms −13%

All 24 pages measured are between 13% and 33% quicker.

File store This store
Stache::warm() from nothing 5.1 s, 281 MB 3.9 s, 177 MB −24%, −37%
First query on a cold Stache 1.6 s 1.2 s −24%
Ten saves through the API 589 ms 600 ms same

Where it isn't better:

  • A one-off command without OPcache reads more slowly than with the file store, since each read compiles a PHP file, and OPcache is off on the command line by default. Warming is the exception, since it writes far more than it reads: stache:warm on a deploy is quicker with this store, as above. It can't fill PHP-FPM's OPcache from the command line, though, so the first web request to touch each file after a deploy compiles it, once.
  • A queue worker without OPcache pays in memory and on its first job. It keeps each file it has read for as long as the pointer has it, so a file is compiled once per worker and not once per job. Thirty jobs that each run two listings and a count, per job after the first: 77 ms with the file store, 56 ms with this store and OPcache off, 53 ms with opcache.enable_cli=1. The first job is slower (about 190 to 260 ms against 90 to 170 ms), and with OPcache off the worker holds what it has compiled itself: 116 MB against 48 MB here. With OPcache on in the CLI it is 13 MB against 24 MB.
  • Saves are no quicker. Level with the file store across runs (571 to 600 ms against 589 to 631 ms for ten).
  • It uses about three times the disk (140 MB against 43 MB here) and many more files (about 1,000 here).
  • It lives on one server's disk. A site that keeps its Stache in Redis so several servers share it shouldn't use this.
  • Timed with one PHP process per request and OPcache's file cache instead of shared memory, pages come out level or a little slower. That isn't how PHP-FPM runs, but it is how my benchmark's main scenarios run, which is why the page figures above come from the worker.

Checked

With the store on, against the same code with it off:

  • every page and Control Panel response, the contents of 136 indexes and the ids returned by 44 queries are byte-identical;
  • what another process sees after this one saves, and the other way round; what a queue worker's next job sees after another process saves, creates, renames or deletes an entry, or renames the page a collection is mounted on; an index first built after another process saved;
  • an Octane worker serves the same HTML as it does with the file store, including after another process saves.

Known gaps

  • Not run on a real site, or on anything larger than the benchmark's 2,500 entries.
  • A collection that grows a great deal between rebuilds keeps the number of files it was built with, so saves get gradually dearer until the next stache:warm.
  • A large site should check opcache.max_accelerated_files.
  • Two processes saving the same entry at once: both cached copies are dropped and the Stache reads the file again. Correct, but that request pays for it.
  • Holding writes until the end of the request is where both the speed and the weight come from. Most of the class is there to notice what other processes did in the meantime. I tried the simpler shape, writing straight through with a pointer file per key and no merging: it passed the same checks with about half the code, but pages were only 4% quicker than the file store, saves 43% slower and warming 20% slower, so it isn't in this branch. Whether core wants this much machinery for the gain is a fair question, and the one I'd most like an answer to.

This branch also carries

#15882 (term associations left alone when an entry's terms haven't changed) and #15883 (duplicate ids kept when the Stache is cleared and warmed in one process), so that the numbers and the checks above are of the store and not of them. They'll drop out when those merge.

Tests

CompiledStoreTest: what it keeps and where; nothing written until it persists; values kept as they were when given; forgetting and flushing; expiry; seeing another process's writes; two processes saving different entries, the same entry, different keys of an index, the same key; a second write compared with what was first seen; something another process has forgotten, whether or not it was there, kept either way; a collection built again over a different number of files; an index whose list was forgotten; what was first seen having been cleared away; garbage collection by when a file was last needed; a file read once however often what was read is forgotten; a file that has gone; entries, dates and items with their own serializing coming back as unserializing would give them.

An opt-in store, chosen by setting statamic.stache.cache_store to stache_compiled. Entries and plain values (indexes, paths, keys) are written as PHP files that return arrays, named after a hash of their contents, so OPcache serves them from shared memory and never has a stale copy. Anything else, and anything with a time to live, goes to an ordinary file store underneath.

Writes are held until the end of the request, job or command and then merged with what other processes wrote in the meantime. Where two processes changed the same entry or the same key of an index, neither copy is kept, and the Stache works it out again from the content files.
…orker

A save or delete is written as soon as the store has it, so another process never works from the old copy for longer than it would with the file store. Filling the cache is still held until the end of the request. What was read, and what was just written, is kept across writes so a process that saves many times doesn't read everything back each time.
…one process

Clearing forgot the duplicates in the cache but not the ones in memory. When the warm that followed found the same duplicate again it wasn't new, so nothing was written back, and the list stayed empty until the next clear. Every other stache:refresh lost them.
A collection's entries were always split over 32 files, so saving one entry rewrote a thirty-second of the collection: about 60 entries of a 2,000-entry collection, and more as it grew. The number of files is now settled when the collection is built, at about eight entries to a file.

A save also no longer reads everything back under the lock when no other process has written, or rewrites a value that was put back as it was.
Saving an entry took its rows out of each taxonomy's associations, added them again under new keys, and wrote the whole index, whether or not its terms had changed. On a site with a 1,000-term taxonomy used by 2,000 entries that is a couple of megabytes written per save. When the rows that are there are the rows that would be put there, they're now left as they are.
What a process first saw of a value is kept for as long as the Stache may still be working from it, so a second write in one request is compared with the right thing. An entry save writes again once its terms and structure have been dealt with. Files are cleared out by when they stopped being needed, under the lock; the lock is beside the store, so flushing leaves it; another process's writes are noticed by a generation marker; and what the pointer no longer needs is deleted only after the pointer has been replaced.

False, arrays of objects, dates, items with their own serializing, and something that expires being incremented all now come back as the file store would give them.
A value kept serialized that another process changed is not written over on a second write either. An array is looked through for objects when it is stored, however deep they are. An item that implements Serializable is left to be serialized. What tells a process that another has written is now the start of the pointer file itself, so the two can't be out of step.
… list of indexes

A process that has had one of its writes turned down for an entry, a value or anything kept serialized has every later write for it turned down too, until it starts afresh. Checking against what it first saw wasn't enough when that was nothing at all.

The list of a store's indexes is the exception to forgetting what two processes disagree on. An index the Stache doesn't know it has is never updated or cleared, so two versions of the list are combined.

An array is always looked through for objects and references when it's stored.
A value is kept as PHP or serialized, and each was compared with what was first seen on its own, so one process writing it one way while another wrote it the other went unnoticed. Disagreements are now noted under the Stache's own key for both. The duplicate ids found while building paths are combined like the list of indexes, since nothing rebuilds them once the paths are kept.
A process that worked something out before another forgot it could put it
back: forgetting left nothing to see where the thing was not there to begin
with, and a thing that had gone was taken as never written. Forgetting now
leaves a marker that is different each time, a collection built again over a
different number of files turns down what was held from before, and two
versions are no longer combined when what they started from has been cleared
away.
A marker left for every forgotten key stayed in the pointer for as long as
the key went unread, which after clearing a large site is most of them. One
of 256 shared markers is changed instead, chosen by the key, so the pointer
stays the size it was however much is forgotten.
The list of a store's indexes and the duplicate ids were put together with
nothing when another process had forgotten them, which put them back. They
are turned down now like anything else, and with the list go the indexes that
would have been found through it. Something turned down a second time marks
it again, for a process that began after the first.
A queue worker forgets what it has read before each job, so that it sees
what other processes have written, and then read every file again. Without
OPcache, which is the usual thing on the command line, that meant compiling
each one again for every job. A file never changes, so it is kept by name
and let go of once the pointer no longer has it.

This branch has not been deployed

No deployments
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.

1 participant