Skip to content

feat(warmup): order the Breeze preload list by importance - #134

Merged
parisek merged 25 commits into
mainfrom
feat/breeze-warmup-priority
Aug 24, 2026
Merged

feat(warmup): order the Breeze preload list by importance#134
parisek merged 25 commits into
mainfrom
feat/breeze-warmup-priority

Conversation

@parisek

@parisek parisek commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Breeze zpracovává preload frontu striktně od začátku, tempem zhruba sekunda na URL. Pozice v poli tedy přímo určuje, jak dlouho po vychladnutí cache dostane návštěvník stránku ze studena. Dosud o tom rozhodovalo pořadí, v jakém URL vyplivl generátor sitemapy.

Tenhle PR pořadí řídí podle důležitosti stránky — a tím mění strop z hrubého ořezu na vědomý výběr.

Jak to funguje

Skóre se počítá ve studené cestě (odložená cron obnova sitemapy) a do wp_options jde hotové seřazené pole. Horká cesta — filtr breeze_preload_urls, který běží synchronně v purge requestu, zatímco redaktor čeká na odpověď — jen přečte uložený seznam a pozičně ho sloučí se seznamem od Breeze. Její náklad neroste s velikostí sitemapy.

Skóre je součet, ne maximum, aby čerstvá stránka v menu předběhla stránku v menu, na kterou rok nikdo nesáhl:

Složka Výchozí
homepage každého aktivního jazyka 1000
ruční seznam z administrace Breeze 800
položka libovolného menu 500
váha per post type 0 (mapa types)
čerstvost z <lastmod> 300 / 200 / 100 / 25 podle stáří

Remízu rozhoduje původní pořadí ze sitemapy, takže datové řazení AIOSEO zůstává jako poslední instance zdarma.

Tři věci, které je potřeba vědět předem

<lastmod> je datum úpravy, ne publikace. Deset let stará stránka s opravou překlepu se počítá jako čerstvá. Signál je zadarmo v XML, které stejně parsujeme; dotahovat skutečná data publikace by znamenalo párovat URL na příspěvky přes WPML a vlastní permalinky.

Strop není celá cena. timberkit_warmup_sitemap_max_urls (výchozí 200) platí jen na URL ze sitemapy. Položky od Breeze se nahřejí navrch a homepage i menu každého jazyka jsou garantované, i když strop překročí — je měkký záměrně.

Nahřát stránku, kterou nikdo nenavštíví během TTL cache, je čistý odpad. Praktické měřítko pro nastavení stropu: kolik URL dostalo včera aspoň jedno zobrazení.

Zapnutí

Vypnuté ve výchozím stavu, jako každá nová funkce v tomhle balíčku.

protected bool $breeze_warmup_sitemap  = true;
protected bool $breeze_warmup_priority = true;

protected array $breeze_warmup_priority_weights = array(
    'types' => array( 'realizace' => 150 ),
);

Invalidace

Uložené pořadí přestane být pravdivé ze tří důvodů, a každý má svou cestu:

  1. Vypršelo CACHE_TTL (hodina) — dosavadní chování.
  2. Změnily se váhy (deploy) — otisk vah se počítá jednou při registraci a při purge stojí jedno porovnání řetězců.
  3. Změnilo se menuwp_update_nav_menu na prioritě 5 přeskládá pořadí na místě z uložených signálů, bez síťového požadavku, ještě než Breeze a kit na prioritě 10 spustí purge.

Cron obnova a přeskládání při změně menu píší do stejného option řádku, takže přibyl optimistický zámek přes revizi: zapisovatel, jehož snímek mezitím zestárl, se zahodí místo aby přepsal čerstvá data. ADR-0006 popisuje, proti kterému prokládání to chrání a proti kterému ne.

Navíc

Site Health check preload_chain_healthy hlásí zaseknutý preload chain. Dnes se ten výpadek nijak neprojeví — web jen tiše zůstane studený.

Změna chování

fetchSitemapUrls() nově dedupuje podle kanonického tvaru URL, ne přesného řetězce. Dvě psaní téže stránky (lomítko na konci, velikost písmen ve schématu, výchozí port, fragment) se sloučí a vítězí první nalezené. Tatáž stránka se tak přestane počítat do stropu dvakrát.

Tvar uloženého payloadu se rozšířil o signály, otisk vah a revizi. Migrace není potřeba — starší řádek se čte jako zastaralý a naplánuje se obnova.

Ověření

  • composer test — 1622 testů, 1 skipped, 0 selhání
  • composer test:property — 18 testů, 15 703 asercí (invariant: řazení je permutace vstupu)
  • composer phpstan — bez chyb
  • composer adr — 6 ADR, všechny v indexu

Šest nových tříd v Parisek\TimberKit\BreezeWarmup, z toho čtyři čisté funkce bez jediného volání WordPressu — proto se dají testovat bez Brain\Monkey a property test splňuje izolační konvenci z AGENTS.md.

Známá omezení

  • WPML v režimu doména-na-jazyk: cizí hostitel neprojde naší kontrolou ani preload_url() v Breeze. Každá doména se nahřívá sama ve svém purge requestu — korektní výsledek, ne chyba.
  • Filtr vah musí být čistá funkce. Jeho výsledek se otiskuje a porovnává napříč requesty; callback, který čte měnící se stav, by plánoval obnovu při každém purge. Zdokumentováno u weights().
  • Časové údaje o Breeze (sekunda na URL, 24h TTL) pocházejí z verze 2.5.11 mimo tenhle repozitář a odsud se ověřit nedají.

