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 / NGramRebuildProgressState.php

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

351 lines 13.1 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 /**
8 * The n-gram cache rebuild's progress record: where the rebuild has got to,
9 * whether it finished, and how many consecutive ticks have failed.
10 *
11 * Why this is its own module rather than fields on the batch runner: the
12 * rebuild's progress is read and written by seven different places (the batch
13 * runner, NGramCacheRebuildScheduler, NGramCoveragePolicy,
14 * DatabaseUpgradeNGramCacheInitializer, PluginLogicLifecycle, WPCLICommands and
15 * the uninstaller), each of which used to spell the option names as raw string
16 * literals. A coordination key repeated across seven files with no single owner
17 * is a rename waiting to go half-finished.
18 *
19 * It also owns one invariant that is easy to break and expensive when broken:
20 * the "initialized" flag is the field every reader gates on, so it must be the
21 * LAST write of a completion sequence. Written first -- as it was -- a process
22 * death between the writes strands "initialized" beside a stale cursor and a
23 * pending-site list that contradict it, and nothing reconciles the two
24 * afterwards. Keeping the completion sequence behind markComplete() /
25 * markNetworkComplete() means no caller can get the order wrong.
26 *
27 * Multisite-aware only through the option store it is handed: on a
28 * network-activated install the store routes to site options, so the whole
29 * network shares one progress record.
30 */
31 class ABJ_404_Solution_NGramRebuildProgressState {
32
33 /**
34 * Coordination keys. Public so the readers listed above can stop spelling
35 * them by hand.
36 */
37 const OPTION_PENDING_SITES = 'abj404_ngram_pending_sites';
38 const OPTION_SITES_COMPLETED = 'abj404_ngram_sites_completed';
39 const OPTION_LAST_SITE_ID = 'abj404_ngram_last_site_id';
40 const OPTION_TOTAL_SITES = 'abj404_ngram_total_sites';
41 const OPTION_CURRENT_SITE_OFFSET = 'abj404_ngram_current_site_offset';
42 const OPTION_REBUILD_OFFSET = 'abj404_ngram_rebuild_offset';
43 const OPTION_CACHE_INITIALIZED = 'abj404_ngram_cache_initialized';
44 const OPTION_CONSECUTIVE_FAILURES = 'abj404_ngram_consecutive_failures';
45
46 /**
47 * Consecutive failing ticks before the chain stops re-arming itself.
48 * Retrying forever would hammer a host that is genuinely broken; stopping
49 * leaves the cache honestly uninitialized so a later rebuild resumes it.
50 */
51 const MAX_CONSECUTIVE_FAILURES = 5;
52
53 /** @var ABJ_404_Solution_NGramNetworkOptionStore */
54 private $optionStore;
55
56 /**
57 * Which cursor cursor()/setCursor() address. A cron tick is either draining
58 * a network or a single site, never both, so the mode is chosen once per
59 * tick and the shared drain loop then needs no idea which it is in.
60 *
61 * @var bool
62 */
63 private $networkMode = false;
64
65 /**
66 * @param ABJ_404_Solution_NGramNetworkOptionStore $optionStore
67 */
68 public function __construct($optionStore) {
69 $this->optionStore = $optionStore;
70 }
71
72 /**
73 * Read an option as an int, tolerating the string forms the options table
74 * returns.
75 *
76 * @param string $key
77 * @param int $default
78 * @return int
79 */
80 private function readInt(string $key, int $default = 0): int {
81 $raw = $this->optionStore->getOption($key, $default);
82 return is_scalar($raw) ? (int)$raw : $default;
83 }
84
85 /**
86 * Address the per-site cursor of a network walk.
87 *
88 * @return void
89 */
90 public function useNetworkCursor(): void {
91 $this->networkMode = true;
92 }
93
94 /**
95 * Address the single-site rebuild cursor.
96 *
97 * @return void
98 */
99 public function useSingleSiteCursor(): void {
100 $this->networkMode = false;
101 }
102
103 /** @return int The active cursor. */
104 public function cursor(): int {
105 return $this->networkMode ? $this->currentSiteOffset() : $this->singleSiteOffset();
106 }
107
108 /**
109 * @param int $offset
110 * @return void
111 */
112 public function setCursor(int $offset): void {
113 if ($this->networkMode) {
114 $this->setCurrentSiteOffset($offset);
115 return;
116 }
117 $this->setSingleSiteOffset($offset);
118 }
119
120 /** @return int Cursor for the single-site rebuild. */
121 public function singleSiteOffset(): int {
122 return $this->readInt(self::OPTION_REBUILD_OFFSET);
123 }
124
125 /**
126 * @param int $offset
127 * @return void
128 */
129 public function setSingleSiteOffset(int $offset): void {
130 $this->optionStore->updateOption(self::OPTION_REBUILD_OFFSET, $offset);
131 }
132
133 /** @return int Cursor within the multisite network's current site. */
134 public function currentSiteOffset(): int {
135 return $this->readInt(self::OPTION_CURRENT_SITE_OFFSET);
136 }
137
138 /**
139 * @param int $offset
140 * @return void
141 */
142 public function setCurrentSiteOffset(int $offset): void {
143 $this->optionStore->updateOption(self::OPTION_CURRENT_SITE_OFFSET, $offset);
144 }
145
146 /**
147 * How many network sites have been fully drained. Progress reporting only:
148 * nothing decides where the walk goes next, or whether it is finished, from
149 * this number.
150 *
151 * It used to be both, and that was the defect. A count doubling as a
152 * POSITION means the walk asks for "the site at position N" of a list whose
153 * positions are assigned at read time, so a site deleted earlier in the
154 * list slides every later site down one and the next tick steps over the
155 * one in the gap. The walk is keyed on {@see lastCompletedSiteId()} now,
156 * which names the same site however the list changes around it.
157 *
158 * @return int
159 */
160 public function sitesCompleted(): int {
161 $this->migrateToKeysetWalk();
162 return $this->readInt(self::OPTION_SITES_COMPLETED);
163 }
164
165 /**
166 * The last site id this walk finished; 0 before the first one lands.
167 *
168 * This is the walk's cursor. Blog ids are immutable and ascending, so the
169 * next site is always "the smallest id greater than this", a question whose
170 * answer cannot be moved by a site being created or deleted elsewhere in
171 * the network.
172 *
173 * @return int
174 */
175 public function lastCompletedSiteId(): int {
176 $this->migrateToKeysetWalk();
177 return max(0, $this->readInt(self::OPTION_LAST_SITE_ID));
178 }
179
180 /**
181 * Retire the site just drained and move the cursor onto it.
182 *
183 * The write order is the point, twice over. The per-site row cursor is
184 * cleared FIRST: a death between the writes then re-drains the current site
185 * from 0 -- idempotent, and it costs one pass -- whereas advancing first
186 * makes the next site inherit this site's row offset and silently skip that
187 * many rows. The walk cursor moves before the completed COUNT for the same
188 * reason in reverse: the count is only reporting, so a death between them
189 * costs a display number, never a site.
190 *
191 * @param int $completedSiteId The site that just finished draining.
192 * @return void
193 */
194 public function advanceToNextSite(int $completedSiteId): void {
195 $this->setCurrentSiteOffset(0);
196 $this->optionStore->updateOption(self::OPTION_LAST_SITE_ID, max(0, $completedSiteId));
197 $this->optionStore->updateOption(self::OPTION_SITES_COMPLETED, $this->sitesCompleted() + 1);
198 }
199
200 /**
201 * Seed the network walk.
202 *
203 * @param int $totalSites Sites the network held when the walk began, for
204 * progress reporting only.
205 * @return void
206 */
207 public function beginNetworkWalk(int $totalSites): void {
208 $this->optionStore->updateOption(self::OPTION_SITES_COMPLETED, 0);
209 // Written explicitly rather than left absent: an absent cursor is what
210 // marks a pre-keyset record, and a fresh walk must not look like one.
211 $this->optionStore->updateOption(self::OPTION_LAST_SITE_ID, 0);
212 $this->setTotalSites($totalSites);
213 $this->setCurrentSiteOffset(0);
214 }
215
216 /** @return bool Whether the network walk has been seeded at all. */
217 public function networkWalkStarted(): bool {
218 $this->migrateToKeysetWalk();
219 return $this->optionStore->getOption(self::OPTION_TOTAL_SITES, null) !== null;
220 }
221
222 /**
223 * Bring a rebuild that was mid-flight under an older progress format onto
224 * the keyset cursor, and drop the formats it replaced so this runs once.
225 *
226 * Both older formats recorded a POSITION (a pending-site list, then a
227 * completed count) and neither can be translated into a site id without
228 * re-reading the list by position -- the very read that can step over a
229 * site. So an in-flight walk with no cursor RESTARTS at the first site.
230 * Re-draining sites is idempotent and costs one pass of cron work; guessing
231 * the cursor can lose a site for the whole rebuild, and losing one is the
232 * failure this cursor exists to make impossible.
233 *
234 * @return void
235 */
236 private function migrateToKeysetWalk(): void {
237 $legacyPendingList = $this->optionStore->getOption(self::OPTION_PENDING_SITES, null);
238 $hasCursor = $this->optionStore->getOption(self::OPTION_LAST_SITE_ID, null) !== null;
239 $walkInFlight = $this->optionStore->getOption(self::OPTION_TOTAL_SITES, null) !== null;
240
241 if ($legacyPendingList === null && ($hasCursor || !$walkInFlight)) {
242 return;
243 }
244
245 if ($legacyPendingList !== null) {
246 $this->optionStore->updateOption(self::OPTION_PENDING_SITES, null);
247 }
248 if (!$walkInFlight || $hasCursor) {
249 // Nothing in flight to carry over, or it is already on the cursor.
250 return;
251 }
252
253 $this->optionStore->updateOption(self::OPTION_SITES_COMPLETED, 0);
254 $this->setCurrentSiteOffset(0);
255 $this->optionStore->updateOption(self::OPTION_LAST_SITE_ID, 0);
256 }
257
258 /**
259 * @param int $default Reported when the total was never recorded.
260 * @return int
261 */
262 public function totalSites(int $default = 0): int {
263 return $this->readInt(self::OPTION_TOTAL_SITES, $default);
264 }
265
266 /**
267 * @param int $total
268 * @return void
269 */
270 public function setTotalSites(int $total): void {
271 $this->optionStore->updateOption(self::OPTION_TOTAL_SITES, $total);
272 }
273
274 /** @return bool Whether the cache has been recorded as fully built. */
275 public function isInitialized(): bool {
276 $raw = $this->optionStore->getOption(self::OPTION_CACHE_INITIALIZED, '');
277 return is_scalar($raw) && (string)$raw === '1';
278 }
279
280 /** @return string The raw flag value, for diagnostics that report it verbatim. */
281 public function rawInitializedValue(): string {
282 $raw = $this->optionStore->getOption(self::OPTION_CACHE_INITIALIZED, 'not set');
283 return is_scalar($raw) ? (string)$raw : 'not set';
284 }
285
286 /**
287 * Record a single-site rebuild as complete.
288 *
289 * Order is the point: the cursor is cleared and the failure count reset
290 * BEFORE the flag readers gate on is set, so there is no window in which
291 * "initialized" coexists with progress that contradicts it.
292 *
293 * @return void
294 */
295 public function markComplete(): void {
296 $this->setSingleSiteOffset(0);
297 $this->clearFailures();
298 $this->optionStore->updateOption(self::OPTION_CACHE_INITIALIZED, '1');
299 }
300
301 /**
302 * Record a whole network as complete. Same ordering rule as markComplete().
303 *
304 * @return void
305 */
306 public function markNetworkComplete(): void {
307 $this->optionStore->updateOption(self::OPTION_PENDING_SITES, null);
308 $this->optionStore->updateOption(self::OPTION_SITES_COMPLETED, null);
309 $this->optionStore->updateOption(self::OPTION_LAST_SITE_ID, null);
310 $this->optionStore->updateOption(self::OPTION_TOTAL_SITES, null);
311 $this->optionStore->updateOption(self::OPTION_CURRENT_SITE_OFFSET, null);
312 $this->clearFailures();
313 $this->optionStore->updateOption(self::OPTION_CACHE_INITIALIZED, '1');
314 }
315
316 /**
317 * Count one failed tick.
318 *
319 * @return int The new consecutive-failure total.
320 */
321 public function recordFailure(): int {
322 $failures = $this->readInt(self::OPTION_CONSECUTIVE_FAILURES) + 1;
323 $this->optionStore->updateOption(self::OPTION_CONSECUTIVE_FAILURES, $failures);
324 return $failures;
325 }
326
327 /**
328 * Failing ticks in a row, 0 on a healthy chain.
329 *
330 * Exposed as a reader because the retry cadence is a function of it:
331 * {@see ABJ_404_Solution_NGramRebuildRetryPolicy} answers "how long until
332 * the next attempt" from this number, so the count and the delay cannot
333 * disagree about how far into an outage the chain is.
334 *
335 * @return int
336 */
337 public function consecutiveFailures(): int {
338 return max(0, $this->readInt(self::OPTION_CONSECUTIVE_FAILURES));
339 }
340
341 /** @return void */
342 public function clearFailures(): void {
343 $this->optionStore->updateOption(self::OPTION_CONSECUTIVE_FAILURES, 0);
344 }
345
346 /** @return bool Whether the chain has failed too often to keep retrying. */
347 public function hasExhaustedRetries(): bool {
348 return $this->readInt(self::OPTION_CONSECUTIVE_FAILURES) >= self::MAX_CONSECUTIVE_FAILURES;
349 }
350 }
351