PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / ngram / NGramRebuildDrain.php

NGramRebuildDrain.php in 404 Solution trunk, at includes/ngram/NGramRebuildDrain.php

272 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 require_once __DIR__ . '/NGramRebuildProgressState.php';
8
9 /**
10 * Drives the n-gram rebuilder across a row range, one bounded chunk of batches
11 * per call, and owns the single invariant that makes the rebuild trustworthy:
12 *
13 * THE CURSOR NEVER MOVES PAST ROWS THAT DID NOT REBUILD.
14 *
15 * Nothing ever revisits an offset the cursor has already passed, so any row the
16 * cursor skips is skipped for the life of that rebuild. Every defect this class
17 * was extracted from was a variation on breaking that one rule: advancing past a
18 * batch that threw, advancing past a batch that RETURNED failed rows, and
19 * advancing on a batch whose result could not be read at all.
20 *
21 * Why it is its own module rather than two private methods on the runner: the
22 * multisite and single-site cron paths both drain, and while the algorithm
23 * existed as two copies inside the runner the copies had already drifted apart
24 * in production -- one checked a condition the other dropped on the floor. One
25 * named owner is what makes a third path unable to get it wrong.
26 *
27 * Deliberately holds no logger and issues no SQL. A failure is REPORTED back in
28 * the outcome (see {@see drain()}'s `failureContext`) and the orchestrator
29 * decides what to do about it, so the policy that owns the cursor stays free of
30 * the presentation and cron concerns around it.
31 *
32 * Assumes the caller has already switched to whichever site is being rebuilt;
33 * this class rebuilds "whatever is currently switched in".
34 */
35 class ABJ_404_Solution_NGramRebuildDrain {
36
37 /**
38 * Rows rebuilt per batch, and batches per call.
39 *
40 * These are constants rather than parameters threaded down through the
41 * private methods deliberately. They were previously passed as two adjacent
42 * ints, which meant every call site could transpose them and silently
43 * change the cron workload by a factor of 2.5 with nothing to catch it.
44 * Constants make that transposition unrepresentable.
45 */
46 const BATCH_SIZE = 50;
47 const MAX_BATCHES_PER_RUN = 20;
48
49 /**
50 * The one operation this drain needs from its rebuilder.
51 *
52 * Held as a callable rather than as the whole object because rebuildCache()
53 * is genuinely all this class uses, and because the rebuilder arrives
54 * duck-typed: DatabaseUpgradeNGram::resolveNGramRebuilder() may hand over an
55 * ABJ_404_Solution_NGramRebuilder, a legacy facade, or any object exposing
56 * the method, so there is no single interface to typehint against.
57 *
58 * @var callable(int, int): mixed
59 */
60 private $rebuildCache;
61
62 /** @var ABJ_404_Solution_NGramRebuildProgressState */
63 private $progress;
64
65 /**
66 * @param callable $rebuildCache Bound rebuildCache() of the validated rebuilder.
67 * @param ABJ_404_Solution_NGramRebuildProgressState $progress Owns the cursor.
68 */
69 public function __construct($rebuildCache, ABJ_404_Solution_NGramRebuildProgressState $progress) {
70 $this->rebuildCache = $rebuildCache;
71 $this->progress = $progress;
72 }
73
74 /**
75 * Rebuild up to MAX_BATCHES_PER_RUN batches of the set currently switched
76 * in, advancing the active cursor only as each batch actually lands.
77 *
78 * At most one failure can be reported per call, because every failure path
79 * stops the loop where it is rather than carrying on past unrebuilt rows.
80 *
81 * @param int $totalRows Rows in the set being rebuilt.
82 * @param string $context Human-readable subject, for the failure message.
83 * @return array{offset:int, threw:bool, processed:int, success:int, rowsFailed:int, percent:float, failureContext:string|null}
84 */
85 public function drain(int $totalRows, string $context): array {
86 $offset = $this->progress->cursor();
87 $batchesProcessed = 0;
88 $processed = 0;
89 $success = 0;
90 $rowsFailed = 0;
91 $threw = false;
92 $failureContext = null;
93
94 while ($batchesProcessed < self::MAX_BATCHES_PER_RUN && $offset < $totalRows) {
95 try {
96 $stats = $this->runRebuildBatch($offset);
97 } catch (Throwable $e) {
98 // Do NOT advance the cursor. The rows in this batch were not
99 // rebuilt, and an advanced cursor is never revisited -- that is
100 // what silently skipped them and then let the caller declare the
101 // cache complete without them.
102 $failureContext = "Error during N-gram rebuild for {$context} at offset {$offset}: "
103 . $e->getMessage();
104 $threw = true;
105 break;
106 }
107
108 $processed += $stats['processed'];
109 $success += $stats['success'];
110 $rowsFailed += $stats['failed'];
111
112 if ($stats['failed'] > 0) {
113 // Same rule as the thrown-batch path, one level down: rows that
114 // did not rebuild must stay in front of the cursor. Advancing
115 // past a batch that reported failures skips exactly those rows,
116 // and nothing revisits an offset the cursor has passed -- which
117 // is the whole defect this class exists to make unrepresentable.
118 $failureContext = sprintf(
119 'N-gram rebuild reported %d failed row(s) for %s at offset %d; holding the cursor '
120 . 'so the next tick retries them.',
121 $stats['failed'], $context, $offset
122 );
123 break;
124 }
125
126 $offset += self::BATCH_SIZE;
127 $batchesProcessed++;
128 $this->progress->setCursor($offset);
129
130 if ($stats['processed'] < self::BATCH_SIZE) {
131 break;
132 }
133 }
134
135 return array(
136 'offset' => $offset,
137 'threw' => $threw,
138 'processed' => $processed,
139 'success' => $success,
140 'rowsFailed' => $rowsFailed,
141 'percent' => $totalRows > 0
142 ? (float)min(100, round(($offset / $totalRows) * 100, 1)) : 100.0,
143 'failureContext' => $failureContext,
144 );
145 }
146
147 /**
148 * Whether a drain covered its whole set with nothing left behind.
149 *
150 * Reaching the end of the set is not sufficient. Retiring on offset alone is
151 * how a partial cache gets published as complete, which is the same defect
152 * as advancing past a thrown batch, one level down.
153 *
154 * The loop already holds the cursor in front of any batch that reported
155 * failed rows, so today a drain cannot both carry failures and reach the
156 * end. The rowsFailed check stays anyway: it states the invariant where the
157 * CONSEQUENCE is (publishing the cache), not only where the cursor happens
158 * to be maintained, so a later change to the loop cannot quietly turn "some
159 * rows never rebuilt" back into "rebuild complete".
160 *
161 * @param array{offset:int, threw:bool, rowsFailed:int} $outcome
162 * @param int $totalRows
163 * @return bool
164 */
165 public static function isClean(array $outcome, int $totalRows): bool {
166 return !$outcome['threw']
167 && $outcome['rowsFailed'] === 0
168 && $outcome['offset'] >= $totalRows;
169 }
170
171 /**
172 * What stopped this drain, as text, or '' when nothing did.
173 *
174 * Kept here, beside the method that writes the field, because the
175 * orchestrator had grown three different readings of it: `=== null` to
176 * decide whether the tick counted as a success, `is_string()` to decide
177 * whether to ask the retry policy about it, and `is_string() && !== ''` to
178 * decide whether to record it against the consecutive-failure budget. Three
179 * readings of one field is three chances to disagree, and the two that test
180 * the TYPE disagree with the one that tests for null: they answer "nothing
181 * failed" for a value that is not null.
182 *
183 * NULL, and only null, is the absence of a failure. Anything else is a
184 * failure, and one whose description cannot be read says exactly that
185 * rather than disappearing -- dropping it would leave the tick unrecorded
186 * against the budget that stops the chain AND unmentioned in the log, so
187 * the rebuild would re-arm at the base cadence indefinitely with nothing
188 * anywhere saying why.
189 *
190 * @param array{failureContext?: mixed} $outcome
191 * @return string Empty only when the drain reported no failure at all.
192 */
193 public static function failureTextOf(array $outcome): string {
194 $failureContext = $outcome['failureContext'] ?? null;
195 if ($failureContext === null) {
196 return '';
197 }
198 if (is_string($failureContext) && $failureContext !== '') {
199 return $failureContext;
200 }
201 // Unclassifiable by the retry policy, which reads driver text, so this
202 // is treated as retryable -- the safe direction: the consecutive-failure
203 // budget still ends the chain, it just costs a few backed-off ticks to
204 // get there instead of one.
205 return 'N-gram rebuild stopped for a reason it could not describe (got '
206 . gettype($failureContext) . ').';
207 }
208
209 /**
210 * Rebuild one batch and return its stats.
211 *
212 * A rebuilder that does not report a processed count is a broken
213 * collaborator, and this method says so instead of substituting 0.
214 * Substituting 0 was indistinguishable from "this batch found no more
215 * rows", which is the loop's end-of-data signal -- so a rebuilder returning
216 * junk read as a finished rebuild and the cache was marked complete.
217 *
218 * Takes only the offset: the batch size is a class constant, so there is no
219 * pair of adjacent ints a caller can transpose.
220 *
221 * @param int $offset
222 * @return array{processed: int, success: int, failed: int}
223 * @throws RuntimeException When the rebuilder's result cannot be read.
224 */
225 private function runRebuildBatch(int $offset): array {
226 $stats = ($this->rebuildCache)(self::BATCH_SIZE, $offset);
227
228 if (!is_array($stats) || !isset($stats['processed']) || !is_numeric($stats['processed'])) {
229 throw new RuntimeException(
230 'N-gram rebuilder returned no readable processed count at offset ' . $offset .
231 ' (got ' . gettype($stats) . '); refusing to read that as end-of-data.'
232 );
233 }
234
235 // Range-check, not just presence-check. A negative count would walk the
236 // cursor BACKWARDS into an endless loop, and a count larger than the
237 // batch we asked for means the collaborator did something other than
238 // what was requested -- in both cases the number is not a description of
239 // this batch, and letting it advance the cursor publishes a cache whose
240 // coverage nobody can account for.
241 $processed = (int)$stats['processed'];
242 if ($processed < 0 || $processed > self::BATCH_SIZE) {
243 throw new RuntimeException(
244 'N-gram rebuilder reported ' . $processed . ' rows processed for a batch of '
245 . self::BATCH_SIZE . ' at offset ' . $offset . '; refusing to advance on a count '
246 . 'that cannot describe this batch.'
247 );
248 }
249
250 // The failed count is REQUIRED, not defaulted. Defaulting it to 0 makes
251 // an unreadable result look like a clean batch, and a clean batch is
252 // what lets isClean() publish the cache as complete.
253 if (!isset($stats['failed']) || !is_numeric($stats['failed'])) {
254 throw new RuntimeException(
255 'N-gram rebuilder returned no readable failed count at offset ' . $offset
256 . '; refusing to read an unreadable batch as a clean one.'
257 );
258 }
259 $success = isset($stats['success']) && is_numeric($stats['success']) ? (int)$stats['success'] : 0;
260 $failed = (int)$stats['failed'];
261
262 return [
263 'processed' => $processed,
264 // Clamped rather than trusted: these two only drive reporting and
265 // the completion guard, so an out-of-range value must not be able to
266 // make a run look cleaner than it was.
267 'success' => max(0, min($success, $processed)),
268 'failed' => max(0, min($failed, $processed)),
269 ];
270 }
271 }
272