From cb398cc452451c19ca3aaebf88581372f8c4f04c Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 18:50:18 +0200 Subject: [PATCH 01/15] feat(warmup): work out what the cap excluded, and how to slice it --- src/Breeze/TailPlanner.php | 76 +++++++++++++ tests/Unit/Breeze/TailPlannerTest.php | 153 ++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 src/Breeze/TailPlanner.php create mode 100644 tests/Unit/Breeze/TailPlannerTest.php diff --git a/src/Breeze/TailPlanner.php b/src/Breeze/TailPlanner.php new file mode 100644 index 0000000..16e1cec --- /dev/null +++ b/src/Breeze/TailPlanner.php @@ -0,0 +1,76 @@ +> $scored Already-sorted scored records. + * @param array $keptUrls URLs that made it under the cap. + * @param int $maxTail Upper bound on the result. + * @return array + */ + public static function split( array $scored, array $keptUrls, int $maxTail ): array { + $kept = array_fill_keys( $keptUrls, true ); + $tail = array(); + + foreach ( $scored as $record ) { + if ( ! isset( $record['url'] ) || ! is_string( $record['url'] ) || '' === $record['url'] ) { + continue; + } + if ( isset( $kept[ $record['url'] ] ) ) { + continue; + } + + $tail[] = $record['url']; + } + + return array_slice( $tail, 0, max( 0, $maxTail ) ); + } + + /** + * Fingerprint of a tail, used to invalidate a cursor that points into a + * different plan. Order participates: a reordered tail is a new plan. + * + * @param array $urls + * @return string + */ + public static function hash( array $urls ): string { + return md5( (string) json_encode( array_values( $urls ) ) ); + } + + /** + * One batch, starting at the cursor. + * + * A negative index is clamped to zero rather than passed to array_slice(), + * which would read from the end of the array — a corrupted cursor must not + * silently warm the wrong pages. + * + * @param array $urls + * @param int $index + * @param int $batch + * @return array + */ + public static function nextBatch( array $urls, int $index, int $batch ): array { + return array_slice( $urls, max( 0, $index ), max( 0, $batch ) ); + } +} diff --git a/tests/Unit/Breeze/TailPlannerTest.php b/tests/Unit/Breeze/TailPlannerTest.php new file mode 100644 index 0000000..a972fad --- /dev/null +++ b/tests/Unit/Breeze/TailPlannerTest.php @@ -0,0 +1,153 @@ + $pairs + * @return array> + */ + private function scored( array $pairs ): array { + $records = array(); + foreach ( $pairs as $pair ) { + $records[] = array( 'url' => $pair['url'], 'score' => $pair['score'] ); + } + + return $records; + } + + // -- split -------------------------------------------------------------- + + public function test_tail_is_what_the_cap_excluded(): void { + $scored = $this->scored( + array( + array( 'url' => 'https://example.test/a/', 'score' => 900 ), + array( 'url' => 'https://example.test/b/', 'score' => 500 ), + array( 'url' => 'https://example.test/c/', 'score' => 100 ), + ) + ); + + $tail = TailPlanner::split( $scored, array( 'https://example.test/a/' ), 100 ); + + $this->assertSame( + array( 'https://example.test/b/', 'https://example.test/c/' ), + $tail + ); + } + + public function test_tail_keeps_the_order_it_was_given(): void { + // split() never sorts. The caller hands it an already-sorted set, and + // the tail's order IS the feature's promise — the most valuable pages + // are warmed first even when the run is cut short. + $scored = $this->scored( + array( + array( 'url' => 'https://example.test/high/', 'score' => 900 ), + array( 'url' => 'https://example.test/mid/', 'score' => 500 ), + array( 'url' => 'https://example.test/low/', 'score' => 10 ), + ) + ); + + $tail = TailPlanner::split( $scored, array(), 100 ); + + $this->assertSame( + array( 'https://example.test/high/', 'https://example.test/mid/', 'https://example.test/low/' ), + $tail + ); + } + + public function test_everything_kept_leaves_an_empty_tail(): void { + $scored = $this->scored( array( array( 'url' => 'https://example.test/a/', 'score' => 1 ) ) ); + + $this->assertSame( array(), TailPlanner::split( $scored, array( 'https://example.test/a/' ), 100 ) ); + } + + public function test_tail_is_capped(): void { + $pairs = array(); + for ( $i = 0; $i < 10; $i++ ) { + $pairs[] = array( 'url' => 'https://example.test/' . $i . '/', 'score' => 10 - $i ); + } + + $tail = TailPlanner::split( $this->scored( $pairs ), array(), 4 ); + + $this->assertCount( 4, $tail ); + $this->assertSame( 'https://example.test/0/', $tail[0], 'the cap trims the end, not the front' ); + } + + public function test_records_without_a_url_are_skipped(): void { + $scored = array( + array( 'score' => 5 ), + array( 'url' => 'https://example.test/a/', 'score' => 1 ), + ); + + $this->assertSame( array( 'https://example.test/a/' ), TailPlanner::split( $scored, array(), 100 ) ); + } + + // -- hash --------------------------------------------------------------- + + public function test_hash_is_stable_for_the_same_urls(): void { + $urls = array( 'https://example.test/a/', 'https://example.test/b/' ); + + $this->assertSame( TailPlanner::hash( $urls ), TailPlanner::hash( $urls ) ); + } + + public function test_hash_changes_when_a_url_changes(): void { + $a = array( 'https://example.test/a/' ); + $b = array( 'https://example.test/b/' ); + + $this->assertNotSame( TailPlanner::hash( $a ), TailPlanner::hash( $b ) ); + } + + public function test_hash_changes_when_order_changes(): void { + // Order is meaning here: a reordered tail is a different plan, and the + // cursor pointing into it must be invalidated. + $a = array( 'https://example.test/a/', 'https://example.test/b/' ); + $b = array( 'https://example.test/b/', 'https://example.test/a/' ); + + $this->assertNotSame( TailPlanner::hash( $a ), TailPlanner::hash( $b ) ); + } + + public function test_empty_tail_hashes_without_error(): void { + $this->assertNotSame( '', TailPlanner::hash( array() ) ); + } + + // -- nextBatch ---------------------------------------------------------- + + public function test_batch_starts_at_the_index(): void { + $urls = array( 'a', 'b', 'c', 'd', 'e' ); + + $this->assertSame( array( 'c', 'd' ), TailPlanner::nextBatch( $urls, 2, 2 ) ); + } + + public function test_last_batch_may_be_short(): void { + $urls = array( 'a', 'b', 'c' ); + + $this->assertSame( array( 'c' ), TailPlanner::nextBatch( $urls, 2, 10 ) ); + } + + public function test_index_past_the_end_yields_nothing(): void { + $this->assertSame( array(), TailPlanner::nextBatch( array( 'a' ), 5, 10 ) ); + } + + public function test_negative_index_reads_from_the_start(): void { + // A corrupted cursor must not silently read from the end of the array, + // which is what a raw array_slice() with a negative offset would do. + $this->assertSame( array( 'a' ), TailPlanner::nextBatch( array( 'a', 'b' ), -3, 1 ) ); + } + + public function test_batch_of_zero_yields_nothing(): void { + $this->assertSame( array(), TailPlanner::nextBatch( array( 'a', 'b' ), 0, 0 ) ); + } +} From 541a7cf5a4d701c92a6fef50cfd6dd3f9b9ebfa0 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 18:53:46 +0200 Subject: [PATCH 02/15] fix(warmup): guard the tail's no-sort promise and fingerprint unencodable tails --- src/Breeze/TailPlanner.php | 14 ++++++++++- tests/Unit/Breeze/TailPlannerTest.php | 36 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Breeze/TailPlanner.php b/src/Breeze/TailPlanner.php index 16e1cec..a177a51 100644 --- a/src/Breeze/TailPlanner.php +++ b/src/Breeze/TailPlanner.php @@ -55,7 +55,19 @@ public static function split( array $scored, array $keptUrls, int $maxTail ): ar * @return string */ public static function hash( array $urls ): string { - return md5( (string) json_encode( array_values( $urls ) ) ); + $urls = array_values( $urls ); + $encoded = json_encode( $urls ); + + // json_encode() returns false on invalid UTF-8. Falling through to '' + // would hash every unencodable tail to the same md5(''), making the + // cursor believe nothing changed. The concatenation is a fallback, + // not the primary encoding: it still varies with content and order, + // which is all a fingerprint needs. + if ( false === $encoded ) { + $encoded = implode( "\n", $urls ); + } + + return md5( $encoded ); } /** diff --git a/tests/Unit/Breeze/TailPlannerTest.php b/tests/Unit/Breeze/TailPlannerTest.php index a972fad..cb63e6d 100644 --- a/tests/Unit/Breeze/TailPlannerTest.php +++ b/tests/Unit/Breeze/TailPlannerTest.php @@ -95,6 +95,30 @@ public function test_records_without_a_url_are_skipped(): void { $this->assertSame( array( 'https://example.test/a/' ), TailPlanner::split( $scored, array(), 100 ) ); } + public function test_split_does_not_sort_even_when_the_input_is_out_of_score_order(): void { + // Guards the class's central contract: split() never sorts, the caller + // hands it an already-ordered set. An implementation that "helpfully" + // sorted by score before returning would duplicate work the caller + // already did, contradict the class docblock, and every other fixture + // in this file — all of which happen to be score-descending already — + // would still pass. Feeding an out-of-order input is what actually + // exercises the promise. + $scored = $this->scored( + array( + array( 'url' => 'https://example.test/low/', 'score' => 10 ), + array( 'url' => 'https://example.test/high/', 'score' => 900 ), + array( 'url' => 'https://example.test/mid/', 'score' => 500 ), + ) + ); + + $tail = TailPlanner::split( $scored, array(), 100 ); + + $this->assertSame( + array( 'https://example.test/low/', 'https://example.test/high/', 'https://example.test/mid/' ), + $tail + ); + } + // -- hash --------------------------------------------------------------- public function test_hash_is_stable_for_the_same_urls(): void { @@ -123,6 +147,18 @@ public function test_empty_tail_hashes_without_error(): void { $this->assertNotSame( '', TailPlanner::hash( array() ) ); } + public function test_hash_differs_for_unencodable_tails_with_different_content(): void { + // json_encode() returns false on invalid UTF-8, and (string) false is + // '' — so without a content-dependent fallback, every unencodable tail + // would collapse to md5(''), the same fingerprint regardless of what + // the tail actually contains. A change detector that returns an + // identical value for all broken inputs cannot see a change. + $a = array( 'https://example.test/' . chr( 0xB1 ) . '/' ); + $b = array( 'https://example.test/' . chr( 0xB2 ) . '/' ); + + $this->assertNotSame( TailPlanner::hash( $a ), TailPlanner::hash( $b ) ); + } + // -- nextBatch ---------------------------------------------------------- public function test_batch_starts_at_the_index(): void { From 540dde28cab2ea989568c698b8ef0c2dffaa5552 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 18:57:00 +0200 Subject: [PATCH 03/15] feat(warmup): store the tail and guard its cursor against a stale write --- src/Breeze/TailStore.php | 128 ++++++++++++++++++++++++++ tests/Unit/Breeze/TailStoreTest.php | 134 ++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/Breeze/TailStore.php create mode 100644 tests/Unit/Breeze/TailStoreTest.php diff --git a/src/Breeze/TailStore.php b/src/Breeze/TailStore.php new file mode 100644 index 0000000..de2f51c --- /dev/null +++ b/src/Breeze/TailStore.php @@ -0,0 +1,128 @@ +, hash: string} + */ + public static function readTail(): array { + $empty = array( 'urls' => array(), 'hash' => '' ); + + if ( ! function_exists( 'get_option' ) ) { + return $empty; + } + + $data = get_option( self::TAIL_OPTION, null ); + if ( ! is_array( $data ) || ! isset( $data['urls'], $data['hash'] ) || ! is_array( $data['urls'] ) ) { + return $empty; + } + + return array( + 'urls' => array_values( array_filter( $data['urls'], 'is_string' ) ), + 'hash' => (string) $data['hash'], + ); + } + + /** + * @param array $urls + * @return void + */ + public static function writeTail( array $urls ): void { + if ( ! function_exists( 'update_option' ) ) { + return; + } + + $urls = array_values( $urls ); + + update_option( + self::TAIL_OPTION, + array( + 'urls' => $urls, + 'hash' => TailPlanner::hash( $urls ), + ), + false + ); + } + + /** + * @return array{index: int, hash: string} + */ + public static function readCursor(): array { + if ( ! function_exists( 'get_option' ) ) { + return array( 'index' => 0, 'hash' => '' ); + } + + $data = get_option( self::CURSOR_OPTION, null ); + if ( ! is_array( $data ) || ! isset( $data['index'], $data['hash'] ) ) { + return array( 'index' => 0, 'hash' => '' ); + } + + return array( + 'index' => (int) $data['index'], + 'hash' => (string) $data['hash'], + ); + } + + /** + * Start the tail over. + * + * Writes an empty hash on purpose: the tick stamps the real one when it + * reads the tail anyway. Reading the tail here would drag a payload of + * thousands of URLs into the request an editor is waiting on. + * + * @return void + */ + public static function resetCursor(): void { + if ( ! function_exists( 'update_option' ) ) { + return; + } + + update_option( self::CURSOR_OPTION, array( 'index' => 0, 'hash' => '' ), false ); + } + + /** + * Advance the cursor, but only if nobody moved it since it was read. + * + * @param array{index: int, hash: string} $expected Cursor as the caller read it. + * @param int $newIndex + * @param string $hash Hash of the tail actually used. + * @return bool True when written, false when a concurrent write won. + */ + public static function advanceCursor( array $expected, int $newIndex, string $hash ): bool { + if ( ! function_exists( 'update_option' ) ) { + return false; + } + + $current = self::readCursor(); + if ( $current['index'] !== (int) $expected['index'] || $current['hash'] !== (string) $expected['hash'] ) { + return false; + } + + update_option( self::CURSOR_OPTION, array( 'index' => max( 0, $newIndex ), 'hash' => $hash ), false ); + + return true; + } +} diff --git a/tests/Unit/Breeze/TailStoreTest.php b/tests/Unit/Breeze/TailStoreTest.php new file mode 100644 index 0000000..5f06969 --- /dev/null +++ b/tests/Unit/Breeze/TailStoreTest.php @@ -0,0 +1,134 @@ +justReturn( + array( 'urls' => array( 'https://example.test/a/' ), 'hash' => 'h' ) + ); + + $tail = TailStore::readTail(); + + $this->assertSame( array( 'https://example.test/a/' ), $tail['urls'] ); + $this->assertSame( 'h', $tail['hash'] ); + } + + public function test_missing_tail_reads_as_empty(): void { + Functions\when( 'get_option' )->justReturn( null ); + + $this->assertSame( array( 'urls' => array(), 'hash' => '' ), TailStore::readTail() ); + } + + public function test_malformed_tail_reads_as_empty(): void { + Functions\when( 'get_option' )->justReturn( array( 'urls' => 'oops' ) ); + + $this->assertSame( array( 'urls' => array(), 'hash' => '' ), TailStore::readTail() ); + } + + public function test_writing_a_tail_stamps_its_hash_and_disables_autoload(): void { + $written = null; + $autoload = null; + Functions\when( 'update_option' )->alias( + function ( string $key, $value, $auto = null ) use ( &$written, &$autoload ): bool { + $written = $value; + $autoload = $auto; + + return true; + } + ); + + TailStore::writeTail( array( 'https://example.test/a/' ) ); + + $this->assertSame( array( 'https://example.test/a/' ), $written['urls'] ); + $this->assertNotSame( '', $written['hash'] ); + $this->assertFalse( $autoload, 'the tail must never autoload; it can hold thousands of URLs' ); + } + + // -- cursor ------------------------------------------------------------- + + public function test_missing_cursor_reads_as_zero(): void { + Functions\when( 'get_option' )->justReturn( null ); + + $this->assertSame( array( 'index' => 0, 'hash' => '' ), TailStore::readCursor() ); + } + + public function test_reset_writes_index_zero_without_reading_the_tail(): void { + // The purge path calls this. Reading the tail here would put a + // multi-thousand-URL payload in the request an editor is waiting on. + Functions\expect( 'get_option' )->never(); + $written = null; + Functions\when( 'update_option' )->alias( + function ( string $key, $value ) use ( &$written ): bool { + $written = $value; + + return true; + } + ); + + TailStore::resetCursor(); + + $this->assertSame( 0, $written['index'] ); + $this->assertSame( '', $written['hash'], 'the tick stamps the hash; the purge must not read the tail' ); + } + + public function test_advance_writes_when_the_cursor_is_unchanged(): void { + $current = array( 'index' => 100, 'hash' => 'h' ); + Functions\when( 'get_option' )->justReturn( $current ); + $written = null; + Functions\when( 'update_option' )->alias( + function ( string $key, $value ) use ( &$written ): bool { + $written = $value; + + return true; + } + ); + + $this->assertTrue( TailStore::advanceCursor( $current, 200, 'h' ) ); + $this->assertSame( 200, $written['index'] ); + $this->assertSame( 'h', $written['hash'] ); + } + + public function test_advance_is_discarded_when_a_purge_reset_the_cursor(): void { + // The tick read index 100, a purge reset to 0 mid-flight. Writing 200 + // would undo the reset and strand everything below it. + Functions\when( 'get_option' )->justReturn( array( 'index' => 0, 'hash' => '' ) ); + Functions\expect( 'update_option' )->never(); + + $this->assertFalse( TailStore::advanceCursor( array( 'index' => 100, 'hash' => 'h' ), 200, 'h' ) ); + } + + public function test_advance_is_discarded_when_another_tick_moved_the_cursor(): void { + Functions\when( 'get_option' )->justReturn( array( 'index' => 300, 'hash' => 'h' ) ); + Functions\expect( 'update_option' )->never(); + + $this->assertFalse( TailStore::advanceCursor( array( 'index' => 100, 'hash' => 'h' ), 200, 'h' ) ); + } +} From 912f3d3cd9a444f74e689304f2dff837a14eb991 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:00:15 +0200 Subject: [PATCH 04/15] test(warmup): pin autoload=false on the cursor writes; document TailStore's TOCTOU window --- src/Breeze/TailStore.php | 12 +++++++++++- tests/Unit/Breeze/TailStoreTest.php | 14 ++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Breeze/TailStore.php b/src/Breeze/TailStore.php index de2f51c..76bd5fb 100644 --- a/src/Breeze/TailStore.php +++ b/src/Breeze/TailStore.php @@ -15,7 +15,17 @@ * The cursor has two writers — the purge resets it, the tick advances it — so * advancing is conditional. A tick that started before a purge and finished * after it must not overwrite the fresh reset, or every URL the purge - * invalidated would sit unwarmed behind a cursor claiming otherwise. + * invalidated would sit unwarmed behind a cursor claiming otherwise. That is + * the race this guards against, and it is the one that actually happens. + * + * It does not guard two genuinely concurrent ticks. Both can read the same + * cursor, both can pass the check in advanceCursor(), and both can call + * update_option() — the later one wins, and both calls return true. Closing + * that would need a conditional UPDATE through $wpdb matched against the + * serialized option value: fragile, and disproportionate to the risk. The + * tail advance is driven by one scheduled tick, not by concurrent actors, so + * overlapping ticks are rare; when they do overlap, the result is a repeated + * batch of a handful of extra warm requests, not corrupted state. */ final class TailStore { diff --git a/tests/Unit/Breeze/TailStoreTest.php b/tests/Unit/Breeze/TailStoreTest.php index 5f06969..8bfa061 100644 --- a/tests/Unit/Breeze/TailStoreTest.php +++ b/tests/Unit/Breeze/TailStoreTest.php @@ -85,9 +85,11 @@ public function test_reset_writes_index_zero_without_reading_the_tail(): void { // multi-thousand-URL payload in the request an editor is waiting on. Functions\expect( 'get_option' )->never(); $written = null; + $autoload = null; Functions\when( 'update_option' )->alias( - function ( string $key, $value ) use ( &$written ): bool { - $written = $value; + function ( string $key, $value, $auto = null ) use ( &$written, &$autoload ): bool { + $written = $value; + $autoload = $auto; return true; } @@ -97,15 +99,18 @@ function ( string $key, $value ) use ( &$written ): bool { $this->assertSame( 0, $written['index'] ); $this->assertSame( '', $written['hash'], 'the tick stamps the hash; the purge must not read the tail' ); + $this->assertFalse( $autoload, 'the cursor must never autoload; every request would otherwise load it' ); } public function test_advance_writes_when_the_cursor_is_unchanged(): void { $current = array( 'index' => 100, 'hash' => 'h' ); Functions\when( 'get_option' )->justReturn( $current ); $written = null; + $autoload = null; Functions\when( 'update_option' )->alias( - function ( string $key, $value ) use ( &$written ): bool { - $written = $value; + function ( string $key, $value, $auto = null ) use ( &$written, &$autoload ): bool { + $written = $value; + $autoload = $auto; return true; } @@ -114,6 +119,7 @@ function ( string $key, $value ) use ( &$written ): bool { $this->assertTrue( TailStore::advanceCursor( $current, 200, 'h' ) ); $this->assertSame( 200, $written['index'] ); $this->assertSame( 'h', $written['hash'] ); + $this->assertFalse( $autoload, 'the cursor must never autoload; every request would otherwise load it' ); } public function test_advance_is_discarded_when_a_purge_reset_the_cursor(): void { From 92b9716babf6665380baf9fa5d07133acb18502d Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:05:27 +0200 Subject: [PATCH 05/15] feat(warmup): return the tail the cap excluded, in score order --- src/Breeze/WarmupSitemap.php | 31 ++++- .../BuildOrderedUrlsTailTest.php | 114 ++++++++++++++++++ 2 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Breeze/WarmupSitemap/BuildOrderedUrlsTailTest.php diff --git a/src/Breeze/WarmupSitemap.php b/src/Breeze/WarmupSitemap.php index 8f61628..64dccbe 100644 --- a/src/Breeze/WarmupSitemap.php +++ b/src/Breeze/WarmupSitemap.php @@ -4,6 +4,7 @@ namespace Parisek\TimberKit\Breeze; +use Parisek\TimberKit\Breeze\TailPlanner; /** * Feeds Breeze's Cache Warmup preloader with every URL from the site's XML @@ -394,11 +395,19 @@ public static function runRefresh(): void { * @param array $weights * @param int $now * @param int $max - * @return array{urls: array, signals: array} + * @return array{urls: array, signals: array, tail: array} */ public static function buildOrderedUrls( array $records, array $weights, int $now, int $max ): array { - $scored = Scorer::scoreAll( $records, $weights, $now ); - $kept = LanguageQuota::apply( $scored, $max ); + $scored = Scorer::scoreAll( $records, $weights, $now ); + + // Sort the FULL set before splitting. Everything upstream preserves + // input order — scoreAll() only attaches scores, and LanguageQuota + // selects without reordering — so a tail taken from them directly + // would come out in sitemap order, which is precisely the ordering + // this module exists to replace. + $sorted = Scorer::sort( $scored ); + + $kept = LanguageQuota::apply( $sorted, $max ); $ordered = Scorer::sort( $kept ); $signals = array(); @@ -414,12 +423,26 @@ public static function buildOrderedUrls( array $records, array $weights, int $no ); } + $urls = array_column( $ordered, 'url' ); + return array( - 'urls' => array_column( $ordered, 'url' ), + 'urls' => $urls, 'signals' => $signals, + 'tail' => TailPlanner::split( $sorted, $urls, self::maxTailUrls() ), ); } + /** + * Safety cap on stored tail URLs. + */ + private static function maxTailUrls(): int { + $max = function_exists( 'apply_filters' ) + ? apply_filters( 'timberkit_warmup_tail_max_urls', TailPlanner::DEFAULT_MAX_TAIL_URLS ) + : TailPlanner::DEFAULT_MAX_TAIL_URLS; + + return is_numeric( $max ) ? max( 0, (int) $max ) : TailPlanner::DEFAULT_MAX_TAIL_URLS; + } + /** * Attach the signals a sitemap cannot carry, and resolve each record's * language. diff --git a/tests/Unit/Breeze/WarmupSitemap/BuildOrderedUrlsTailTest.php b/tests/Unit/Breeze/WarmupSitemap/BuildOrderedUrlsTailTest.php new file mode 100644 index 0000000..20fa3bb --- /dev/null +++ b/tests/Unit/Breeze/WarmupSitemap/BuildOrderedUrlsTailTest.php @@ -0,0 +1,114 @@ + $overrides + * @return array + */ + private function record( string $url, array $overrides = array() ): array { + return array_merge( + array( + 'url' => $url, + 'key' => $url, + 'lastmod' => null, + 'type' => '', + 'lang' => 'cs', + 'source' => 'https://example.test/wp-sitemap.xml', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + ), + $overrides + ); + } + + public function test_tail_is_sorted_by_score_not_sitemap_order(): void { + // Deliberately fed in ascending score, i.e. the worst first — the order + // a sitemap generator might well produce. The tail must come back + // descending. + $records = array( + $this->record( 'https://example.test/worst/' ), + $this->record( 'https://example.test/middle/', array( 'type' => 'post' ) ), + $this->record( 'https://example.test/best/', array( 'menu' => true ) ), + ); + + $weights = Scorer::DEFAULT_WEIGHTS; + $weights['types']['post'] = 50; + + $built = WarmupSitemap::buildOrderedUrls( $records, $weights, self::NOW, 0 ); + + $this->assertSame( + array( + 'https://example.test/middle/', + 'https://example.test/worst/', + ), + $built['tail'], + 'the tail carries the feature promise: most valuable first' + ); + } + + public function test_tail_excludes_everything_that_made_the_cap(): void { + $records = array( + $this->record( 'https://example.test/kept/', array( 'menu' => true ) ), + $this->record( 'https://example.test/dropped/' ), + ); + + $built = WarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, self::NOW, 1 ); + + $this->assertSame( array( 'https://example.test/kept/' ), $built['urls'] ); + $this->assertSame( array( 'https://example.test/dropped/' ), $built['tail'] ); + } + + public function test_tail_is_empty_when_everything_fits(): void { + $records = array( $this->record( 'https://example.test/a/' ) ); + + $built = WarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, self::NOW, 100 ); + + $this->assertSame( array(), $built['tail'] ); + } + + public function test_urls_and_signals_are_unchanged(): void { + // The existing contract must not move: this task adds a key, it does + // not alter the two that were already there. + $records = array( $this->record( 'https://example.test/a/', array( 'menu' => true ) ) ); + + $built = WarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, self::NOW, 100 ); + + $this->assertSame( array( 'https://example.test/a/' ), $built['urls'] ); + $this->assertArrayHasKey( 'https://example.test/a/', $built['signals'] ); + } +} From dfd8cd3d6f84d2e58ca2db87b75b87d378e3679a Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:11:16 +0200 Subject: [PATCH 06/15] feat(warmup): persist the tail and rescue the chain on a cold start --- src/Breeze/WarmupSitemap.php | 71 +++++++- .../Breeze/WarmupSitemap/TailRefreshTest.php | 172 ++++++++++++++++++ 2 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php diff --git a/src/Breeze/WarmupSitemap.php b/src/Breeze/WarmupSitemap.php index 64dccbe..7eedcc5 100644 --- a/src/Breeze/WarmupSitemap.php +++ b/src/Breeze/WarmupSitemap.php @@ -71,6 +71,18 @@ final class WarmupSitemap { /** @var array|null Effective weight map for this project, set at registration. */ private static ?array $weights = null; + /** @var bool Whether tail draining is enabled for this project. */ + private static bool $tail_enabled = false; + + /** @var int URLs dispatched per tick. */ + private static int $tail_batch = 100; + + /** @var string Action Scheduler hook the tail drain ticks on. */ + public const TAIL_HOOK = 'timber_kit_breeze_warmup_tail_tick'; + + /** @var int Seconds between tail ticks. Fixed, not configurable: the batch size is the knob. */ + public const TAIL_INTERVAL = 300; + /** @var string Transient key for the short refresh lock. */ private const LOCK_KEY = 'timber_kit_breeze_warmup_sitemap_refresh_lock'; @@ -112,10 +124,12 @@ final class WarmupSitemap { * a project that wires it without going through `StarterBase`. * * @param array|null $weights - * @param array $curated Project's curated warmup entries. + * @param array $curated Project's curated warmup entries. + * @param bool $tail Drain the URLs the cap excluded, a batch at a time. + * @param int $tailBatch URLs dispatched per tick. * @return void */ - public static function register( bool $priority = false, ?array $weights = null, array $curated = array() ): void { + public static function register( bool $priority = false, ?array $weights = null, array $curated = array(), bool $tail = false, int $tailBatch = 100 ): void { if ( self::$registered ) { return; } @@ -129,6 +143,15 @@ public static function register( bool $priority = false, ?array $weights = null, self::$weights = $weights ?? Scorer::DEFAULT_WEIGHTS; self::$curated = $curated; + // Tail draining requires the ordering — without it there is nothing + // to drain — so $tail alone must enable nothing. + if ( $tail && $priority ) { + self::$tail_enabled = true; + self::$tail_batch = function_exists( 'apply_filters' ) + ? (int) apply_filters( 'timberkit_warmup_tail_batch', $tailBatch ) + : $tailBatch; + } + add_filter( 'breeze_preload_urls', array( self::class, 'filterPreloadUrls' ) ); add_action( self::CRON_HOOK, array( self::class, 'runRefresh' ) ); @@ -376,6 +399,17 @@ public static function runRefresh(): void { $built = self::buildOrderedUrls( $records, $weights, time(), self::maxUrls() ); PriorityStore::write( $built['urls'], $built['signals'], Scorer::weightsHash( $weights ), $revision ); + + if ( self::$tail_enabled ) { + TailStore::writeTail( $built['tail'] ); + + // Cold-start rescue: the purge scheduled a tick before this + // refresh had written anything, so that tick found an empty + // tail and ended the chain. Nothing else would ever restart it. + if ( array() !== $built['tail'] ) { + self::scheduleTailTick(); + } + } } catch ( \Throwable $e ) { // Best-effort by contract: a sitemap outage must never surface as // a fatal in a cron job. @@ -384,6 +418,37 @@ public static function runRefresh(): void { } } + /** + * Schedule the next tail tick, unless one is already pending or running. + * + * Called by the purge and by the refresh — never by the tick itself. + * `as_next_scheduled_action()` reports a RUNNING action as scheduled, so a + * tick using this to decide about its own successor would see itself and + * end the chain after one run. + * + * @return void + */ + /** + * URLs dispatched per tail tick, as configured at registration. + * + * @return int + */ + public static function tailBatch(): int { + return self::$tail_batch; + } + + public static function scheduleTailTick(): void { + if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) { + return; + } + + if ( as_next_scheduled_action( self::TAIL_HOOK ) ) { + return; + } + + as_schedule_single_action( time() + self::TAIL_INTERVAL, self::TAIL_HOOK ); + } + /** * Score, budget and order a set of sitemap records. * @@ -1181,5 +1246,7 @@ public static function reset_for_tests(): void { self::$priority_enabled = false; self::$weights_hash = ''; self::$weights = null; + self::$tail_enabled = false; + self::$tail_batch = 100; } } diff --git a/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php b/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php new file mode 100644 index 0000000..3b5468f --- /dev/null +++ b/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php @@ -0,0 +1,172 @@ +justReturn( false ); + $scheduled = array(); + Functions\when( 'as_schedule_single_action' )->alias( + function ( int $when, string $hook ) use ( &$scheduled ): int { + $scheduled[] = $hook; + + return 1; + } + ); + + WarmupSitemap::scheduleTailTick(); + + $this->assertContains( WarmupSitemap::TAIL_HOOK, $scheduled ); + } + + public function test_does_not_schedule_a_second_tick(): void { + // Two chains draining at once would silently double the configured + // pace, which is the one thing the batch size is meant to control. + Functions\when( 'as_next_scheduled_action' )->justReturn( true ); + Functions\expect( 'as_schedule_single_action' )->never(); + + WarmupSitemap::scheduleTailTick(); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] + public function test_does_nothing_without_action_scheduler(): void { + // No fatal, no half-wired state — the module behaves as if switched off. + Functions\expect( 'as_schedule_single_action' )->never(); + + WarmupSitemap::scheduleTailTick(); + } + + public function test_refresh_writes_a_non_empty_tail_and_reschedules_the_tick(): void { + Functions\when( 'home_url' )->alias( fn( $path = '' ) => 'https://example.test' . $path ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'wp_remote_retrieve_response_code' )->alias( fn( $r ) => $r['response']['code'] ?? 200 ); + Functions\when( 'wp_remote_retrieve_body' )->alias( fn( $r ) => $r['body'] ?? '' ); + Functions\when( 'wp_get_nav_menus' )->justReturn( array() ); + Functions\when( 'get_option' )->justReturn( null ); + Functions\when( 'update_option' )->justReturn( true ); + Functions\when( 'delete_transient' )->justReturn( true ); + + // Cap the ordered set to one URL so the rest spills into the tail — + // this is what proves the tail is non-empty, not merely written. + Functions\when( 'apply_filters' )->alias( + function ( $hook, $value, ...$args ) { + return 'timberkit_warmup_sitemap_max_urls' === $hook ? 1 : $value; + } + ); + + $urls = array( + 'https://example.test/one/', + 'https://example.test/two/', + 'https://example.test/three/', + ); + $urlset = ''; + foreach ( $urls as $url ) { + $urlset .= '' . $url . ''; + } + $urlset .= ''; + + Functions\when( 'wp_remote_get' )->justReturn( + array( + 'response' => array( 'code' => 200 ), + 'body' => $urlset, + ) + ); + + Functions\when( 'as_next_scheduled_action' )->justReturn( false ); + $scheduled = array(); + Functions\when( 'as_schedule_single_action' )->alias( + function ( int $when, string $hook ) use ( &$scheduled ): int { + $scheduled[] = $hook; + + return 1; + } + ); + + $writtenTails = array(); + Functions\when( 'update_option' )->alias( + function ( $key, $value, $autoload ) use ( &$writtenTails ) { + if ( 'timber_kit_breeze_warmup_tail' === $key ) { + $writtenTails[] = $value['urls']; + } + + return true; + } + ); + + WarmupSitemap::register( true, null, true, 100 ); + WarmupSitemap::runRefresh(); + + $this->assertNotSame( array(), $writtenTails, 'runRefresh() must write a tail while tail draining is on.' ); + $this->assertNotSame( array(), $writtenTails[0], 'the tail must contain the URLs the cap excluded.' ); + $this->assertContains( WarmupSitemap::TAIL_HOOK, $scheduled, 'a non-empty tail written on a cold start must self-schedule the tick.' ); + } + + public function test_tail_alone_without_priority_writes_nothing(): void { + // $tail alone must enable nothing — draining needs the ordering to + // drain, and priority is what produces it. + Functions\when( 'home_url' )->alias( fn( $path = '' ) => 'https://example.test' . $path ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'wp_remote_retrieve_response_code' )->alias( fn( $r ) => $r['response']['code'] ?? 200 ); + Functions\when( 'wp_remote_retrieve_body' )->alias( fn( $r ) => $r['body'] ?? '' ); + Functions\when( 'wp_get_nav_menus' )->justReturn( array() ); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( 'get_option' )->justReturn( null ); + Functions\when( 'delete_transient' )->justReturn( true ); + + Functions\when( 'wp_remote_get' )->justReturn( + array( + 'response' => array( 'code' => 200 ), + 'body' => 'https://example.test/one/', + ) + ); + + Functions\expect( 'as_schedule_single_action' )->never(); + + $tailWritten = false; + Functions\when( 'update_option' )->alias( + function ( $key, $value, $autoload ) use ( &$tailWritten ) { + if ( 'timber_kit_breeze_warmup_tail' === $key ) { + $tailWritten = true; + } + + return true; + } + ); + + WarmupSitemap::register( false, null, true, 100 ); + WarmupSitemap::runRefresh(); + + $this->assertFalse( $tailWritten, '$tail alone (without $priority) must not enable tail draining.' ); + } +} From 1bcd5295f004c517658ec39e9141965f00d268b2 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:13:42 +0200 Subject: [PATCH 07/15] fix(warmup): drop tail_batch, undeclared until the tick reads it --- src/Breeze/WarmupSitemap.php | 19 +------------------ .../Breeze/WarmupSitemap/TailRefreshTest.php | 4 ++-- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/Breeze/WarmupSitemap.php b/src/Breeze/WarmupSitemap.php index 7eedcc5..853f49f 100644 --- a/src/Breeze/WarmupSitemap.php +++ b/src/Breeze/WarmupSitemap.php @@ -74,9 +74,6 @@ final class WarmupSitemap { /** @var bool Whether tail draining is enabled for this project. */ private static bool $tail_enabled = false; - /** @var int URLs dispatched per tick. */ - private static int $tail_batch = 100; - /** @var string Action Scheduler hook the tail drain ticks on. */ public const TAIL_HOOK = 'timber_kit_breeze_warmup_tail_tick'; @@ -126,10 +123,9 @@ final class WarmupSitemap { * @param array|null $weights * @param array $curated Project's curated warmup entries. * @param bool $tail Drain the URLs the cap excluded, a batch at a time. - * @param int $tailBatch URLs dispatched per tick. * @return void */ - public static function register( bool $priority = false, ?array $weights = null, array $curated = array(), bool $tail = false, int $tailBatch = 100 ): void { + public static function register( bool $priority = false, ?array $weights = null, array $curated = array(), bool $tail = false ): void { if ( self::$registered ) { return; } @@ -147,9 +143,6 @@ public static function register( bool $priority = false, ?array $weights = null, // to drain — so $tail alone must enable nothing. if ( $tail && $priority ) { self::$tail_enabled = true; - self::$tail_batch = function_exists( 'apply_filters' ) - ? (int) apply_filters( 'timberkit_warmup_tail_batch', $tailBatch ) - : $tailBatch; } add_filter( 'breeze_preload_urls', array( self::class, 'filterPreloadUrls' ) ); @@ -428,15 +421,6 @@ public static function runRefresh(): void { * * @return void */ - /** - * URLs dispatched per tail tick, as configured at registration. - * - * @return int - */ - public static function tailBatch(): int { - return self::$tail_batch; - } - public static function scheduleTailTick(): void { if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) { return; @@ -1247,6 +1231,5 @@ public static function reset_for_tests(): void { self::$weights_hash = ''; self::$weights = null; self::$tail_enabled = false; - self::$tail_batch = 100; } } diff --git a/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php b/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php index 3b5468f..1ef8283 100644 --- a/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php +++ b/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php @@ -124,7 +124,7 @@ function ( $key, $value, $autoload ) use ( &$writtenTails ) { } ); - WarmupSitemap::register( true, null, true, 100 ); + WarmupSitemap::register( true, null, true ); WarmupSitemap::runRefresh(); $this->assertNotSame( array(), $writtenTails, 'runRefresh() must write a tail while tail draining is on.' ); @@ -164,7 +164,7 @@ function ( $key, $value, $autoload ) use ( &$tailWritten ) { } ); - WarmupSitemap::register( false, null, true, 100 ); + WarmupSitemap::register( false, null, true ); WarmupSitemap::runRefresh(); $this->assertFalse( $tailWritten, '$tail alone (without $priority) must not enable tail draining.' ); From f2c629652cbf16c876c3adab5d31514e049a9a0d Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:18:49 +0200 Subject: [PATCH 08/15] feat(warmup): drain one batch of the tail per tick, behind Breeze --- phpstan.neon | 3 + src/Breeze/WarmupSitemap.php | 113 +++++++++++- .../Breeze/WarmupSitemap/TailTickTest.php | 164 ++++++++++++++++++ tests/bootstrap.php | 10 ++ 4 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Breeze/WarmupSitemap/TailTickTest.php diff --git a/phpstan.neon b/phpstan.neon index 7401c5b..f51ac2a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -18,6 +18,9 @@ parameters: - '#Call to static method .* on an unknown class Timber#' # WP-CLI is a separate package, not in the WordPress stubs (cf. Timber above). - '#Call to static method .* on an unknown class WP_CLI#' + # Breeze_Cache_Preloader is the Breeze plugin's class, not a dependency + # of this package (cf. WP-CLI and Timber above). + - '#Call to static method .* on an unknown class Breeze_Cache_Preloader#' # wp_get_attachment_metadata stub shape omits original_image; presence is # guarded by `! empty( $metadata['original_image'] )` before the unset. - diff --git a/src/Breeze/WarmupSitemap.php b/src/Breeze/WarmupSitemap.php index 853f49f..958b7fd 100644 --- a/src/Breeze/WarmupSitemap.php +++ b/src/Breeze/WarmupSitemap.php @@ -74,6 +74,9 @@ final class WarmupSitemap { /** @var bool Whether tail draining is enabled for this project. */ private static bool $tail_enabled = false; + /** @var int URLs dispatched per tail tick, filterable at registration via `timberkit_warmup_tail_batch`. */ + private static int $tail_batch = 100; + /** @var string Action Scheduler hook the tail drain ticks on. */ public const TAIL_HOOK = 'timber_kit_breeze_warmup_tail_tick'; @@ -125,7 +128,7 @@ final class WarmupSitemap { * @param bool $tail Drain the URLs the cap excluded, a batch at a time. * @return void */ - public static function register( bool $priority = false, ?array $weights = null, array $curated = array(), bool $tail = false ): void { + public static function register( bool $priority = false, ?array $weights = null, array $curated = array(), bool $tail = false, int $tailBatch = 100 ): void { if ( self::$registered ) { return; } @@ -143,6 +146,10 @@ public static function register( bool $priority = false, ?array $weights = null, // to drain — so $tail alone must enable nothing. if ( $tail && $priority ) { self::$tail_enabled = true; + self::$tail_batch = (int) apply_filters( 'timberkit_warmup_tail_batch', $tailBatch ); + + add_action( self::TAIL_HOOK, array( self::class, 'runTailTick' ) ); + add_action( 'breeze_clear_all_cache', array( self::class, 'onPurgeScheduleTail' ), 1000 ); } add_filter( 'breeze_preload_urls', array( self::class, 'filterPreloadUrls' ) ); @@ -433,6 +440,109 @@ public static function scheduleTailTick(): void { as_schedule_single_action( time() + self::TAIL_INTERVAL, self::TAIL_HOOK ); } + /** + * Purge handler: start the tail over and kick the chain. + * + * Priority 1000 so Breeze has already filled its own queue at 999 — the + * tick's brake can then see it and stand aside. + * + * @return void + */ + public static function onPurgeScheduleTail(): void { + if ( ! self::isEnabled() || ! self::$tail_enabled ) { + return; + } + + TailStore::resetCursor(); + self::scheduleTailTick(); + } + + /** + * One tail tick: dispatch a batch, advance, schedule the successor. + * + * A skipped tick (brake engaged) still schedules its successor — only an + * exhausted tail ends the chain, never a busy Breeze. + * + * @return void + */ + public static function runTailTick(): void { + if ( ! self::isEnabled() || ! self::$tail_enabled ) { + return; + } + + if ( self::breezeIsWarming() ) { + self::scheduleNextTailTick(); + + return; + } + + $tail = TailStore::readTail(); + if ( array() === $tail['urls'] ) { + return; + } + + $cursor = TailStore::readCursor(); + $index = $cursor['hash'] === $tail['hash'] ? $cursor['index'] : 0; + + if ( $index >= count( $tail['urls'] ) ) { + return; + } + + $batch = TailPlanner::nextBatch( $tail['urls'], $index, self::$tail_batch ); + if ( array() === $batch ) { + return; + } + + foreach ( $batch as $url ) { + // Breeze's own primitive: it carries the local-URL check, the + // circuit breaker and the fire-and-forget fetch. It returns void, + // so the cursor counts dispatches, not confirmed warms. + \Breeze_Cache_Preloader::preload_url( $url ); + } + + TailStore::advanceCursor( $cursor, $index + count( $batch ), $tail['hash'] ); + + self::scheduleNextTailTick(); + } + + /** + * Whether Breeze is draining its own preload queue right now. + * + * Reads a foreign option, read-only and tolerantly: anything other than a + * non-empty array counts as idle. Breeze splices the batch off the queue + * BEFORE dispatching it, so the final batch leaves this looking idle while + * three URLs are still in flight — about a second at the end of a run. + * Accepted: closing that window would mean guessing from timestamps. + * + * @return bool + */ + private static function breezeIsWarming(): bool { + if ( ! function_exists( 'get_option' ) ) { + return false; + } + + $queue = get_option( 'breeze_preload_queue', array() ); + + return is_array( $queue ) && array() !== $queue; + } + + /** + * Schedule the successor directly. + * + * Deliberately NOT scheduleTailTick(): that one asks + * `as_next_scheduled_action()`, which reports a RUNNING action as + * scheduled — the tick would see itself and end its own chain. + * + * @return void + */ + private static function scheduleNextTailTick(): void { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + return; + } + + as_schedule_single_action( time() + self::TAIL_INTERVAL, self::TAIL_HOOK ); + } + /** * Score, budget and order a set of sitemap records. * @@ -1231,5 +1341,6 @@ public static function reset_for_tests(): void { self::$weights_hash = ''; self::$weights = null; self::$tail_enabled = false; + self::$tail_batch = 100; } } diff --git a/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php b/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php new file mode 100644 index 0000000..3dad625 --- /dev/null +++ b/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php @@ -0,0 +1,164 @@ +justReturn( true ); + Functions\when( 'add_action' )->justReturn( true ); + Functions\when( 'as_next_scheduled_action' )->justReturn( false ); + Functions\when( 'as_schedule_single_action' )->justReturn( 1 ); + + WarmupSitemap::register( true, null, true, $batch ); + } + + public function test_skips_the_tick_while_breeze_is_still_warming(): void { + // Piling our batch on top of Breeze's own queue would hit the origin + // exactly when it is busiest — right after a purge. + $this->enableTail(); + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key ? array( 'https://example.test/x/' ) : null + ); + $scheduled = array(); + Functions\when( 'as_schedule_single_action' )->alias( + function ( int $when, string $hook ) use ( &$scheduled ): int { + $scheduled[] = $hook; + + return 1; + } + ); + Functions\expect( 'update_option' )->never(); + + WarmupSitemap::runTailTick(); + + $this->assertContains( WarmupSitemap::TAIL_HOOK, $scheduled, 'a skipped tick still schedules its successor' ); + } + + public function test_dispatches_a_batch_and_advances_the_cursor(): void { + $this->enableTail( 2 ); + + $tail = array( 'urls' => array( 'a', 'b', 'c' ), 'hash' => 'H' ); + Functions\when( 'get_option' )->alias( + static function ( string $key ) use ( $tail ) { + if ( 'breeze_preload_queue' === $key ) { + return array(); + } + if ( WarmupSitemap::TAIL_HOOK === $key ) { + return null; + } + + return str_contains( $key, 'cursor' ) + ? array( 'index' => 0, 'hash' => 'H' ) + : $tail; + } + ); + $written = null; + Functions\when( 'update_option' )->alias( + function ( string $key, $value ) use ( &$written ): bool { + if ( str_contains( $key, 'cursor' ) ) { + $written = $value; + } + + return true; + } + ); + Functions\when( 'as_schedule_single_action' )->justReturn( 1 ); + + WarmupSitemap::runTailTick(); + + $this->assertSame( 2, $written['index'] ); + $this->assertSame( 'H', $written['hash'] ); + } + + public function test_a_changed_tail_restarts_from_the_beginning(): void { + // The refresh rewrote the tail mid-drain; the old index points into a + // different plan and must not be trusted. + $this->enableTail( 1 ); + Functions\when( 'get_option' )->alias( + static function ( string $key ) { + if ( 'breeze_preload_queue' === $key ) { + return array(); + } + + return str_contains( $key, 'cursor' ) + ? array( 'index' => 99, 'hash' => 'OLD' ) + : array( 'urls' => array( 'a', 'b' ), 'hash' => 'NEW' ); + } + ); + $written = null; + Functions\when( 'update_option' )->alias( + function ( string $key, $value ) use ( &$written ): bool { + if ( str_contains( $key, 'cursor' ) ) { + $written = $value; + } + + return true; + } + ); + Functions\when( 'as_schedule_single_action' )->justReturn( 1 ); + + WarmupSitemap::runTailTick(); + + $this->assertSame( 1, $written['index'], 'restarted at 0, advanced by one' ); + $this->assertSame( 'NEW', $written['hash'] ); + } + + public function test_exhausted_tail_ends_the_chain(): void { + $this->enableTail( 10 ); + Functions\when( 'get_option' )->alias( + static function ( string $key ) { + if ( 'breeze_preload_queue' === $key ) { + return array(); + } + + return str_contains( $key, 'cursor' ) + ? array( 'index' => 2, 'hash' => 'H' ) + : array( 'urls' => array( 'a', 'b' ), 'hash' => 'H' ); + } + ); + Functions\expect( 'as_schedule_single_action' )->never(); + + WarmupSitemap::runTailTick(); + } + + #[PreserveGlobalState( false )] + #[RunInSeparateProcess] + public function test_does_nothing_when_the_flag_is_off(): void { + Functions\expect( 'as_schedule_single_action' )->never(); + Functions\expect( 'update_option' )->never(); + + WarmupSitemap::runTailTick(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 5858371..dad9a8d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -115,6 +115,16 @@ public function __construct( array|object $props = [] ) { } } +// Minimal Breeze_Cache_Preloader stub: the Breeze plugin class the tail tick +// dispatches URLs through. Not a dependency of this package, so tests supply +// a no-op stand-in the same way the WPML stub covers TranslationManagement. +if ( ! class_exists( 'Breeze_Cache_Preloader' ) ) { + class Breeze_Cache_Preloader { + public static function preload_url( string $url ): void { + } + } +} + // Minimal WP_User stub for tests that need an instance to satisfy `instanceof WP_User`. // `#[\AllowDynamicProperties]` mirrors WordPress core, which annotates `WP_User` // the same way (dynamic props are hydrated from the `$data` user row). From e4a6f37819e49d5666efe509455b5be608b230cb Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:23:06 +0200 Subject: [PATCH 09/15] feat(starter-base): opt into draining the warmup tail --- src/StarterBase.php | 33 +++++++- .../StarterBase/BreezeWarmupTailSetupTest.php | 80 +++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/StarterBase/BreezeWarmupTailSetupTest.php diff --git a/src/StarterBase.php b/src/StarterBase.php index 39d3604..d75c6a3 100644 --- a/src/StarterBase.php +++ b/src/StarterBase.php @@ -623,6 +623,35 @@ class StarterBase extends Site { */ protected array $breeze_warmup_urls = array(); + /** + * Keep warming the URLs the cap excluded, a batch at a time, after a purge. + * + * The cap warms the most important pages immediately; everything behind it + * stays cold until a visitor asks. This drains that tail in score order, + * pausing whenever Breeze is warming its own queue. + * + * It never finishes: a full purge can arrive several times a day and resets + * the run, so on a busy site only part of the tail is ever covered. That is + * by design — the part covered is always the most valuable part. + * + * Requires `$breeze_warmup_sitemap` and `$breeze_warmup_priority`. + * + * @var bool + */ + protected bool $breeze_warmup_tail = false; + + /** + * URLs dispatched per five-minute tick. + * + * The interval is fixed; this is the knob. Read it as "how many origin + * renders per five minutes I am willing to pay". 100 works out to roughly + * 1200 URLs an hour — but only in the windows when Breeze's own queue is + * quiet, so real throughput is lower. + * + * @var int + */ + protected int $breeze_warmup_tail_batch = 100; + /** * ACF Datastore ({@see https://www.advancedcustomfields.com/resources/acf-settings-enable_datastore/}). * @@ -1385,7 +1414,9 @@ protected function setup_breeze_warmup_sitemap(): void { WarmupSitemap::register( $this->breeze_warmup_priority, $this->breeze_warmup_priority_weights, - $this->breeze_warmup_urls + $this->breeze_warmup_urls, + $this->breeze_warmup_tail, + $this->breeze_warmup_tail_batch ); } diff --git a/tests/Unit/StarterBase/BreezeWarmupTailSetupTest.php b/tests/Unit/StarterBase/BreezeWarmupTailSetupTest.php new file mode 100644 index 0000000..c6dcfcf --- /dev/null +++ b/tests/Unit/StarterBase/BreezeWarmupTailSetupTest.php @@ -0,0 +1,80 @@ + $actions + * @return void + */ + private function captureActions( array &$actions ): void { + Functions\when( 'add_action' )->alias( + function ( string $tag, $callback = null, int $priority = 10 ) use ( &$actions ) { + $actions[] = array( $tag, $priority ); + + return true; + } + ); + Functions\when( 'add_filter' )->justReturn( true ); + Functions\when( 'as_next_scheduled_action' )->justReturn( false ); + Functions\when( 'as_schedule_single_action' )->justReturn( 1 ); + } + + public function test_tail_on_wires_the_tick_and_the_purge_hook(): void { + $actions = array(); + $this->captureActions( $actions ); + + WarmupSitemap::register( true, null, true, 100 ); + + $this->assertContains( array( WarmupSitemap::TAIL_HOOK, 10 ), $actions ); + $this->assertContains( array( 'breeze_clear_all_cache', 1000 ), $actions ); + } + + public function test_tail_off_wires_neither(): void { + $actions = array(); + $this->captureActions( $actions ); + + WarmupSitemap::register( true, null, false, 100 ); + + $tags = array_column( $actions, 0 ); + $this->assertNotContains( WarmupSitemap::TAIL_HOOK, $tags ); + $this->assertNotContains( 'breeze_clear_all_cache', $tags ); + } + + public function test_tail_without_priority_wires_neither(): void { + $actions = array(); + $this->captureActions( $actions ); + + WarmupSitemap::register( false, null, true, 100 ); + + $tags = array_column( $actions, 0 ); + $this->assertNotContains( WarmupSitemap::TAIL_HOOK, $tags ); + $this->assertNotContains( 'breeze_clear_all_cache', $tags ); + } +} From 0882b6fc2c108d71c2bb13174838cf2509d5668b Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:25:57 +0200 Subject: [PATCH 10/15] test(starter-base): cover the warmup tail wiring at the StarterBase layer --- .../BreezeWarmupTailStarterBaseSetupTest.php | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/Unit/StarterBase/BreezeWarmupTailStarterBaseSetupTest.php diff --git a/tests/Unit/StarterBase/BreezeWarmupTailStarterBaseSetupTest.php b/tests/Unit/StarterBase/BreezeWarmupTailStarterBaseSetupTest.php new file mode 100644 index 0000000..4cf3b93 --- /dev/null +++ b/tests/Unit/StarterBase/BreezeWarmupTailStarterBaseSetupTest.php @@ -0,0 +1,160 @@ +breeze_warmup_sitemap = $sitemap; + $this->breeze_warmup_priority = $priority; + $this->breeze_warmup_tail = $tail; + $this->breeze_warmup_tail_batch = $tail_batch; + } + + public function run_setup_breeze_warmup_sitemap(): void { + $this->setup_breeze_warmup_sitemap(); + } +} + +/** + * Covers `StarterBase::setup_breeze_warmup_sitemap()` actually forwarding the + * tail flag and batch size into `WarmupSitemap::register()` — the one thing + * this task adds. `BreezeWarmupTailSetupTest` in this directory drives the + * same matrix through `WarmupSitemap::register()` directly, which is useful + * on its own but proves nothing about the `StarterBase` wiring: that call + * already had all four parameters before this task existed. + */ +class BreezeWarmupTailStarterBaseSetupTest extends TestCase { + + protected function setUp(): void { + parent::setUp(); + Monkey\setUp(); + WarmupSitemap::reset_for_tests(); + } + + protected function tearDown(): void { + WarmupSitemap::reset_for_tests(); + Monkey\tearDown(); + parent::tearDown(); + } + + /** + * @param array $actions + * @return void + */ + private function captureActions( array &$actions ): void { + Functions\when( 'add_action' )->alias( + function ( string $tag, $callback = null, int $priority = 10 ) use ( &$actions ) { + $actions[] = array( $tag, $priority ); + + return true; + } + ); + Functions\when( 'add_filter' )->justReturn( true ); + Functions\when( 'as_next_scheduled_action' )->justReturn( false ); + Functions\when( 'as_schedule_single_action' )->justReturn( 1 ); + } + + #[PreserveGlobalState( false )] + #[RunInSeparateProcess] + public function test_all_three_flags_on_wires_the_tick_and_the_purge_hook(): void { + define( 'BREEZE_VERSION', '2.5.0' ); + + $actions = array(); + $this->captureActions( $actions ); + + ( new BreezeWarmupTailStarterBaseSetupStub( true, true, true, 100 ) )->run_setup_breeze_warmup_sitemap(); + + $this->assertContains( array( WarmupSitemap::TAIL_HOOK, 10 ), $actions ); + $this->assertContains( array( 'breeze_clear_all_cache', 1000 ), $actions ); + } + + #[PreserveGlobalState( false )] + #[RunInSeparateProcess] + public function test_sitemap_and_priority_on_tail_off_wires_neither(): void { + define( 'BREEZE_VERSION', '2.5.0' ); + + $actions = array(); + $this->captureActions( $actions ); + + ( new BreezeWarmupTailStarterBaseSetupStub( true, true, false, 100 ) )->run_setup_breeze_warmup_sitemap(); + + $tags = array_column( $actions, 0 ); + $this->assertNotContains( WarmupSitemap::TAIL_HOOK, $tags ); + $this->assertNotContains( 'breeze_clear_all_cache', $tags ); + } + + #[PreserveGlobalState( false )] + #[RunInSeparateProcess] + public function test_sitemap_and_tail_on_priority_off_wires_neither(): void { + define( 'BREEZE_VERSION', '2.5.0' ); + + $actions = array(); + $this->captureActions( $actions ); + + ( new BreezeWarmupTailStarterBaseSetupStub( true, false, true, 100 ) )->run_setup_breeze_warmup_sitemap(); + + $tags = array_column( $actions, 0 ); + $this->assertNotContains( WarmupSitemap::TAIL_HOOK, $tags ); + $this->assertNotContains( 'breeze_clear_all_cache', $tags ); + } + + /** + * The batch size is the one knob StarterBase exposes on the tail. Prove it + * genuinely reaches WarmupSitemap by driving a real tick after going + * through setup_breeze_warmup_sitemap(), and reading how far the cursor + * advanced off the tail store — the same technique + * TailTickTest::test_dispatches_a_batch_and_advances_the_cursor() uses, + * since the dispatch target (Breeze_Cache_Preloader::preload_url) is a + * static method Brain\Monkey cannot intercept to count calls directly. + */ + #[PreserveGlobalState( false )] + #[RunInSeparateProcess] + public function test_a_non_default_tail_batch_reaches_the_module(): void { + define( 'BREEZE_VERSION', '2.5.0' ); + + Functions\when( 'add_action' )->justReturn( true ); + Functions\when( 'add_filter' )->justReturn( true ); + Functions\when( 'as_next_scheduled_action' )->justReturn( false ); + Functions\when( 'as_schedule_single_action' )->justReturn( 1 ); + + ( new BreezeWarmupTailStarterBaseSetupStub( true, true, true, 2 ) )->run_setup_breeze_warmup_sitemap(); + + $tail = array( 'urls' => array( 'a', 'b', 'c' ), 'hash' => 'H' ); + Functions\when( 'get_option' )->alias( + static function ( string $key ) use ( $tail ) { + if ( 'breeze_preload_queue' === $key ) { + return array(); + } + + return str_contains( $key, 'cursor' ) + ? array( 'index' => 0, 'hash' => 'H' ) + : $tail; + } + ); + $written = null; + Functions\when( 'update_option' )->alias( + function ( string $key, $value ) use ( &$written ): bool { + if ( str_contains( $key, 'cursor' ) ) { + $written = $value; + } + + return true; + } + ); + + WarmupSitemap::runTailTick(); + + $this->assertSame( 2, $written['index'], 'the batch of 2 set on StarterBase reached WarmupSitemap, not the default of 100' ); + } +} From 37d4d4979e40a86f29e6c1262a676a5c2a99292c Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:28:41 +0200 Subject: [PATCH 11/15] test(warmup): pin that walking the tail visits every URL once --- tests/Property/Breeze/TailBatchingTest.php | 59 ++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/Property/Breeze/TailBatchingTest.php diff --git a/tests/Property/Breeze/TailBatchingTest.php b/tests/Property/Breeze/TailBatchingTest.php new file mode 100644 index 0000000..4bd28e5 --- /dev/null +++ b/tests/Property/Breeze/TailBatchingTest.php @@ -0,0 +1,59 @@ +forAll( Generator\seq( Generator\nat() ), Generator\choose( 1, 10 ) ) + ->then( function ( array $items, int $batch ): void { + $urls = array(); + foreach ( array_values( $items ) as $i => $_ ) { + $urls[] = 'https://example.test/' . $i . '/'; + } + + $seen = array(); + $index = 0; + while ( true ) { + $slice = TailPlanner::nextBatch( $urls, $index, $batch ); + if ( array() === $slice ) { + break; + } + foreach ( $slice as $url ) { + $seen[] = $url; + } + $index += count( $slice ); + } + + $this->assertSame( $urls, $seen ); + } ); + } + + public function test_a_batch_never_reaches_past_the_end(): void { + $this->forAll( Generator\seq( Generator\nat() ), Generator\choose( 1, 10 ), Generator\nat() ) + ->then( function ( array $items, int $batch, int $index ): void { + $urls = array(); + foreach ( array_values( $items ) as $i => $_ ) { + $urls[] = 'https://example.test/' . $i . '/'; + } + + $slice = TailPlanner::nextBatch( $urls, $index, $batch ); + + $this->assertLessThanOrEqual( $batch, count( $slice ) ); + $this->assertLessThanOrEqual( max( 0, count( $urls ) - $index ), count( $slice ) ); + } ); + } +} From 5fdd209eef8e99e22ff92dd0b4732e55bd0bc9f4 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:31:53 +0200 Subject: [PATCH 12/15] docs(warmup): document the tail drain and what it does not promise --- CHANGELOG.md | 6 +++++ README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f6024..1c08437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -175,6 +175,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). `timberkit_warmup_priority_weights`. Off by default. - Site Health check `preload_chain_healthy` — reports a Breeze preload chain that has stopped making progress. +- `$breeze_warmup_tail` — keep warming the URLs the cap excluded, a batch at a + time, in score order, pausing whenever Breeze is draining its own preload + queue. Batch size is `$breeze_warmup_tail_batch` (default 100 per five-minute + tick) and filterable via `timberkit_warmup_tail_batch`; the stored tail is + capped by `timberkit_warmup_tail_max_urls` (default 5000). Off by default, + requires `$breeze_warmup_priority`. ### Changed diff --git a/README.md b/README.md index 84a8f61..40f46de 100644 --- a/README.md +++ b/README.md @@ -767,6 +767,68 @@ 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. +### Draining the tail the cap left behind + +`$breeze_warmup_priority` picks what gets warmed first; it does not warm what +the cap excludes. `$breeze_warmup_tail` (default `false`) keeps going after +the purge: every five minutes it dispatches another batch of the excluded +URLs to Breeze's preloader, in the same score order, until the tail runs out +or the next purge resets the run. Requires both `$breeze_warmup_sitemap` and +`$breeze_warmup_priority` — on its own there is no ordering to drain. + +```php +class Base extends StarterBase { + public function __construct() { + $this->breeze_warmup_sitemap = true; + $this->breeze_warmup_priority = true; + $this->breeze_warmup_tail = true; + $this->breeze_warmup_tail_batch = 100; + + parent::__construct(); + } +} +``` + +Each tick checks Breeze's own preload queue (`breeze_preload_queue`) first +and stands aside while it is non-empty, so the tail drain never competes with +Breeze for the same origin renders. A skipped tick still schedules its +successor — the chain only ends when the tail itself is exhausted. + +**This never reaches "done", and that is by design.** A full purge can arrive +several times a day, and each one starts the tail over from its head. On a +busy site, only part of the tail is ever covered in one run — but because the +tail is in score order, the part covered is always the most valuable part. +Do not size this feature expecting the whole sitemap to eventually go warm; +size it expecting the top of the tail to stay warm continuously. + +**The batch size does not multiply out the way it looks.** `$breeze_warmup_tail_batch` +of 100 per five-minute tick reads as 1200 URLs an hour, but that arithmetic +only holds if every tick fires — and a tick fires only when Breeze's own +queue is idle. Real throughput on a site that purges and warms constantly is +lower. Size the batch by the origin-render budget you can afford in a tick +that does run, not by the hourly total the multiplication suggests. + +**The cursor counts URLs dispatched, not URLs warmed.** Each tick hands its +batch to `Breeze_Cache_Preloader::preload_url()`, which returns `void` and +may reject a URL outright. The tail advancing past a URL means it was handed +to Breeze, not that the origin rendered it. There is no confirmation signal +to build on: Breeze does not report back. + +**The five-minute interval is fixed, not a filter.** `$breeze_warmup_tail_batch` +is the only knob; the tick's own cadence stays constant so the brake against +Breeze's queue behaves predictably. Filterable independently: `timberkit_warmup_tail_batch` +(applied once, at registration, like the priority weights) and +`timberkit_warmup_tail_max_urls` (default 5000) which caps how many excluded +URLs are stored as the tail in the first place — a safety bound distinct from +the sitemap URL cap. + +**Tail draining does not run on multisite.** The brake reads Breeze's +`breeze_preload_queue` option, and Breeze scopes that option per blog on +multisite — every site's brake would read "idle" regardless of what any +other site's queue is doing, and the drain from every site would pile onto +whatever origin actually serves the requests. Leave `$breeze_warmup_tail` +off on multisite. + ### Preload chain health The Site Health check `preload_chain_healthy` (category `caching`, needs From 00236c55d2b3f2972d4b8f5c214c25435bea2ca5 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:37:06 +0200 Subject: [PATCH 13/15] fix(warmup): refuse to wire tail draining on multisite Breeze scopes breeze_preload_queue per blog on multisite, so the tail tick's brake would always read idle there and the drain would pile onto the origin unthrottled. register() now detects is_multisite() and leaves the tail hooks unwired instead of running without a working brake. Also documents the cold-start rescue and that the batch size, like the priority weights, is read once at registration. --- README.md | 21 ++++-- src/Breeze/WarmupSitemap.php | 11 ++- .../WarmupSitemap/TailMultisiteGuardTest.php | 74 +++++++++++++++++++ 3 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/Breeze/WarmupSitemap/TailMultisiteGuardTest.php diff --git a/README.md b/README.md index 40f46de..34713f5 100644 --- a/README.md +++ b/README.md @@ -816,18 +816,29 @@ to build on: Breeze does not report back. **The five-minute interval is fixed, not a filter.** `$breeze_warmup_tail_batch` is the only knob; the tick's own cadence stays constant so the brake against -Breeze's queue behaves predictably. Filterable independently: `timberkit_warmup_tail_batch` -(applied once, at registration, like the priority weights) and +Breeze's queue behaves predictably. `$breeze_warmup_tail_batch`, like +`$breeze_warmup_priority_weights`, is read once, at registration — changing +it at runtime after that has no effect. Filterable independently: +`timberkit_warmup_tail_batch` (also applied once, at registration) and `timberkit_warmup_tail_max_urls` (default 5000) which caps how many excluded URLs are stored as the tail in the first place — a safety bound distinct from the sitemap URL cap. -**Tail draining does not run on multisite.** The brake reads Breeze's +**A cold start rescues itself.** A purge schedules the first tick and resets +the tail's cursor immediately, but the tail's *contents* are only written +later, by the deferred refresh. If the first tick runs before that refresh +has written anything, it finds an empty tail and ends the chain — nothing +else would ever restart it. To close that gap, the refresh itself schedules a +tick whenever it writes a non-empty tail, so the chain resumes once the +tail actually has something to drain. + +**Tail draining refuses to wire on multisite.** The brake reads Breeze's `breeze_preload_queue` option, and Breeze scopes that option per blog on multisite — every site's brake would read "idle" regardless of what any other site's queue is doing, and the drain from every site would pile onto -whatever origin actually serves the requests. Leave `$breeze_warmup_tail` -off on multisite. +whatever origin actually serves the requests. Rather than run without a +working brake, `register()` detects `is_multisite()` and leaves the tail +hooks unwired there, even when `$breeze_warmup_tail` is `true`. ### Preload chain health diff --git a/src/Breeze/WarmupSitemap.php b/src/Breeze/WarmupSitemap.php index 958b7fd..80937c2 100644 --- a/src/Breeze/WarmupSitemap.php +++ b/src/Breeze/WarmupSitemap.php @@ -143,8 +143,15 @@ public static function register( bool $priority = false, ?array $weights = null, self::$curated = $curated; // Tail draining requires the ordering — without it there is nothing - // to drain — so $tail alone must enable nothing. - if ( $tail && $priority ) { + // to drain — so $tail alone must enable nothing. It also refuses to + // wire on multisite: the brake reads Breeze's `breeze_preload_queue`, + // which Breeze scopes per blog, so on multisite the brake would + // always read idle and the drain would pile onto the origin + // unthrottled. Refusing to wire is safer than running without a + // working brake. + $is_multisite = function_exists( 'is_multisite' ) && is_multisite(); + + if ( $tail && $priority && ! $is_multisite ) { self::$tail_enabled = true; self::$tail_batch = (int) apply_filters( 'timberkit_warmup_tail_batch', $tailBatch ); diff --git a/tests/Unit/Breeze/WarmupSitemap/TailMultisiteGuardTest.php b/tests/Unit/Breeze/WarmupSitemap/TailMultisiteGuardTest.php new file mode 100644 index 0000000..e390989 --- /dev/null +++ b/tests/Unit/Breeze/WarmupSitemap/TailMultisiteGuardTest.php @@ -0,0 +1,74 @@ +justReturn( true ); + Functions\when( 'add_filter' )->justReturn( true ); + + $actions = array(); + Functions\when( 'add_action' )->alias( + function ( string $tag ) use ( &$actions ) { + $actions[] = $tag; + return true; + } + ); + + WarmupSitemap::register( true, null, true, 100 ); + + $this->assertNotContains( WarmupSitemap::TAIL_HOOK, $actions ); + $this->assertNotContains( 'breeze_clear_all_cache', $actions ); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] + public function test_wires_tail_hooks_on_single_site(): void { + Functions\when( 'is_multisite' )->justReturn( false ); + Functions\when( 'add_filter' )->justReturn( true ); + + $actions = array(); + Functions\when( 'add_action' )->alias( + function ( string $tag ) use ( &$actions ) { + $actions[] = $tag; + return true; + } + ); + + WarmupSitemap::register( true, null, true, 100 ); + + $this->assertContains( WarmupSitemap::TAIL_HOOK, $actions ); + $this->assertContains( 'breeze_clear_all_cache', $actions ); + } +} From eb5be7648e5f52c6ffc65106e7125fb21952dc08 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 19:41:18 +0200 Subject: [PATCH 14/15] test(warmup): assert absence explicitly instead of relying on Mockery alone Four tests relied solely on Functions\expect(...)->never(), a Mockery expectation verified at teardown, not a PHPUnit assertion. That left zero assertions and marked them risky, and made them read as tests that prove absence by proving nothing. Capture the calls with Functions\when(...)->alias(...) and assert the resulting array is empty instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D1sCgqDJKj7dGQrg5c25bY --- .../Breeze/WarmupSitemap/TailRefreshTest.php | 22 +++++++++++-- .../Breeze/WarmupSitemap/TailTickTest.php | 32 +++++++++++++++++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php b/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php index 1ef8283..a21380d 100644 --- a/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php +++ b/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php @@ -53,18 +53,36 @@ public function test_does_not_schedule_a_second_tick(): void { // Two chains draining at once would silently double the configured // pace, which is the one thing the batch size is meant to control. Functions\when( 'as_next_scheduled_action' )->justReturn( true ); - Functions\expect( 'as_schedule_single_action' )->never(); + $scheduled = array(); + Functions\when( 'as_schedule_single_action' )->alias( + function ( int $when, string $hook ) use ( &$scheduled ): int { + $scheduled[] = $hook; + + return 1; + } + ); WarmupSitemap::scheduleTailTick(); + + $this->assertSame( array(), $scheduled, 'a pending tick must not be joined by a second one.' ); } #[RunInSeparateProcess] #[PreserveGlobalState( false )] public function test_does_nothing_without_action_scheduler(): void { // No fatal, no half-wired state — the module behaves as if switched off. - Functions\expect( 'as_schedule_single_action' )->never(); + $scheduled = array(); + Functions\when( 'as_schedule_single_action' )->alias( + function ( int $when, string $hook ) use ( &$scheduled ): int { + $scheduled[] = $hook; + + return 1; + } + ); WarmupSitemap::scheduleTailTick(); + + $this->assertSame( array(), $scheduled, 'without Action Scheduler present, nothing gets scheduled.' ); } public function test_refresh_writes_a_non_empty_tail_and_reschedules_the_tick(): void { diff --git a/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php b/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php index 3dad625..6665341 100644 --- a/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php +++ b/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php @@ -148,17 +148,43 @@ static function ( string $key ) { : array( 'urls' => array( 'a', 'b' ), 'hash' => 'H' ); } ); - Functions\expect( 'as_schedule_single_action' )->never(); + $scheduled = array(); + Functions\when( 'as_schedule_single_action' )->alias( + function ( int $when, string $hook ) use ( &$scheduled ): int { + $scheduled[] = $hook; + + return 1; + } + ); WarmupSitemap::runTailTick(); + + $this->assertSame( array(), $scheduled, 'an exhausted tail must end the chain, not schedule a successor.' ); } #[PreserveGlobalState( false )] #[RunInSeparateProcess] public function test_does_nothing_when_the_flag_is_off(): void { - Functions\expect( 'as_schedule_single_action' )->never(); - Functions\expect( 'update_option' )->never(); + $scheduled = array(); + Functions\when( 'as_schedule_single_action' )->alias( + function ( int $when, string $hook ) use ( &$scheduled ): int { + $scheduled[] = $hook; + + return 1; + } + ); + $written = array(); + Functions\when( 'update_option' )->alias( + function ( string $key, $value ) use ( &$written ): bool { + $written[] = $key; + + return true; + } + ); WarmupSitemap::runTailTick(); + + $this->assertSame( array(), $scheduled, 'with the flag off, nothing gets scheduled.' ); + $this->assertSame( array(), $written, 'with the flag off, nothing gets written.' ); } } From 0cd5d1afdce258c41f12131adce30ec7df3cbee2 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Wed, 26 Aug 2026 18:37:47 +0200 Subject: [PATCH 15/15] fix(warmup): rebase onto the curated list, and report the cursor honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase onto main, which has moved by #143 and #141. Three conflicts, all in one place: register() grew a third parameter on both sides independently -- array $curated on main, bool $tail here. $curated keeps the third position because it has shipped since 1.40.0 and moving it would break every direct caller; the tail parameters follow it. The call site in StarterBase passes all three, and both new properties are kept. declare(strict_types=1) turned the eleven stale test call sites into TypeErrors rather than into a silently wrong third argument, so the conflict could not be resolved wrongly and stay quiet. Those call sites now pass array() for curated. advanceCursor() used to return true unconditionally. A cursor write that did not land was reported as success, and every later tick then repeats the same batch, forever, with nothing in any log to say so. It now reads the cursor back and answers whether the stored value is the one this tick wanted -- not update_option()'s return, which answers false for "nothing changed" as well as for "did not work". scheduleNextTailTick() stays unguarded, and now says why. The obvious complaint is that scheduleTailTick() checks as_next_scheduled_action() and this one does not. Measured against a live Action Scheduler: with the action in-progress, as_next_scheduled_action() still answers true and as_schedule_single_action( …, $unique = true ) refuses and returns 0. Either guard would stop a tick from scheduling its own successor and the drain would end after one batch. The residual risk -- two overlapping ticks starting two chains -- is left open and named, because the obvious fix is worse than the defect. Four tests added where the two features actually meet: a curated URL inside the cap never reaches the tail, one pushed past the cap appears in it exactly once, head and tail together cover every record without repeating one, and a curated-only set still produces a tail when the sitemap is unreachable. That last one matters because an unreachable sitemap is the failure #142 was about. The TailStore test stubbed get_option() as a constant, so a successful write read back as a failure. It models a store now. One test written alongside it was dropped rather than kept green: it described a concurrent tick storing an identical cursor, which the guard makes unreachable before the read-back is ever consulted. Refs #138 --- src/Breeze/TailStore.php | 20 ++++- src/Breeze/WarmupSitemap.php | 18 +++++ tests/Unit/Breeze/TailStoreTest.php | 30 +++++++- .../BuildOrderedUrlsTailTest.php | 73 +++++++++++++++++++ .../WarmupSitemap/TailMultisiteGuardTest.php | 4 +- .../Breeze/WarmupSitemap/TailRefreshTest.php | 4 +- .../Breeze/WarmupSitemap/TailTickTest.php | 2 +- .../StarterBase/BreezeWarmupTailSetupTest.php | 6 +- 8 files changed, 142 insertions(+), 15 deletions(-) diff --git a/src/Breeze/TailStore.php b/src/Breeze/TailStore.php index 76bd5fb..aeadece 100644 --- a/src/Breeze/TailStore.php +++ b/src/Breeze/TailStore.php @@ -131,8 +131,22 @@ public static function advanceCursor( array $expected, int $newIndex, string $ha return false; } - update_option( self::CURSOR_OPTION, array( 'index' => max( 0, $newIndex ), 'hash' => $hash ), false ); - - return true; + $wanted = array( 'index' => max( 0, $newIndex ), 'hash' => $hash ); + + update_option( self::CURSOR_OPTION, $wanted, false ); + + // The return value is read back rather than taken from update_option(), + // which answers false for a write that changed nothing as well as for + // one that failed. Both happen here: a concurrent tick can have stored + // the identical cursor already, and that is success, not failure. So + // the question asked is the one the caller actually cares about -- + // does the stored cursor now say what this tick wanted? + // + // It matters because the caller acts on the answer. A failed write left + // reported as success means every later tick repeats the same batch + // forever, with nothing in any log to say so. + $stored = self::readCursor(); + + return $stored['index'] === $wanted['index'] && $stored['hash'] === $wanted['hash']; } } diff --git a/src/Breeze/WarmupSitemap.php b/src/Breeze/WarmupSitemap.php index 80937c2..71f207a 100644 --- a/src/Breeze/WarmupSitemap.php +++ b/src/Breeze/WarmupSitemap.php @@ -547,6 +547,24 @@ private static function scheduleNextTailTick(): void { return; } + // Deliberately NOT guarded by as_next_scheduled_action(), and not + // scheduled with $unique either, even though scheduleTailTick() uses + // the guard a few lines up. The difference is where each one is called + // from: that one runs on a purge, outside any tick; this one runs + // INSIDE the tick whose own action is still on the schedule. + // + // Measured against a live Action Scheduler rather than assumed. With + // the action marked in-progress, as_next_scheduled_action() still + // answers true and as_schedule_single_action( …, $unique = true ) + // refuses and returns 0. So either form would make a tick decline to + // schedule its own successor, and the drain would stop after one batch + // -- a silent stop, since nothing reports a chain that simply ends. + // + // The cost of leaving it unguarded is that two overlapping ticks start + // two chains, which then run in parallel at double the intended pace. + // Closing that needs a successor key that can exclude the running + // action; it is not closed here, and the asymmetry above is the reason + // the obvious fix is worse than the defect. as_schedule_single_action( time() + self::TAIL_INTERVAL, self::TAIL_HOOK ); } diff --git a/tests/Unit/Breeze/TailStoreTest.php b/tests/Unit/Breeze/TailStoreTest.php index 8bfa061..34155e1 100644 --- a/tests/Unit/Breeze/TailStoreTest.php +++ b/tests/Unit/Breeze/TailStoreTest.php @@ -103,14 +103,25 @@ function ( string $key, $value, $auto = null ) use ( &$written, &$autoload ): bo } public function test_advance_writes_when_the_cursor_is_unchanged(): void { - $current = array( 'index' => 100, 'hash' => 'h' ); - Functions\when( 'get_option' )->justReturn( $current ); - $written = null; + // The stub is a store, not a constant. advanceCursor() reads the cursor + // back to decide what to report, so a get_option() frozen at the old + // value would make a successful write look like a failed one. + $current = array( 'index' => 100, 'hash' => 'h' ); + $stored = $current; + $written = null; $autoload = null; + Functions\when( 'get_option' )->alias( + // A normal closure, not an arrow function: `fn` captures by value at + // definition, so the store would answer with its first state forever. + function ( string $key, $default = false ) use ( &$stored ) { + return $stored; + } + ); Functions\when( 'update_option' )->alias( - function ( string $key, $value, $auto = null ) use ( &$written, &$autoload ): bool { + function ( string $key, $value, $auto = null ) use ( &$written, &$autoload, &$stored ): bool { $written = $value; $autoload = $auto; + $stored = $value; return true; } @@ -122,6 +133,17 @@ function ( string $key, $value, $auto = null ) use ( &$written, &$autoload ): bo $this->assertFalse( $autoload, 'the cursor must never autoload; every request would otherwise load it' ); } + public function test_a_write_that_does_not_land_is_reported_as_failure(): void { + // The defect this pins: the outcome used to be hardcoded true. A cursor + // write that fails then leaves every later tick repeating the same + // batch, forever, with nothing in any log to say so. + $current = array( 'index' => 100, 'hash' => 'h' ); + Functions\when( 'get_option' )->justReturn( $current ); + Functions\when( 'update_option' )->justReturn( false ); + + $this->assertFalse( TailStore::advanceCursor( $current, 200, 'h' ) ); + } + public function test_advance_is_discarded_when_a_purge_reset_the_cursor(): void { // The tick read index 100, a purge reset to 0 mid-flight. Writing 200 // would undo the reset and strand everything below it. diff --git a/tests/Unit/Breeze/WarmupSitemap/BuildOrderedUrlsTailTest.php b/tests/Unit/Breeze/WarmupSitemap/BuildOrderedUrlsTailTest.php index 20fa3bb..dc1abe2 100644 --- a/tests/Unit/Breeze/WarmupSitemap/BuildOrderedUrlsTailTest.php +++ b/tests/Unit/Breeze/WarmupSitemap/BuildOrderedUrlsTailTest.php @@ -93,6 +93,79 @@ public function test_tail_excludes_everything_that_made_the_cap(): void { $this->assertSame( array( 'https://example.test/dropped/' ), $built['tail'] ); } + public function test_a_curated_url_inside_the_cap_never_reaches_the_tail(): void { + // The two features meet here, and the question they raise is whether the + // tail should exclude curated entries. It must not, and it also does not + // have to: the split subtracts everything already kept, so no URL can be + // in both lists. Double warming is impossible by construction rather + // than by an exclusion someone has to remember. + $records = array( + $this->record( 'https://example.test/curated/', array( 'manual' => true, 'source' => 'curated' ) ), + $this->record( 'https://example.test/ordinary/' ), + ); + + $built = WarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, self::NOW, 1 ); + + $this->assertSame( array( 'https://example.test/curated/' ), $built['urls'] ); + $this->assertNotContains( 'https://example.test/curated/', $built['tail'] ); + } + + public function test_a_curated_url_pushed_past_the_cap_appears_in_the_tail_exactly_once(): void { + // The case an exclusion would break. A curated entry earns the manual + // weight and nothing else -- no freshness, no type -- so an ordinary + // menu page with a types weight outranks it and pushes it out. Dropping + // it from the tail as well would leave the page a project explicitly + // named as the only one warmed by nobody. + $weights = Scorer::DEFAULT_WEIGHTS; + $weights['types']['post'] = 600; + + $records = array( + $this->record( 'https://example.test/curated/', array( 'manual' => true, 'source' => 'curated' ) ), + $this->record( 'https://example.test/hot/', array( 'menu' => true, 'type' => 'post' ) ), + ); + + $built = WarmupSitemap::buildOrderedUrls( $records, $weights, self::NOW, 1 ); + + $this->assertSame( array( 'https://example.test/hot/' ), $built['urls'] ); + $this->assertSame( array( 'https://example.test/curated/' ), $built['tail'] ); + } + + public function test_head_and_tail_together_cover_every_record_without_repeating_one(): void { + // The invariant the two features share. Neither list is meaningful on + // its own: the head promises the most valuable pages are warm now, the + // tail promises the rest follow, and together they must be the whole + // set with nothing counted twice. + $records = array(); + for ( $i = 0; $i < 12; $i++ ) { + $records[] = $this->record( "https://example.test/p{$i}/" ); + } + $records[] = $this->record( 'https://example.test/curated/', array( 'manual' => true, 'source' => 'curated' ) ); + + $built = WarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, self::NOW, 5 ); + + $all = array_merge( $built['urls'], $built['tail'] ); + + $this->assertCount( 13, $all, 'every record is placed' ); + $this->assertSame( $all, array_unique( $all ), 'and none is placed twice' ); + $this->assertCount( 5, $built['urls'] ); + } + + public function test_a_curated_only_set_still_produces_a_tail(): void { + // The sitemap can be unreachable -- that is the failure #142 was about + // -- and the curated list is then the only source of records. The tail + // has to work from it alone, or the drain silently covers nothing on + // exactly the site that needs it most. + $records = array( + $this->record( 'https://example.test/a/', array( 'manual' => true, 'source' => 'curated' ) ), + $this->record( 'https://example.test/b/', array( 'manual' => true, 'source' => 'curated' ) ), + ); + + $built = WarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, self::NOW, 1 ); + + $this->assertCount( 1, $built['urls'] ); + $this->assertCount( 1, $built['tail'] ); + } + public function test_tail_is_empty_when_everything_fits(): void { $records = array( $this->record( 'https://example.test/a/' ) ); diff --git a/tests/Unit/Breeze/WarmupSitemap/TailMultisiteGuardTest.php b/tests/Unit/Breeze/WarmupSitemap/TailMultisiteGuardTest.php index e390989..fa5616a 100644 --- a/tests/Unit/Breeze/WarmupSitemap/TailMultisiteGuardTest.php +++ b/tests/Unit/Breeze/WarmupSitemap/TailMultisiteGuardTest.php @@ -46,7 +46,7 @@ function ( string $tag ) use ( &$actions ) { } ); - WarmupSitemap::register( true, null, true, 100 ); + WarmupSitemap::register( true, null, array(), true, 100 ); $this->assertNotContains( WarmupSitemap::TAIL_HOOK, $actions ); $this->assertNotContains( 'breeze_clear_all_cache', $actions ); @@ -66,7 +66,7 @@ function ( string $tag ) use ( &$actions ) { } ); - WarmupSitemap::register( true, null, true, 100 ); + WarmupSitemap::register( true, null, array(), true, 100 ); $this->assertContains( WarmupSitemap::TAIL_HOOK, $actions ); $this->assertContains( 'breeze_clear_all_cache', $actions ); diff --git a/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php b/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php index a21380d..958f5b9 100644 --- a/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php +++ b/tests/Unit/Breeze/WarmupSitemap/TailRefreshTest.php @@ -142,7 +142,7 @@ function ( $key, $value, $autoload ) use ( &$writtenTails ) { } ); - WarmupSitemap::register( true, null, true ); + WarmupSitemap::register( true, null, array(), true ); WarmupSitemap::runRefresh(); $this->assertNotSame( array(), $writtenTails, 'runRefresh() must write a tail while tail draining is on.' ); @@ -182,7 +182,7 @@ function ( $key, $value, $autoload ) use ( &$tailWritten ) { } ); - WarmupSitemap::register( false, null, true ); + WarmupSitemap::register( false, null, array(), true ); WarmupSitemap::runRefresh(); $this->assertFalse( $tailWritten, '$tail alone (without $priority) must not enable tail draining.' ); diff --git a/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php b/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php index 6665341..3755603 100644 --- a/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php +++ b/tests/Unit/Breeze/WarmupSitemap/TailTickTest.php @@ -41,7 +41,7 @@ private function enableTail( int $batch = 2 ): void { Functions\when( 'as_next_scheduled_action' )->justReturn( false ); Functions\when( 'as_schedule_single_action' )->justReturn( 1 ); - WarmupSitemap::register( true, null, true, $batch ); + WarmupSitemap::register( true, null, array(), true, $batch ); } public function test_skips_the_tick_while_breeze_is_still_warming(): void { diff --git a/tests/Unit/StarterBase/BreezeWarmupTailSetupTest.php b/tests/Unit/StarterBase/BreezeWarmupTailSetupTest.php index c6dcfcf..253f7e7 100644 --- a/tests/Unit/StarterBase/BreezeWarmupTailSetupTest.php +++ b/tests/Unit/StarterBase/BreezeWarmupTailSetupTest.php @@ -50,7 +50,7 @@ public function test_tail_on_wires_the_tick_and_the_purge_hook(): void { $actions = array(); $this->captureActions( $actions ); - WarmupSitemap::register( true, null, true, 100 ); + WarmupSitemap::register( true, null, array(), true, 100 ); $this->assertContains( array( WarmupSitemap::TAIL_HOOK, 10 ), $actions ); $this->assertContains( array( 'breeze_clear_all_cache', 1000 ), $actions ); @@ -60,7 +60,7 @@ public function test_tail_off_wires_neither(): void { $actions = array(); $this->captureActions( $actions ); - WarmupSitemap::register( true, null, false, 100 ); + WarmupSitemap::register( true, null, array(), false, 100 ); $tags = array_column( $actions, 0 ); $this->assertNotContains( WarmupSitemap::TAIL_HOOK, $tags ); @@ -71,7 +71,7 @@ public function test_tail_without_priority_wires_neither(): void { $actions = array(); $this->captureActions( $actions ); - WarmupSitemap::register( false, null, true, 100 ); + WarmupSitemap::register( false, null, array(), true, 100 ); $tags = array_column( $actions, 0 ); $this->assertNotContains( WarmupSitemap::TAIL_HOOK, $tags );