Skip to content

[6.x] Work out an entry's original state only when it's asked for - #15842

Open
DanielDarrenJones wants to merge 1 commit into
statamic:6.xfrom
DanielDarrenJones:perf/6x-lazy-dirty-state
Open

DanielDarrenJones wants to merge 1 commit into
statamic:6.xfrom
DanielDarrenJones:perf/6x-lazy-dirty-state

Conversation

@DanielDarrenJones

@DanielDarrenJones DanielDarrenJones commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

BasicStore::getItem() calls syncOriginal() on every entry it reads from the Stache, so that isDirty() and getOriginal() can answer later. For an entry that builds an array of its collection, locale, origin, slug, date, published state, path and all its data, on every read. Almost no entry read on a request is ever asked.

Entry::syncOriginal() now keeps a copy of the entry as it is at that moment (clone $this, which already copies its data and supplements, plus its own copy of the date), and the attributes are taken from that copy the first time getOriginal(), isDirty() or __sleep() needs them. What they report is unchanged.

This is Entry only. Collection, Term, Tree, User and Asset keep working their state out straight away.

Details

  • Origins. origin() is remembered in Blink by the entry's id, so the copy would be handed the entry's current origin. While the snapshot is worked out, the copy is shown the origin it was synced with, and what was remembered is put back afterwards. the_descendants_cache_is_cleared_for_the_previous_origin_when_the_origin_changes covers this path and fails without it.
  • An origin that changes after the entry is read. Two of the attributes come from the entry's origin: origin (whether it has one) and date (inherited when the entry has none of its own). They used to be read from the origin when the entry was synced, and are now read when the snapshot is first asked for. So if, in the same request, an entry is read, then its origin's date is changed or the origin is deleted, and only after that is the entry asked for getOriginal() or isDirty(), those two attributes show the origin as it is by then. I haven't found anything in core that does things in that order, and the test suite passes, but I haven't proved nothing does. Capturing them at sync time would mean looking the origin up for every localised entry that's read, which is most of what this saves on a multisite.
  • Serialization. __sleep() works the snapshot out first and leaves the copy out, so a serialized entry is what it was before.
  • Subclasses. An overridden getCurrentDirtyStateAttributes() is still what produces the snapshot; it's called on the copy. The Eloquent driver's Entry::fromModel() ends with syncOriginal(), so it gets the same saving.

Benchmark

Reading entries through Entry::query()->where('collection', ...)->get(), with and without this change, run alternately (Apple M1 Pro, PHP 8.5).

A small site (single site, 100 to 319 entries per collection, Statamic 6.40; median of 5 processes):

Entries read Entry size Before After Saved
10 small 0.52 ms 0.39 ms 0.13 ms
25 small 1.31 ms 1.01 ms 0.30 ms
100 small 5.40 ms 4.13 ms 1.28 ms
25 medium 1.58 ms 1.17 ms 0.41 ms
100 medium 6.49 ms 5.37 ms 1.12 ms
25 page builder 2.85 ms 2.60 ms 0.25 ms
100 page builder 13.11 ms 11.64 ms 1.46 ms
300 small 15.69 ms 12.65 ms 3.04 ms

That's 10 to 15 µs per entry: 10 to 25% of the time spent reading entries, but under 1% of a page that takes 50 to 100 ms. Whole page loads on this site are within noise.

A large multisite (9,000 entries, 3,000 of them translations, nested page builder, Statamic 6.x; median of 6 alternating runs):

Reading all 9,000 Before After
Per entry 158 µs 86 µs (−45%)
Peak memory 776 MB 630 MB (−19%)

The ranges don't overlap (151 to 160 µs against 79 to 96 µs). The saving per entry is larger here because the snapshot costs more for translated, dated, larger entries: it looks up the origin, formats the date and copies more data. A cold stache:warm on this site showed no clear change (29.4 s against 28.1 s, with overlapping ranges).

So this is a steady saving on every entry read. It doesn't show on a small site, and it adds up where a request reads hundreds or thousands of entries (large listings and navs, sitemaps, feeds, exports), most of all on multisites.

Tests

Four new tests in EntryTest:

  • the snapshot isn't worked out by syncOriginal(), and getOriginal() takes it from the copy;
  • the original values are those at the time of syncing after changes through set(), data()->put(), slug(), published() and a date modified in place;
  • an origin changed or removed after syncing: the original origin and the date inherited from it are the old ones, and the current origin is left as it is;
  • serializing and cloning keep the original state.

The full suite passes on PHP 8.4 (11,611 passed, 12 skipped).

@DanielDarrenJones
DanielDarrenJones marked this pull request as ready for review November 10, 2026 21:11

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