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 / services / CronScheduler.php

CronScheduler.php in 404 Solution trunk, at includes/services/CronScheduler.php

490 lines 18.6 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 * Owns WordPress cron primitive access for plugin scheduling code.
9 *
10 * Domain services decide whether work is needed; this adapter owns hook names,
11 * schedule checks, wall-clock offsets, and clearing/unscheduling around the
12 * WordPress cron API.
13 *
14 * It performs the writes but does not judge them: what a refused write means,
15 * and what gets said about it, belongs to
16 * {@see ABJ_404_Solution_CronWriteOutcome} (whose reads go through
17 * {@see ABJ_404_Solution_ScheduledEventInspector}), because WordPress reports
18 * several already-satisfied outcomes as failures and believing them mails the
19 * plugin author an ERROR about work that was already done.
20 */
21 class ABJ_404_Solution_CronScheduler {
22
23 const HOOK_CLEANUP = 'abj404_cleanupCronAction';
24 const HOOK_GSC_FETCH = 'abj404_gsc_fetch_cron';
25 const HOOK_GSC_BACKGROUND_REFRESH = 'abj404_gsc_background_refresh';
26 const HOOK_UPDATE_PERMALINK_CACHE = 'abj404_updatePermalinkCacheAction';
27 const HOOK_UPDATE_LOGS_HITS_TABLE = 'abj404_updateLogsHitsTableAction';
28 const HOOK_SEND_DIGEST = 'abj404_send_digest';
29 const HOOK_REBUILD_NGRAM_CACHE = 'abj404_rebuild_ngram_cache_hook';
30 const HOOK_LOGSV2_CANONICAL_BACKFILL = 'abj404_logsv2_canonical_backfill';
31 const HOOK_REDIRECTS_DENORM_BACKFILL = 'abj404_redirects_denorm_backfill';
32 const HOOK_REDIRECTS_SORT_KEY_BACKFILL = 'abj404_redirects_sort_key_backfill';
33 const HOOK_SEND_QUEUED_REPORT = 'abj404_send_queued_report';
34 const HOOK_REFRESH_STATUS_COUNTS = 'abj404_refresh_status_counts';
35 const HOOK_REPAIR_COLLATIONS = 'abj404_repair_collations';
36 const HOOK_NETWORK_ACTIVATION = 'abj404_network_activation_hook';
37 const HOOK_NETWORK_ACTIVATION_BACKGROUND = 'abj404_network_activation_background';
38 const HOOK_NETWORK_UPGRADE_BACKGROUND = 'abj404_network_upgrade_background';
39 const HOOK_DUPLICATE_LEGACY = 'abj404_duplicateCronAction';
40 const HOOK_REMOVE_DUPLICATES_LEGACY = 'removeDuplicatesCron';
41 const HOOK_DELETE_OLD_REDIRECTS_LEGACY = 'deleteOldRedirectsCron';
42 // The staged view_done rebuild cron was removed in the denorm chain, but
43 // sites upgrading from a build that scheduled it may still carry the event;
44 // deactivation must defensively clear it (matches deleteBlogData + Uninstaller).
45 const HOOK_REBUILD_VIEW_DONE_LEGACY = 'abj404_rebuildViewDone';
46
47 /** @var callable(string,array<string,mixed>,callable):mixed|null */
48 private static $statusCountOperationTracer = null;
49
50 /** @var ABJ_404_Solution_Clock */
51 private $clock;
52
53 /** @var ABJ_404_Solution_CronWriteOutcome Decides what a refused write meant, and reports it. */
54 private $outcome;
55
56 /** @var ABJ_404_Solution_ScheduledEventInspector Read side of the cron store. */
57 private $inspector;
58
59 /**
60 * @param ABJ_404_Solution_Clock $clock
61 * @param ABJ_404_Solution_Logging|null $logger
62 * @param ABJ_404_Solution_ScheduledEventInspector|null $inspector Defaults to a plain one; it owns no state.
63 */
64 public function __construct(
65 ABJ_404_Solution_Clock $clock,
66 $logger = null,
67 ?ABJ_404_Solution_ScheduledEventInspector $inspector = null
68 ) {
69 $this->clock = $clock;
70 $this->inspector = $inspector !== null ? $inspector : new ABJ_404_Solution_ScheduledEventInspector();
71 $this->outcome = new ABJ_404_Solution_CronWriteOutcome($logger, $this->inspector);
72 }
73
74 /** @param callable(string,array<string,mixed>,callable):mixed|null $tracer */
75 public static function setStatusCountOperationTracer($tracer): void {
76 self::$statusCountOperationTracer = $tracer;
77 }
78
79 /** @return int */
80 public function now(): int {
81 return $this->clock->now();
82 }
83
84 /** @return string */
85 public function lastFailureDetail(): string {
86 return $this->outcome->lastFailureDetail();
87 }
88
89 /**
90 * @param string $hook
91 * @param array<int, mixed> $args
92 * @return int|false
93 */
94 public function nextScheduled(string $hook, array $args = array()) {
95 return self::traceStatusCountOperation(
96 $hook,
97 'next_scheduled_check',
98 function () use ($hook, $args) {
99 if (!function_exists('wp_next_scheduled')) {
100 return false;
101 }
102 return empty($args)
103 ? wp_next_scheduled($hook)
104 : wp_next_scheduled($hook, $this->listArgs($args));
105 }
106 );
107 }
108
109 /**
110 * Whether anything at all is queued for a hook, whatever arguments it
111 * carries.
112 *
113 * The question {@see nextScheduled()} cannot answer: WordPress identifies
114 * an event by hook AND arguments, so a no-args probe is blind to every
115 * chain that carries a cursor or a counter in its args. Callers that arm a
116 * self-rescheduling chain ask this first, so they recognize their own
117 * in-flight link instead of queueing a second one beside it.
118 *
119 * @param string $hook
120 * @return bool
121 */
122 public function hasAnyScheduledEvent(string $hook): bool {
123 return $this->inspector->anyEventIsStored($hook);
124 }
125
126 /** Make the next cron-store observation read durable cross-request state. */
127 public function refreshStoredEventReads(): void {
128 $this->inspector->refreshCronStoreReads();
129 }
130
131 /**
132 * @param string $hook
133 * @param array<int, mixed> $args
134 * @return bool
135 */
136 public function scheduleSingleIfMissing(string $hook, int $delaySeconds = 0, array $args = array()): bool {
137 if ($this->nextScheduled($hook, $args) !== false) {
138 return true;
139 }
140 return $this->scheduleSingle($hook, $delaySeconds, $args);
141 }
142
143 /**
144 * @param string $hook
145 * @param array<int, mixed> $args
146 * @return bool
147 */
148 public function scheduleSingle(string $hook, int $delaySeconds = 0, array $args = array()): bool {
149 return $this->scheduleSingleAt($hook, $this->timestampAfter($delaySeconds), $args);
150 }
151
152 /**
153 * @param string $hook
154 * @param array<int, mixed> $args
155 * @return bool
156 */
157 public function scheduleSingleAt(string $hook, int $timestamp, array $args = array()): bool {
158 if (!function_exists('wp_schedule_single_event')) {
159 $this->outcome->reportScheduleFailure(array(
160 'type' => 'single',
161 'hook' => $hook,
162 'recurrence' => null,
163 'timestamp' => $timestamp,
164 'args' => $args,
165 'errorCode' => 'cron_primitive_unavailable',
166 'detail' => 'wp_schedule_single_event unavailable',
167 'now' => $this->clock->now(),
168 ));
169 return false;
170 }
171 $scheduled = self::traceStatusCountOperation(
172 $hook,
173 'scheduling_write',
174 fn() => wp_schedule_single_event(
175 $timestamp,
176 $hook,
177 $this->listArgs($args),
178 true
179 )
180 );
181 if ($this->outcome->reportsFailure($scheduled)) {
182 return $this->outcome->resolveSingleWrite(array(
183 'writeResult' => $scheduled,
184 'hook' => $hook,
185 'args' => $args,
186 'timestamp' => $timestamp,
187 'now' => $this->clock->now(),
188 ));
189 }
190 return true;
191 }
192
193 /**
194 * @template T
195 * @param callable():T $work
196 * @return T
197 */
198 private static function traceStatusCountOperation(
199 string $hook,
200 string $operation,
201 callable $work
202 ) {
203 if ($hook !== self::HOOK_REFRESH_STATUS_COUNTS
204 || self::$statusCountOperationTracer === null) {
205 return $work();
206 }
207 return (self::$statusCountOperationTracer)(
208 $operation,
209 array('family' => 'status_refresh_cron'),
210 $work
211 );
212 }
213
214 /**
215 * @param string $hook
216 * @param string $recurrence
217 * @param array<int, mixed> $args
218 * @return bool
219 */
220 public function scheduleRecurringIfMissing(string $hook, string $recurrence, int $delaySeconds = 0, array $args = array()): bool {
221 if ($this->nextScheduled($hook, $args) !== false) {
222 return true;
223 }
224 return $this->scheduleRecurringAt(array(
225 'hook' => $hook,
226 'recurrence' => $recurrence,
227 'timestamp' => $this->timestampAfter($delaySeconds),
228 'args' => $args,
229 ));
230 }
231
232 /**
233 * Removes one identified occurrence without affecting sibling events.
234 *
235 * @param array{timestamp: int, hook: string, args: array<int, mixed>, expectedNextTimestamp: int} $request
236 * expectedNextTimestamp is what nextScheduled() must report afterwards on
237 * WordPress builds whose unschedule primitive returns no status of its own.
238 */
239 public function unscheduleAt(array $request): bool {
240 $timestamp = $request['timestamp'];
241 $hook = $request['hook'];
242 $args = $request['args'];
243 $expectedNextTimestamp = $request['expectedNextTimestamp'];
244 if (!function_exists('wp_unschedule_event')) {
245 $this->outcome->reportUnavailablePrimitive(array(
246 'verb' => 'unschedule',
247 'hook' => $hook,
248 'primitive' => 'wp_unschedule_event',
249 ));
250 return false;
251 }
252 $result = empty($args)
253 ? wp_unschedule_event($timestamp, $hook, array(), true)
254 : wp_unschedule_event($timestamp, $hook, $this->listArgs($args), true);
255 if ($this->outcome->reportsFailure($result)) {
256 return $this->outcome->resolveRemoval(array(
257 'writeResult' => $result,
258 'hook' => $hook,
259 'args' => $args,
260 'timestamp' => $timestamp,
261 ));
262 }
263 if ($result === null && $this->nextScheduled($hook, $args) !== $expectedNextTimestamp) {
264 return $this->outcome->reportRemovalNotVerified($hook, $timestamp);
265 }
266 return true;
267 }
268
269 /**
270 * @return bool
271 */
272 public function scheduleDailyInWindowIfMissing(string $hook, int $startHour, int $endHour): bool {
273 $startHour = max(0, min(23, $startHour));
274 $endHour = max(0, min(23, $endHour));
275 if ($endHour < $startHour) {
276 $endHour = $startHour;
277 }
278 $hourRange = max(1, $endHour - $startHour + 1);
279 $hour = $startHour + (random_int(0, 23) % $hourRange);
280 $timeForEvent = sprintf(
281 '%02d:%02d:%02d',
282 $hour,
283 random_int(10, 59),
284 random_int(10, 59)
285 );
286 // The requested [$startHour, $endHour] window is a WP-site-local
287 // off-peak window (e.g. "0-5am, when this site has the least
288 // traffic"). wp_schedule_event() below compares the resulting
289 // timestamp against WP-Cron's true-UTC clock, so the wall-clock
290 // hour must be anchored to the site's configured timezone
291 // (SiteTimezone) rather than PHP's implicit default timezone --
292 // otherwise the "local off-peak" window silently lands at the
293 // wrong local hour whenever the two timezones differ (e.g. a
294 // managed host running PHP in UTC for a site configured to
295 // America/Los_Angeles).
296 try {
297 $timestamp = (new DateTimeImmutable('today ' . $timeForEvent, ABJ_404_Solution_SiteTimezone::resolve()))->getTimestamp();
298 } catch (Exception $e) {
299 $this->outcome->reportScheduleFailure(array(
300 'type' => 'recurring',
301 'hook' => $hook,
302 'recurrence' => 'daily',
303 'timestamp' => 0,
304 'args' => array(),
305 'errorCode' => 'schedule_timestamp_calculation_failed',
306 'detail' => 'failed to calculate daily schedule timestamp: ' . $e->getMessage(),
307 'now' => $this->clock->now(),
308 ));
309 return false;
310 }
311 if ($this->nextScheduled($hook) !== false) {
312 return true;
313 }
314 return $this->scheduleRecurringAt(array(
315 'hook' => $hook,
316 'recurrence' => 'daily',
317 'timestamp' => $timestamp,
318 'args' => array(),
319 ));
320 }
321
322 /**
323 * @param array<int, mixed> $args
324 * @return void
325 */
326 public function clearHook(string $hook, array $args = array()): void {
327 if (!function_exists('wp_clear_scheduled_hook')) {
328 $this->outcome->reportUnavailablePrimitive(array(
329 'verb' => 'clear',
330 'hook' => $hook,
331 'primitive' => 'wp_clear_scheduled_hook',
332 ));
333 return;
334 }
335 empty($args) ? wp_clear_scheduled_hook($hook) : wp_clear_scheduled_hook($hook, $this->listArgs($args));
336 }
337
338 /**
339 * @param array<int, mixed> $args
340 * @return void
341 */
342 public function unscheduleAllOccurrences(string $hook, array $args = array()): void {
343 if (!function_exists('wp_unschedule_event')) {
344 $this->outcome->reportUnavailablePrimitive(array(
345 'verb' => 'unschedule',
346 'hook' => $hook,
347 'primitive' => 'wp_unschedule_event',
348 ));
349 return;
350 }
351 $timestamp = $this->nextScheduled($hook, $args);
352 while ($timestamp !== false) {
353 $result = empty($args)
354 ? wp_unschedule_event($timestamp, $hook, array(), true)
355 : wp_unschedule_event($timestamp, $hook, $this->listArgs($args), true);
356
357 // A refused removal leaves the occurrence exactly where it was, so
358 // the next read returns the same timestamp and asking again can
359 // only produce the same refusal. wp_unschedule_event() refuses on a
360 // failed cron-store write, and since WordPress 5.7 any plugin on
361 // the `pre_unschedule_event` filter can short-circuit it without
362 // removing anything.
363 if ($this->outcome->reportsFailure($result)) {
364 $this->outcome->resolveRemoval(array(
365 'writeResult' => $result,
366 'hook' => $hook,
367 'args' => $args,
368 'timestamp' => $timestamp,
369 ));
370 return;
371 }
372
373 $next = $this->nextScheduled($hook, $args);
374
375 // Terminate on lack of progress rather than on the primitive's
376 // answer alone. A short-circuiting filter can return a truthy value
377 // while removing nothing, and builds before 5.7 report no status at
378 // all, so "it said it worked" is not evidence the occurrence is
379 // gone. Advancing is.
380 if ($next === $timestamp) {
381 $this->outcome->reportRemovalNotVerified($hook, $timestamp);
382 return;
383 }
384
385 $timestamp = $next;
386 }
387 }
388
389 /**
390 * @param array<int, string>|null $hooks
391 * @return void
392 */
393 public function clearRegisteredHooks(?array $hooks = null): void {
394 foreach ($hooks ?? self::registeredHooks() as $hook) {
395 $this->unscheduleAllOccurrences($hook);
396 $this->unscheduleAllOccurrences($hook, array(''));
397 $this->clearHook($hook);
398 }
399 }
400
401 /**
402 * @return array<int, string>
403 */
404 public static function registeredHooks(): array {
405 return array(
406 self::HOOK_CLEANUP,
407 self::HOOK_GSC_FETCH,
408 self::HOOK_GSC_BACKGROUND_REFRESH,
409 self::HOOK_UPDATE_PERMALINK_CACHE,
410 self::HOOK_UPDATE_LOGS_HITS_TABLE,
411 self::HOOK_SEND_DIGEST,
412 self::HOOK_REBUILD_NGRAM_CACHE,
413 self::HOOK_LOGSV2_CANONICAL_BACKFILL,
414 self::HOOK_REDIRECTS_DENORM_BACKFILL,
415 self::HOOK_REDIRECTS_SORT_KEY_BACKFILL,
416 self::HOOK_SEND_QUEUED_REPORT,
417 self::HOOK_REFRESH_STATUS_COUNTS,
418 self::HOOK_REPAIR_COLLATIONS,
419 self::HOOK_NETWORK_ACTIVATION,
420 self::HOOK_NETWORK_ACTIVATION_BACKGROUND,
421 self::HOOK_NETWORK_UPGRADE_BACKGROUND,
422 self::HOOK_DUPLICATE_LEGACY,
423 self::HOOK_REMOVE_DUPLICATES_LEGACY,
424 self::HOOK_DELETE_OLD_REDIRECTS_LEGACY,
425 self::HOOK_REBUILD_VIEW_DONE_LEGACY,
426 );
427 }
428
429 /**
430 * @param array{hook: string, recurrence: string, timestamp: int, args?: array<int, mixed>} $request
431 * @return bool
432 */
433 public function scheduleRecurringAt(array $request): bool {
434 $hook = $request['hook'];
435 $recurrence = $request['recurrence'];
436 $timestamp = $request['timestamp'];
437 $args = isset($request['args']) ? $request['args'] : array();
438 if (!function_exists('wp_schedule_event')) {
439 $this->outcome->reportScheduleFailure(array(
440 'type' => 'recurring',
441 'hook' => $hook,
442 'recurrence' => $recurrence,
443 'timestamp' => $timestamp,
444 'args' => $args,
445 'errorCode' => 'cron_primitive_unavailable',
446 'detail' => 'wp_schedule_event unavailable',
447 'now' => $this->clock->now(),
448 ));
449 return false;
450 }
451 $scheduled = wp_schedule_event($timestamp, $recurrence, $hook, $this->listArgs($args), true);
452 if ($this->outcome->reportsFailure($scheduled)) {
453 return $this->outcome->resolveRecurringWrite(array(
454 'writeResult' => $scheduled,
455 'hook' => $hook,
456 'recurrence' => $recurrence,
457 'args' => $args,
458 'timestamp' => $timestamp,
459 'now' => $this->clock->now(),
460 ));
461 }
462 return true;
463 }
464
465 /**
466 * The wall-clock second a delay of $delaySeconds lands on, measured against
467 * the same clock every write here uses.
468 *
469 * Public so a caller that has to SAY which timestamp it asked for can hold
470 * the one value and hand it to both {@see scheduleSingleAt()} and its own
471 * diagnostics, rather than re-deriving it. A diagnostic that recomputes the
472 * request it is describing is free to describe a request nobody made, which
473 * is how a stalled n-gram rebuild reported a schedule time ten seconds out
474 * while the chain had actually backed off (production report 294).
475 *
476 * @param int $delaySeconds Negative delays are clamped to now.
477 */
478 public function timestampAfter(int $delaySeconds): int {
479 return $this->clock->now() + max(0, $delaySeconds);
480 }
481
482 /**
483 * @param array<int, mixed> $args
484 * @return list<mixed>
485 */
486 private function listArgs(array $args): array {
487 return array_values($args);
488 }
489 }
490