| 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 |
|