parisek added 25 commits August 24, 2026 14:29
score() re-read $record['lastmod'] directly after already guarding it with
isset(), so a record with no lastmod key at all raised an E_WARNING that
PHPUnit turns into a failure. Pass the guarded local through instead.

Also drop the $now = 0 default: a caller who forgets it silently loses the
whole freshness contribution with no signal. $now is now required, and
freshness always participates in the sum.
…rd score read

Add coverage for the leftover-slot branch in selectByLanguage() (previously
untested — all six original fixtures divided evenly), plus a cap-exceeds-
candidates case. Guard the score comparator with ?? 0 to match the existing
lang guard, so an unscored record sorts last instead of raising an
Undefined array key warning.
- Pair #[RunInSeparateProcess] with #[PreserveGlobalState(false)] on the
  SignalCollector manualKeys tests, matching the existing convention.
- Isolate BreezeWarmupSitemapSetupTest's "Breeze absent" test the same
  way, since its function_exists('breeze_get_option') check is
  unreliable whenever any other test in the run mocks that function.
- menuKeys() now skips menu items that are not absolute http(s) URLs
  (fragments, mailto: links), so they cannot become junk keys that can
  never join with a sitemap record.
- Add missing coverage for activeLanguages() and the all-foreign
  frontPages() fallback branch.
Memoize UrlCanonicalizer::canonicalize() per distinct input URL instead of
recomputing it up to four times per existing entry and three times per
ordered entry. Ordered can hold up to the store's cap (1000 URLs on one
fleet site) and this runs synchronously inside the purge request. No
observable behaviour change — existing MergeUrlsTest/FilterPreloadUrlsTest
pass unedited. Also corrects PriorityStore::readUrls()'s docblock, which
still described itself as serving only the legacy/ordering-off path; it
now supplies the URL list on both branches of filterPreloadUrls().
register() previously fingerprinted self::$weights (unfiltered), while
runRefresh() and rescoreOnMenuUpdate() fingerprint self::weights()
(filtered). On any project using timberkit_warmup_priority_weights the
two hashes could never agree, so weightsChanged() reported a mismatch
on every purge and scheduled a needless sitemap refresh forever.

Both sides now hash self::weights(). Also drops the over-specified
->once() filter-call-count assertion in
test_the_filter_wins_over_the_declared_weights (an implementation
detail, not a contract) and adds a test pinning the actual contract:
the hash computed at registration must equal the hash a refresh write
would store.
rescoreOnMenuUpdate() runs synchronously in wp_update_nav_menu at
priority 5, so an exception from any of its collaborators would fatal
the editor's Save request. Wrap it the same way runRefresh() already
is, minus the finally: this path holds no lock.

Also document that the timberkit_warmup_priority_weights filter must
be pure, since its result is fingerprinted across requests to detect
staleness.
@parisek parisek self-assigned this Aug 24, 2026
@parisek
parisek merged commit 748f1cc into main Aug 24, 2026
6 checks passed
@parisek
parisek deleted the feat/breeze-warmup-priority branch August 24, 2026 15:00
parisek added a commit that referenced this pull request Aug 24, 2026
Cuts 1.39.0 over the two Breeze warmup changes already on `main`: #134
(order the preload list by importance) and #137 (move Breeze code under
`src/Breeze`).

No code changes — this only stamps the `[Unreleased]` section with a
version and a date, the same shape as 9766ccc did for 1.38.0.

## Why now

The ordering in #134 is the difference between the feature working and
not working, measured on a real site rather than argued. On `sloneek`
(2873 sitemap URLs, five languages, 10 sub-sitemaps), the pre-#134
behaviour spends the whole 200-URL cap inside the first sub-sitemap:

| Page | before #134 | with #134 |
| --- | --- | --- |
| `/blog/` | 261 — missed | **17** |
| `/sk/`, `/it/`, `/pl/` blog | 262–264 — missed | **18–20** |
| 53 category archives | unreachable (last sub-sitemap) | **27–30** |
| `/pricing/` | 64 | 7 |

Those are the site's most expensive pages: 4.0–4.4 s cold TTFB against
0.07 s warm. Without ordering, the cap warmed 200 cheap pages and left
every expensive one cold — which is why the flag read as pointless
before this landed.

Simulated by applying the weights `StarterBase` declares on `main` to
the site's real sitemap, `lastmod` dates, 262 menu targets and Breeze's
own manual list.

## One thing the measurement also showed

Category archives score only through Breeze's **manual list** (weight
800). They are in no menu, are not a homepage, and their `lastmod` is
old — so with the manual list absent they fall to position ~2864 of
2873. That list lives in a `wp_options` row, so a database import wipes
it.

Not a blocker for this release, and not something #134 claims to solve.
Worth recording as the gap: a code-side curated list
(`$breeze_warmup_urls`, scored like `manual`) would be the missing link,
since the kit currently has no property or filter for explicit URLs —
`SignalCollector::manualKeys()` reads the admin row.

## Verification

`main` is green (PHP 8.3, PHP 8.4, PHPStan, composer hygiene). Locally:
1627 tests, 2707 assertions, 1 skipped.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01JMYy6JHLf4mU4H4Hd47spb
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