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

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

278 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 /**
8 * Decides whether the asynchronous N-gram cache rebuild needs to be started
9 * or resumed, and arms the WP-Cron chain when it does.
10 *
11 * Arming is a decision with many callers -- the 404 request path on an empty
12 * cache, the daily reconciler when its backlog is beyond incremental repair,
13 * the admin rebuild button, the activation/upgrade initializer -- and it is
14 * the half of the rebuild lifecycle where the "is a rebuild already running?"
15 * question has to be answered correctly. Running the batches once armed is a
16 * separate concern with exactly one caller (the cron callback) and lives in
17 * {@see ABJ_404_Solution_NGramCacheRebuildBatchRunner}.
18 *
19 * Lock acquisition is owned by the orchestrator
20 * (DatabaseUpgradeNGram). This collaborator assumes the appropriate
21 * SyncUtils lock is already held when its methods are called.
22 *
23 * Cross-component caller note: `countTotalPagesForRebuild()` is
24 * reachable from outside (a multisite race-condition test invokes it
25 * through the upgrade dispatcher) so it must remain a stable public
26 * surface on this collaborator.
27 */
28 class ABJ_404_Solution_NGramCacheRebuildScheduler {
29
30 /** WP-Cron hook this scheduler enqueues. */
31 const REBUILD_CRON_HOOK = 'abj404_rebuild_ngram_cache_hook';
32
33 /**
34 * How far out the first tick of a chain is queued. Named rather than
35 * repeated as a literal because it is quoted back to the admin and used in
36 * the failure diagnostic, and three copies of a number that has to agree is
37 * three chances for them not to.
38 */
39 const START_DELAY_SECONDS = 30;
40
41 /** @var ABJ_404_Solution_DatabaseCore */
42 private $dbCore;
43
44 /** @var ABJ_404_Solution_Logging */
45 private $logger;
46
47 /** @var ABJ_404_Solution_NGramNetworkOptionStore */
48 private $optionStore;
49
50 /** @var ABJ_404_Solution_CronScheduler */
51 private $cronScheduler;
52
53 /**
54 * @param ABJ_404_Solution_DatabaseCore $dbCore
55 * @param ABJ_404_Solution_Logging $logger
56 * @param ABJ_404_Solution_NGramNetworkOptionStore $optionStore
57 * @param ABJ_404_Solution_CronScheduler|null $cronScheduler
58 */
59 public function __construct($dbCore, $logger, $optionStore, ?ABJ_404_Solution_CronScheduler $cronScheduler = null) {
60 $this->dbCore = $dbCore;
61 $this->logger = $logger;
62 $this->optionStore = $optionStore;
63 $this->cronScheduler = $cronScheduler instanceof ABJ_404_Solution_CronScheduler
64 ? $cronScheduler
65 : abj_cron_scheduler();
66 }
67
68 /**
69 * Enqueue a single cron-driven rebuild if one is not already
70 * pending or in progress.
71 *
72 * "In progress" is decided by whether the cron chain is still armed, NOT
73 * by whether the offset is non-zero. A partially-advanced offset with no
74 * queued event is a rebuild whose chain DIED (cron refused, request
75 * killed, plugin update mid-walk); reading that as "already in progress"
76 * is what left wedged rebuilds unrecoverable for months, because every
77 * caller that asked for a rebuild -- the 404 request path, the daily
78 * reconciler, the activation initializer -- was told one was already
79 * running. A stalled chain is resumed from its own offset rather than
80 * restarted from zero, so no completed work is repeated.
81 *
82 * @return bool true when scheduling succeeded or was a no-op
83 * because a rebuild is already pending/in progress;
84 * false when WP-Cron rejected the schedule call.
85 */
86 public function scheduleRebuild() {
87 $currentOffset = $this->currentRebuildOffset();
88
89 $hookName = self::REBUILD_CRON_HOOK;
90 $armedAt = $this->armedRebuildTimestamp();
91 if ($armedAt !== false) {
92 // Site-local, not date(): date() renders in whatever timezone the
93 // host process happens to default to, so the same event reads
94 // differently on two hosts and the line cannot be compared against
95 // anything else in the log.
96 $this->logger->debugMessage(
97 "N-gram cache rebuild already scheduled for "
98 . ABJ_404_Solution_SiteLocalTimestamp::format('Y-m-d H:i:s T', $armedAt));
99 return true;
100 }
101
102 $totalPages = $this->countTotalPagesForRebuild();
103
104 // A positive offset with nothing queued is a stalled walk. Resume it
105 // where it stopped. If total-page counting is unavailable we cannot
106 // tell "stalled mid-walk" from "finished", so treat in-flight state as
107 // resumable rather than discarding it.
108 $resuming = $currentOffset > 0 && ($totalPages <= 0 || $currentOffset < $totalPages);
109
110 if ($resuming) {
111 $this->logger->infoMessage(
112 "N-gram cache rebuild stalled at offset {$currentOffset} of {$totalPages} with no queued event. Resuming.");
113 } else {
114 $this->optionStore->updateOption('abj404_ngram_rebuild_offset', 0);
115 }
116
117 // Resolved to an absolute second ONCE, then used both to ask and to
118 // report. Computing it twice against the clock let the reported
119 // schedule time differ from the requested one whenever the two reads
120 // straddled a second boundary.
121 $scheduleTime = $this->cronScheduler->timestampAfter(self::START_DELAY_SECONDS);
122 // Resumed events carry the offset as their cron argument, exactly like
123 // the chain's own reschedules, so armedRebuildTimestamp() recognizes
124 // them and a second caller cannot start a parallel chain.
125 $scheduled = $this->cronScheduler->scheduleSingleAt(
126 $hookName, $scheduleTime, $resuming ? [$currentOffset] : []);
127
128 if ($scheduled === false) {
129 $this->reportScheduleFailure($hookName, $scheduleTime);
130 return false;
131 }
132
133 $context = is_multisite() ? ' (network-wide)' : '';
134 $this->logger->infoMessage(
135 "N-gram cache rebuild scheduled to start in " . self::START_DELAY_SECONDS . " seconds{$context}.");
136 return true;
137 }
138
139 /**
140 * Timestamp of the queued rebuild event, or false when the chain is not
141 * armed.
142 *
143 * Two probes are needed because WP-Cron identifies an event by hook AND
144 * arguments: the first tick of a chain (and every multisite reschedule) is
145 * enqueued with no arguments, while a single-site chain in flight
146 * reschedules itself as scheduleSingle($hook, <retry delay>, [$offset]) --
147 * the delay varies with the chain's backoff, the arguments do not. A no-args
148 * probe alone cannot see an in-flight chain, so it would report every
149 * healthy mid-walk rebuild as unarmed and spawn a second chain beside it.
150 *
151 * Public, and reading the offset itself rather than taking it as a
152 * parameter, because it is the ONE place that knows how this chain is
153 * identified in the cron store. Every other "is a rebuild queued?" question
154 * -- the failure diagnostic below, the Tools-tab rebuild button -- routes
155 * through here instead of issuing its own probe. Each private copy of that
156 * question was a single no-args probe, and each therefore answered "not
157 * queued" for a healthy in-flight single-site chain.
158 *
159 * @return int|false
160 */
161 public function armedRebuildTimestamp() {
162 $hookName = self::REBUILD_CRON_HOOK;
163 $nextScheduled = $this->cronScheduler->nextScheduled($hookName);
164 if ($nextScheduled !== false) {
165 return $nextScheduled;
166 }
167 $currentOffset = $this->currentRebuildOffset();
168 if ($currentOffset > 0) {
169 $nextForOffset = $this->cronScheduler->nextScheduled($hookName, [$currentOffset]);
170 if ($nextForOffset !== false) {
171 return $nextForOffset;
172 }
173 }
174 return false;
175 }
176
177 /**
178 * The rebuild cursor as stored, coerced to an offset.
179 *
180 * @return int
181 */
182 private function currentRebuildOffset(): int {
183 $raw = $this->optionStore->getOption('abj404_ngram_rebuild_offset', 0);
184 return is_scalar($raw) ? (int)$raw : 0;
185 }
186
187 /**
188 * Count the total number of permalink-cache rows the rebuild has
189 * to cover. In a network-activated multisite install this sums
190 * across every site; in single-site it returns the current site
191 * only.
192 *
193 * @return int
194 */
195 public function countTotalPagesForRebuild() {
196 if (!$this->optionStore->isNetworkActivated()) {
197 $permalinkCacheTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_permalink_cache');
198 return $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}");
199 }
200
201 $sites = get_sites(array('fields' => 'ids', 'number' => 0));
202 $totalPages = 0;
203
204 foreach ($sites as $blog_id) {
205 switch_to_blog($blog_id);
206 $permalinkCacheTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_permalink_cache');
207 $totalPages += $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}");
208 restore_current_blog();
209 }
210
211 return $totalPages;
212 }
213
214 /**
215 * Initial schedule failed: emit a diagnostic error log and, if a
216 * concurrent infra DB error is in play, surface it through the
217 * plugin-page admin-notice classifier.
218 */
219 private function reportScheduleFailure(string $hookName, int $scheduleTime): void {
220 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
221 $this->logger->errorMessage(
222 "Cannot schedule N-gram cache rebuild: WP-Cron is disabled (DISABLE_WP_CRON=true). " .
223 "Consider enabling WP-Cron or using server-side cron with a fallback mechanism."
224 );
225 return;
226 }
227
228 global $wpdb;
229
230 $cronDisabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON;
231 // Asked through the armed check rather than probed directly: WP-Cron
232 // identifies an event by hook AND args, and a resumed chain carries its
233 // offset as an argument, so a bare no-args probe here reported
234 // "Already scheduled: no" for a hook that had an event queued. Same
235 // defect, same hook, as production report 294 on the batch runner's own
236 // refusal report.
237 $alreadyScheduled = $this->armedRebuildTimestamp();
238 $dbError = !empty($wpdb->last_error) ? $wpdb->last_error : 'none';
239 $rawRebuildOffset = $this->optionStore->getOption('abj404_ngram_rebuild_offset', 'not set');
240 $rebuildOffset = is_scalar($rawRebuildOffset) ? (string)$rawRebuildOffset : 'not set';
241 $rawCacheInit = $this->optionStore->getOption('abj404_ngram_cache_initialized', 'not set');
242 $cacheInitialized = is_scalar($rawCacheInit) ? (string)$rawCacheInit : 'not set';
243
244 $errorMsg = sprintf(
245 "Failed to schedule N-gram cache rebuild. Hook: %s, Schedule time: %d (current: %d), " .
246 "Already scheduled: %s, WP-Cron disabled: %s, DB error: %s, " .
247 "Rebuild offset: %s, Cache initialized: %s, Multisite: %s, Blog ID: %d",
248 $hookName,
249 $scheduleTime,
250 $this->cronScheduler->now(),
251 // Site-local, not date(), for the same reason the batch runner's
252 // refusal report is: date() renders in the host process's default
253 // timezone, so the same event reads differently on two hosts.
254 $alreadyScheduled
255 ? ABJ_404_Solution_SiteLocalTimestamp::format('Y-m-d H:i:s T', (int)$alreadyScheduled)
256 : 'no',
257 $cronDisabled ? 'yes' : 'no',
258 $dbError,
259 $rebuildOffset,
260 $cacheInitialized,
261 is_multisite() ? 'yes' : 'no',
262 get_current_blog_id()
263 );
264
265 // Pattern 7 (defense-in-depth): a concurrent infra-level DB
266 // error (disk full, read-only, crashed table) may have
267 // contributed to wp_schedule_single_event() failing: surface
268 // the hosting cause as an admin notice while keeping the cron
269 // failure ERROR level so the user must act on it.
270 if (!empty($wpdb->last_error)) {
271 $this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($wpdb->last_error);
272 }
273
274 $this->logger->errorMessage($errorMsg);
275 }
276
277 }
278