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

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

412 lines 18.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 * Answers questions about what the WordPress cron store currently holds.
9 *
10 * The read half of plugin cron access, split from
11 * {@see ABJ_404_Solution_CronScheduler} (which owns the writes). It exists as
12 * its own module because a truthful answer is not simply "call
13 * wp_next_scheduled()": within a request that has already written to the `cron`
14 * option, or failed to, the cached copy WordPress reads back can disagree with
15 * what is actually stored. This class owns that distinction, so callers cannot
16 * accidentally decide something important from a stale read.
17 *
18 * It never writes. It depends on no other plugin service, so the scheduler can
19 * depend on it without a cycle.
20 */
21 class ABJ_404_Solution_ScheduledEventInspector {
22
23 /**
24 * WordPress's own duplicate-event window (10 * MINUTE_IN_SECONDS, see
25 * wp_schedule_single_event() in wp-includes/cron.php). An existing event
26 * this close to a requested one is a duplicate as far as WordPress is
27 * concerned, so it also satisfies the request.
28 */
29 const DUPLICATE_EVENT_WINDOW_SECONDS = 600;
30
31 /**
32 * Whether WordPress would treat an event already stored at
33 * $storedTimestamp as a duplicate of one requested for
34 * $requestedTimestamp -- and therefore whether the stored event already
35 * satisfies that request.
36 *
37 * A port of the window wp_schedule_single_event() scans, and the reason
38 * this is a method rather than one comparison: the window is NOT symmetric
39 * around the requested run time, so `abs($stored - $requested) <= 600` is
40 * wrong in both directions.
41 *
42 * - Asking for a run within the next ten minutes drops the lower bound to
43 * timestamp 0, so EVERY overdue event for the hook is a duplicate. A
44 * site with DISABLE_WP_CRON and a job that never completes accumulates
45 * exactly those, hours or days old.
46 * - Asking for a run that has already passed raises the upper bound to
47 * now + ten minutes rather than requested + ten minutes.
48 *
49 * Getting this wrong does not merely mis-schedule: it makes the plugin
50 * report WordPress's benign "A duplicate event already exists." refusal as
51 * a scheduling failure, which is production report 284
52 * (vishalborewell.com, signature b2c064ec15425cf2).
53 *
54 * @param array{storedTimestamp: int, requestedTimestamp: int, now: int} $window
55 */
56 public static function duplicateWindowCovers(array $window): bool {
57 $storedTimestamp = $window['storedTimestamp'];
58 $requestedTimestamp = $window['requestedTimestamp'];
59 $now = $window['now'];
60 $minTimestamp = ($requestedTimestamp < $now + self::DUPLICATE_EVENT_WINDOW_SECONDS)
61 ? 0
62 : $requestedTimestamp - self::DUPLICATE_EVENT_WINDOW_SECONDS;
63 $maxTimestamp = ($requestedTimestamp < $now)
64 ? $now + self::DUPLICATE_EVENT_WINDOW_SECONDS
65 : $requestedTimestamp + self::DUPLICATE_EVENT_WINDOW_SECONDS;
66 return $storedTimestamp >= $minTimestamp && $storedTimestamp <= $maxTimestamp;
67 }
68
69 /**
70 * The event currently scheduled for a hook, with its recurrence.
71 *
72 * A null `recurrence` means "an event exists but this WordPress build
73 * cannot report its schedule", which callers must treat differently from
74 * "no event exists" (null return).
75 *
76 * @param array<int, mixed> $args
77 * @return array{timestamp: int, recurrence: string|null}|null
78 */
79 public function currentEvent(string $hook, array $args = array()): ?array {
80 if (function_exists('wp_get_scheduled_event')) {
81 $event = empty($args)
82 ? wp_get_scheduled_event($hook)
83 : wp_get_scheduled_event($hook, $this->listArgs($args));
84 if ($event === false) {
85 return null;
86 }
87 if (!is_object($event) || !isset($event->timestamp) || !is_numeric($event->timestamp)) {
88 throw new UnexpectedValueException(
89 'wp_get_scheduled_event returned a malformed event for cron hook ' . $hook
90 . ': expected an object with a numeric timestamp.'
91 );
92 }
93 $recurrence = isset($event->schedule) && is_string($event->schedule) && $event->schedule !== ''
94 ? $event->schedule
95 : null;
96 return array('timestamp' => (int)$event->timestamp, 'recurrence' => $recurrence);
97 }
98
99 $timestamp = $this->nextScheduledTimestamp($hook, $args);
100 if ($timestamp === false) {
101 return null;
102 }
103 if (!function_exists('wp_get_schedule')) {
104 return array('timestamp' => (int)$timestamp, 'recurrence' => null);
105 }
106 $schedule = empty($args) ? wp_get_schedule($hook) : wp_get_schedule($hook, $this->listArgs($args));
107 return array(
108 'timestamp' => (int)$timestamp,
109 'recurrence' => is_string($schedule) && $schedule !== '' ? $schedule : null,
110 );
111 }
112
113 /**
114 * Return every stored event for the exact hook/argument identity, ordered
115 * by timestamp. This is the convergence read used by recurrence migration:
116 * wp_get_scheduled_event() exposes only the next event and cannot reveal a
117 * stale recurrence sitting beside a valid replacement.
118 *
119 * @param array<int, mixed> $args
120 * @return list<array{timestamp: int, recurrence: string|null}>
121 */
122 public function eventsForHook(string $hook, array $args = array()): array {
123 if (!function_exists('_get_cron_array')) {
124 $current = $this->currentEvent($hook, $args);
125 return $current === null ? array() : array($current);
126 }
127
128 $cron = _get_cron_array();
129 if (!is_array($cron)) {
130 throw new UnexpectedValueException('WordPress returned a malformed cron array for hook ' . $hook . '.');
131 }
132
133 $targetArgs = $this->listArgs($args);
134 $matches = array();
135 foreach ($cron as $timestamp => $eventsByHook) {
136 if (!is_numeric($timestamp) || !is_array($eventsByHook) || !isset($eventsByHook[$hook])) {
137 continue;
138 }
139 if (!is_array($eventsByHook[$hook])) {
140 throw new UnexpectedValueException('WordPress returned malformed events for cron hook ' . $hook . '.');
141 }
142 foreach ($eventsByHook[$hook] as $event) {
143 if (!is_array($event)) {
144 throw new UnexpectedValueException('WordPress returned a malformed event for cron hook ' . $hook . '.');
145 }
146 if (!isset($event['args']) || !is_array($event['args'])) {
147 throw new UnexpectedValueException(
148 'WordPress returned an event with malformed args for cron hook ' . $hook . '.'
149 );
150 }
151 $eventArgs = $this->listArgs($event['args']);
152 if ($eventArgs !== $targetArgs) {
153 continue;
154 }
155 $matches[] = array(
156 'timestamp' => (int)$timestamp,
157 'recurrence' => isset($event['schedule']) && is_string($event['schedule'])
158 && $event['schedule'] !== '' ? $event['schedule'] : null,
159 );
160 }
161 }
162
163 usort($matches, static function(array $left, array $right): int {
164 return $left['timestamp'] <=> $right['timestamp'];
165 });
166 return $matches;
167 }
168
169 /**
170 * Answer whether the cron store already holds an event that was just
171 * requested, after a write WordPress reported as failed.
172 *
173 * WordPress reports a cron write as failed whenever
174 * `update_option('cron', ...)` returns false, and option.php returns false
175 * for a write that changed NOTHING just as readily as for one that could
176 * not be performed: once for its own "the new and old values are the same"
177 * short-circuit, and once because `$wpdb->update()` reports 0 affected rows
178 * when the stored row already holds byte-identical content.
179 *
180 * That second case is a lost race, not a failure. Two requests a fraction
181 * of a second apart both find the event missing, both build the identical
182 * cron array, and the second one writes bytes that are already there. The
183 * event the caller asked for exists; only the return value says otherwise.
184 * Treating it as a failure reported production error 87a00a2680c1bc07 to
185 * the plugin author once per collation-failing query -- fourteen identical
186 * ERROR lines inside one second -- for work that had already been done.
187 *
188 * The caller must invoke {@see refreshCronStoreReads()} after the failed
189 * write. Keeping the cache mutation explicit lets this method remain a
190 * read while still ensuring it sees the durable state.
191 *
192 * @param array{hook: string, args: array<int, mixed>, timestamp: int, recurrence: string|null, now: int} $request
193 */
194 public function requestedEventIsStored(array $request): bool {
195 $hook = $request['hook'];
196 $args = $request['args'];
197 $timestamp = $request['timestamp'];
198 $recurrence = $request['recurrence'];
199 $now = $request['now'];
200 if (function_exists('wp_get_scheduled_event')) {
201 $event = wp_get_scheduled_event($hook, $this->listArgs($args), $timestamp);
202 if ($event !== false) {
203 $this->assertExactEventShape($event, $hook, $timestamp);
204 return $recurrence === null
205 || (isset($event->schedule) && $event->schedule === $recurrence);
206 }
207 }
208
209 if ($recurrence !== null && function_exists('_get_cron_array')) {
210 foreach ($this->eventsForHook($hook, $args) as $event) {
211 if ($event['timestamp'] === $timestamp) {
212 return $event['recurrence'] === $recurrence;
213 }
214 }
215 return false;
216 }
217
218 $next = $this->nextScheduledTimestamp($hook, $args);
219 if ($next === false) {
220 return false;
221 }
222 if ($recurrence !== null) {
223 return (int)$next === $timestamp && $this->scheduledRecurrenceMatches(array(
224 'hook' => $hook,
225 'args' => $args,
226 'recurrence' => $recurrence,
227 ));
228 }
229 // WordPress itself refuses to add a second identical event inside its
230 // duplicate window, so an event in that window satisfies the request
231 // exactly as one at the requested timestamp would have.
232 return self::duplicateWindowCovers(array(
233 'storedTimestamp' => (int)$next,
234 'requestedTimestamp' => $timestamp,
235 'now' => $now,
236 ));
237 }
238
239 /**
240 * Answer whether the cron store holds an event for a hook under ANY
241 * arguments.
242 *
243 * The one cron question WordPress has no public API for. Every other read
244 * -- wp_next_scheduled(), wp_get_scheduled_event(), wp_get_schedule() --
245 * identifies an event by hook AND arguments, hashed as
246 * md5(serialize($args)), so none of them can see a chain whose links carry
247 * a cursor, an offset or an execution count in their args. Asking one of
248 * them anyway is how a self-rescheduling chain fails to recognize itself:
249 * ABJ_404_Solution_NGramCacheRebuildScheduler::armedRebuildTimestamp()
250 * documents the same trap from the other side, and worked around it by
251 * probing the two arg shapes that chain can hold.
252 *
253 * That workaround does not generalize. The permalink-cache chain's args are
254 * `[max_execution_time - 5, executionCount]`, and BOTH move: the execution
255 * count walks 2..15, and the budget is whatever ini_get() reports in the
256 * request that armed the link, which a WP-Cron request and a front-end
257 * request routinely disagree about. Enumerating the tuples would be
258 * guessing; reading the store is not.
259 *
260 * `_get_cron_array()` is core's own accessor for exactly this (it is what
261 * wp_next_scheduled() and wp_schedule_single_event() both read), but it is
262 * a private function, so a build that does not provide it -- or that
263 * answers with something other than an array -- falls back to the no-args
264 * probe. That fallback can only under-report, which leaves the caller
265 * asking WordPress for an event it may already hold: a benign duplicate
266 * refusal this class already settles, and the behaviour that shipped before
267 * this method existed. Failing the other way would strand a chain forever.
268 *
269 * This method does not invalidate WordPress's option cache on its own.
270 * Callers whose correctness crosses requests must first call
271 * refreshCronStoreReads(); callers making an advisory observation may keep
272 * the cheaper process-local view.
273 */
274 public function anyEventIsStored(string $hook): bool {
275 if (function_exists('_get_cron_array')) {
276 $crons = _get_cron_array();
277 if (is_array($crons)) {
278 foreach ($crons as $eventsByHook) {
279 if (is_array($eventsByHook) && !empty($eventsByHook[$hook])) {
280 return true;
281 }
282 }
283 return false;
284 }
285 }
286 return $this->nextScheduledTimestamp($hook, array()) !== false;
287 }
288
289 /**
290 * Answer whether the event a removal targeted is gone from the cron store.
291 *
292 * The mirror image of {@see requestedEventIsStored}: wp_unschedule_event()
293 * writes the cron array with the event taken out, so when another request
294 * removed it first the write changes nothing and WordPress reports the same
295 * `could_not_set` error for an event that is already gone.
296 *
297 * No duplicate window applies here, unlike its counterpart above:
298 * wp_unschedule_event() targets one exact timestamp, so absence means "no
299 * event at THAT timestamp" and nothing weaker.
300 *
301 * @param array{hook: string, args: array<int, mixed>, timestamp: int} $request
302 */
303 public function requestedEventIsAbsent(array $request): bool {
304 $hook = $request['hook'];
305 $args = $request['args'];
306 $timestamp = $request['timestamp'];
307 if (function_exists('wp_get_scheduled_event')) {
308 $event = wp_get_scheduled_event($hook, $this->listArgs($args), $timestamp);
309 if ($event === false) {
310 return true;
311 }
312 $this->assertExactEventShape($event, $hook, $timestamp);
313 return false;
314 }
315 if (!function_exists('_get_cron_array')) {
316 // WordPress 5.0 has _get_cron_array(); an environment that removes
317 // both exact-read APIs cannot prove an exact removal succeeded.
318 return false;
319 }
320 $crons = _get_cron_array();
321 if (!is_array($crons)) {
322 throw new UnexpectedValueException(
323 '_get_cron_array returned malformed data while verifying removal of cron hook '
324 . $hook . ' at timestamp ' . $timestamp . '.'
325 );
326 }
327 $argsKey = md5(serialize($this->listArgs($args)));
328 return !isset($crons[$timestamp][$hook][$argsKey]);
329 }
330
331 /**
332 * @param array<int, mixed> $args
333 * @return int|false
334 */
335 private function nextScheduledTimestamp(string $hook, array $args) {
336 if (!function_exists('wp_next_scheduled')) {
337 return false;
338 }
339 return empty($args)
340 ? wp_next_scheduled($hook)
341 : wp_next_scheduled($hook, $this->listArgs($args));
342 }
343
344 /**
345 * @param array{hook: string, args: array<int, mixed>, recurrence: string} $request
346 */
347 private function scheduledRecurrenceMatches(array $request): bool {
348 $hook = $request['hook'];
349 $args = $request['args'];
350 $recurrence = $request['recurrence'];
351 if (!function_exists('wp_get_schedule')) {
352 return false;
353 }
354 $schedule = empty($args)
355 ? wp_get_schedule($hook)
356 : wp_get_schedule($hook, $this->listArgs($args));
357 return is_string($schedule) && $schedule === $recurrence;
358 }
359
360 /**
361 * Validate the contract of an exact-timestamp wp_get_scheduled_event()
362 * lookup before its answer is allowed to excuse a failed cron write.
363 *
364 * @param mixed $event
365 * @throws UnexpectedValueException when WordPress or a replacement returns
366 * a shape that cannot prove the requested event exists.
367 */
368 private function assertExactEventShape($event, string $hook, int $timestamp): void {
369 if (!is_object($event) || !isset($event->timestamp) || !is_numeric($event->timestamp)) {
370 throw new UnexpectedValueException(
371 'wp_get_scheduled_event returned a malformed exact event for cron hook ' . $hook
372 . ' at timestamp ' . $timestamp . ': expected an object with a numeric timestamp.'
373 );
374 }
375 if ((int)$event->timestamp !== $timestamp) {
376 throw new UnexpectedValueException(
377 'wp_get_scheduled_event returned timestamp ' . (int)$event->timestamp
378 . ' for exact cron hook lookup ' . $hook . ' at timestamp ' . $timestamp . '.'
379 );
380 }
381 }
382
383 /**
384 * Drop the cached copy of the `cron` option so the next read comes from
385 * durable storage.
386 *
387 * `cron` is autoloaded on a stock install, so it is served out of the
388 * `alloptions` blob; a site that has flipped it to non-autoloaded caches it
389 * under its own key, and a site that has never scheduled anything caches
390 * its absence in `notoptions`. Same three keys, and the same reason, as
391 * ABJ_404_Solution_FeedbackSiteTokenStore::clearOptionCaches().
392 *
393 * @return void
394 */
395 public function refreshCronStoreReads(): void {
396 if (!function_exists('wp_cache_delete')) {
397 return;
398 }
399 wp_cache_delete('cron', 'options');
400 wp_cache_delete('notoptions', 'options');
401 wp_cache_delete('alloptions', 'options');
402 }
403
404 /**
405 * @param array<array-key, mixed> $args
406 * @return list<mixed>
407 */
408 private function listArgs(array $args): array {
409 return array_values($args);
410 }
411 }
412