Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b92b90c
feat(warmup): one canonical URL shape to join signals on
parisek Aug 24, 2026
b8a71c2
feat(warmup): derive post type and language from sitemap provenance
parisek Aug 24, 2026
0a2320a
fix(warmup): don't guess AIOSEO archive indexes as post types, lowerc…
parisek Aug 24, 2026
53314d1
feat(warmup): score a sitemap record and order by it, stably
parisek Aug 24, 2026
d822d84
fix(warmup): guard the undefined-key warning and require $now in score()
parisek Aug 24, 2026
3f0543e
feat(warmup): divide the URL budget between languages, guarantees first
parisek Aug 24, 2026
d7b033b
test(warmup): cover leftover redistribution and unscored records; gua…
parisek Aug 24, 2026
ced4684
feat(warmup): carry lastmod and provenance through the sitemap crawl
parisek Aug 24, 2026
5fad0e5
docs(warmup): state that sitemap URLs dedup on canonical form
parisek Aug 24, 2026
810a6de
feat(warmup): collect menu, per-language homepage and manual signals
parisek Aug 24, 2026
fce5d64
fix(warmup): harden test isolation and reject non-page menu URLs
parisek Aug 24, 2026
3e83fc2
feat(warmup): guard the ordering row with an optimistic revision
parisek Aug 24, 2026
08a3fc0
docs(warmup): state which write race the revision guard does and does…
parisek Aug 24, 2026
1f8682d
feat(warmup): score and order the sitemap during the deferred refresh
parisek Aug 24, 2026
bcce0f5
feat(warmup): merge the ordered list positionally, on canonical keys
parisek Aug 24, 2026
bafd883
fix(warmup): canonicalize each mergeUrls() input URL exactly once
parisek Aug 24, 2026
401e942
feat(warmup): rescore in place when a menu changes, before the purge
parisek Aug 24, 2026
4ccf470
feat(starter-base): opt into warmup ordering with declarative weights
parisek Aug 24, 2026
f047e5b
fix(warmup): hash filtered weights at registration, not raw
parisek Aug 24, 2026
d5948e9
feat(health): report a stalled Breeze preload chain
parisek Aug 24, 2026
ee8ac72
fix(health): never-warmed preload chain, pin stall boundary, explain …
parisek Aug 24, 2026
2eec282
test(warmup): pin that ordering is a permutation of its input
parisek Aug 24, 2026
0a4ee1a
docs(warmup): document the ordering, its price and the decision behin…
parisek Aug 24, 2026
4fa2d77
docs(warmup): disambiguate TTL, cover future lastmod and per-type wei…
parisek Aug 24, 2026
d68b6e2
fix(breeze-warmup): swallow exceptions in the menu-save rescore hook
parisek Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

### Added

- `$breeze_warmup_priority` — order the Breeze warmup list by importance
(per-language homepages, Breeze's own manual list, menu membership, post
type and `<lastmod>` freshness) instead of leaving it in sitemap order.
Weights are declared in `$breeze_warmup_priority_weights` and filterable via
`timberkit_warmup_priority_weights`. Off by default.
- Site Health check `preload_chain_healthy` — reports a Breeze preload chain
that has stopped making progress.

### Changed

- The warmup option row now stores the ordered list, the signals behind it, a
weight fingerprint and a revision counter. A row written by an earlier
version reads as stale and is refreshed; no migration is needed.
- `fetchSitemapUrls()` now deduplicates by canonical URL form rather than
exact string, so two spellings of the same page (differing in trailing
slash, scheme case, default port or fragment) collapse to one, first-seen
spelling winning.

## [1.38.0] - 2026-08-20

### Added
Expand Down
107 changes: 107 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,113 @@ The class discovers the sitemap (AIOSEO's `/sitemap.xml` when active, otherwise
core's `/wp-sitemap.xml`), follows a sitemap index recursively within bounded
limits, and merges the result in.

`fetchSitemapUrls()` deduplicates by **canonical URL form**, not by exact
string: two spellings of the same page — differing only in trailing slash,
scheme case, default port, or fragment — collapse to one entry, and the
first-seen spelling wins. Warming the same page twice under two spellings
would waste a slot of the URL cap.

### Ordering the warmup list by importance

By default the sitemap-sourced URLs are merged in whatever order the sitemap
generator emitted them, and Breeze warms its queue strictly front to back —
so that order decides how long after a purge a page stays cold.
`$breeze_warmup_priority` (default `false`) replaces it with a computed
ordering, scored during the deferred refresh and stored ready to serve, so
the purge-time filter still pays no cost that grows with the sitemap's size.
Requires `$breeze_warmup_sitemap` — on its own it has nothing to order.

```php
class Base extends StarterBase {
public function __construct() {
$this->breeze_warmup_sitemap = true;
$this->breeze_warmup_priority = true;

parent::__construct();
}
}
```

Score is a sum of independent signals, so a fresh page in a menu outranks a
menu page nobody has touched in a year:

| Signal | Default weight | Notes |
| --- | --- | --- |
| `front_page` | 1000 | A language's homepage. |
| `manual` | 800 | Already in Breeze's own preload list. |
| `menu` | 500 | Linked from a registered nav menu. |
| `types` | `[]` | Per-post-type points, keyed by post type slug. Empty by default. |
| `freshness` | `2 => 300, 7 => 200, 30 => 100, 365 => 25` | Points by `<lastmod>` age in days, ascending buckets. Anything older, missing, unparseable, or **in the future** scores 0. |

Override the map wholesale in `$breeze_warmup_priority_weights`, or adjust it
per project with the `timberkit_warmup_priority_weights` filter (runs once,
at registration — not per purge).

A future `<lastmod>` scores 0 rather than the top freshness bucket: scheduled
content is not fresh content, and a broken `lastmod` must never be able to
shoot a URL to the front of the queue.

`types` is keyed by post type slug, for example:

```php
class Base extends StarterBase {
public function __construct() {
$this->breeze_warmup_priority_weights = array(
'front_page' => 1000,
'manual' => 800,
'menu' => 500,
'types' => array( 'realizace' => 150 ),
'freshness' => array( 2 => 300, 7 => 200, 30 => 100, 365 => 25 ),
);

parent::__construct();
}
}
```

The post type is derived from the **sub-sitemap filename**, not looked up in
the database: `wp-sitemap-posts-<type>-N.xml` for core,
`<type>-sitemap.xml` for AIOSEO. A URL whose sub-sitemap doesn't match either
shape gets no type, so its `types` weight is 0. AIOSEO's structural indexes
(`author`, `date`, `product_attributes`, `rss`, `additional`) are excluded on
purpose — they share the `<name>-sitemap.xml` shape but aren't post types. A
taxonomy sitemap can't be told apart from a post-type one by filename either,
so it falls through to weight 0 as well. If a `types` weight seems to have no
effect, check the sub-sitemap's filename first.

Three things worth knowing before this ships to a real sitemap:

**Freshness reads `<lastmod>`, which is the date of the last edit, not of
publication.** A ten-year-old page with a fixed typo therefore ranks as
fresh. The signal is free — it is already in the XML we parse — and
resolving real publication dates would mean matching URLs back to posts
through WPML and custom permalinks.

**The cap is not the whole cost.** `timberkit_warmup_sitemap_max_urls`
(default 200) applies only to sitemap-sourced URLs. Entries Breeze itself
supplies are warmed on top of it, and every language's homepage and menu
items are guaranteed even when that pushes the total over the cap — the cap
is soft by design. Budget roughly 200 origin renders and about three and a
half minutes of warming, plus Breeze's own entries, plus any guarantee
overflow.

**Warming a page nobody visits within the cache TTL (24 hours by default) is
wasted work** — the cache expires before the visitor arrives. That 24 hours
is Breeze's own page-cache expiry setting; it is unrelated to this class's
`CACHE_TTL` (one hour), which governs a different clock — how long the
stored URL list itself is trusted before a refresh is scheduled. A practical
way to size the cap: count how many URLs got at least one pageview
yesterday; that number is the cap.

### Preload chain health

The Site Health check `preload_chain_healthy` (category `caching`, needs
`$site_health`) watches Breeze's own preload queue for silent stalls. Breeze
drives that queue through an Action Scheduler loopback; when the loopback
can't reach the site, the queue simply stops advancing and nothing anywhere
reports it. The check flags a queue that hasn't made progress in the last
minute.

---

## Usage
Expand Down
64 changes: 64 additions & 0 deletions docs/adr/0006-warmup-priority-precomputed-at-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 0006. Precompute warmup priority at refresh, never at purge

## Context

Breeze processes its preload queue strictly front to back, at roughly a
second per URL. Position in the array is the schedule: whatever sits at
index 0 is warm within a second of the purge, and whatever sits near the end
may stay cold for minutes. `BreezeWarmupSitemap` already merges thousands of
sitemap-sourced URLs into that queue, and the merge runs inside the
`breeze_preload_urls` filter — which fires **synchronously inside the purge
request**, the same request an editor is sitting in front of after clicking
Save.

A sitemap can hold thousands of URLs on a real site. Anything this filter
does costs that editor wall-clock time, once per purge, for as long as the
site exists. That rules out any per-purge step whose cost scales with the
sitemap's size — sorting by a computed score chief among them.

## Decision

Ordering is computed once, during the existing deferred refresh job, and
stored as a finished list. The purge-time filter only reads that list and
splices it against Breeze's own entries positionally — homepage, then
Breeze's unscored entries, then the stored ordering — a cost independent of
how many URLs the sitemap holds.

Two alternatives were rejected:

- **Sort at purge time.** Cheapest to build, but it is exactly the cost this
decision exists to avoid: an O(n log n) sort of a sitemap-sized list,
paid synchronously by a user waiting on the request.
- **Drop menu membership as a signal.** Menu membership is the strongest
cheap signal of importance available — a human deliberately linked that
page from every page of the site — and it costs one option read during the
refresh. Dropping it to simplify the hot path would have thrown away a free
signal to protect a path that was never going to use it anyway.

## Consequences

The ordering is only ever as fresh as the last refresh, not as fresh as the
last purge. That forced three separate invalidation paths, because no single
one covers every way the ordering can go stale: a TTL for the ordinary case
of time passing, a weight fingerprint so a config change (a deploy changing
`$breeze_warmup_priority_weights`, or the `timberkit_warmup_priority_weights`
filter) is noticed without polling, and an in-place rescore on
`wp_update_nav_menu` so a menu edit — the single fastest way to invalidate
the strongest signal — doesn't wait for the TTL to catch up.

Two writers now share one `wp_options` row: the cron refresh and the
menu-change rescore. WordPress options are last-write-wins, so a slow cron
refresh that started before a menu edit and finishes after it could silently
overwrite the newer ordering with a stale one. A revision counter guards the
case that actually happens in practice — the refresh re-reads the revision
immediately before writing and discards its result if it has moved. It does
not guard two writers landing in the same instant; closing that would need a
conditional `UPDATE` matched against the serialized option value, which is
disproportionate to a race between an hourly cron job and a human saving a
menu.

The cap became soft. Every language's homepage and every menu item are
guaranteed a slot even when that pushes the total past
`timberkit_warmup_sitemap_max_urls` — a language losing its homepage from the
warmup list is a worse outcome than a cap overrun bounded by the number of
menu items.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ guard (test, CI check, convention) that keeps it from drifting, if any.
- [0003](0003-dev-media-origin-env-and-self-host-guard.md) — Dev-media origin via env, with a self-host guard
- [0004](0004-image-downscaling-via-core-threshold.md) — Drive downscaling through core's threshold; never delete originals on upload
- [0005](0005-first-party-gtm-container.md) — Load the GTM container from the kit, configured in code
- [0006](0006-warmup-priority-precomputed-at-refresh.md) — Precompute warmup priority at refresh, never at purge
117 changes: 117 additions & 0 deletions src/BreezeWarmup/LanguageQuota.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

namespace Parisek\TimberKit\BreezeWarmup;

/**
* Divides the URL budget between languages.
*
* The cap is **divided**, never multiplied: without this a trilingual site
* would quietly triple the number of origin renders one purge costs.
*
* Homepages and menu items of every language are guaranteed and may push the
* total **over** the cap. That is the deliberate trade: a language losing its
* homepage from the warmup list is a worse outcome than a soft cap, and the
* overflow is bounded by the number of menu items, not by the sitemap.
*
* Selection only — ordering is the Scorer's job and must not change here.
*/
final class LanguageQuota {

/**
* @param array<int, array<string, mixed>> $records Scored records.
* @param int $max Soft cap on total URLs.
* @return array<int, array<string, mixed>> Selected records, input order preserved.
*/
public static function apply( array $records, int $max ): array {
$max = max( 0, $max );
$keep = array();
$dropped = array();

foreach ( $records as $i => $record ) {
if ( ! empty( $record['front_page'] ) || ! empty( $record['menu'] ) ) {
$keep[ $i ] = true;
} else {
$dropped[] = $i;
}
}

$budget = max( 0, $max - count( $keep ) );
if ( $budget > 0 && array() !== $dropped ) {
foreach ( self::selectByLanguage( $records, $dropped, $budget ) as $i ) {
$keep[ $i ] = true;
}
}

$result = array();
foreach ( $records as $i => $record ) {
if ( isset( $keep[ $i ] ) ) {
$result[] = $record;
}
}

return $result;
}

/**
* Split the remaining budget across languages in proportion to how many
* optional URLs each has, then take that many best-scoring ones.
*
* Proportional rather than equal: on a site where 90 percent of the
* content is Czech, an equal split would hand English a third of the
* budget for nothing.
*
* @param array<int, array<string, mixed>> $records
* @param array<int, int> $candidates Indexes eligible for selection.
* @param int $budget
* @return array<int, int> Selected indexes.
*/
private static function selectByLanguage( array $records, array $candidates, int $budget ): array {
$byLang = array();
foreach ( $candidates as $i ) {
$lang = isset( $records[ $i ]['lang'] ) ? (string) $records[ $i ]['lang'] : '';
$byLang[ $lang ] = $byLang[ $lang ] ?? array();
$byLang[ $lang ][] = $i;
}

$total = count( $candidates );
$selected = array();
$assigned = 0;

// Largest-remainder is overkill here; floor each share and hand any
// leftover slots to the languages with the most candidates, so no
// slot is wasted to rounding.
$quotas = array();
foreach ( $byLang as $lang => $indexes ) {
$quotas[ $lang ] = (int) floor( $budget * count( $indexes ) / $total );
$assigned += $quotas[ $lang ];
}

$leftover = $budget - $assigned;
if ( $leftover > 0 ) {
uasort( $byLang, static fn( array $a, array $b ): int => count( $b ) <=> count( $a ) );
foreach ( array_keys( $byLang ) as $lang ) {
if ( $leftover <= 0 ) {
break;
}
++$quotas[ $lang ];
--$leftover;
}
}

foreach ( $byLang as $lang => $indexes ) {
// Guard the read: a record that never went through the Scorer has no
// 'score' key, and it should sort last rather than warn.
usort(
$indexes,
static fn( int $a, int $b ): int => ( (int) ( $records[ $b ]['score'] ?? 0 ) ) <=> ( (int) ( $records[ $a ]['score'] ?? 0 ) )
);
foreach ( array_slice( $indexes, 0, $quotas[ $lang ] ) as $i ) {
$selected[] = $i;
}
}

return $selected;
}
}
Loading
Loading