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

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

233 lines 9.5 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 * Moves a plugin cron hook onto a fixed `daily` recurrence, retiring whatever
9 * event an older build left behind at a different one.
10 *
11 * A cadence policy rather than a cron primitive, which is why it lives beside
12 * ABJ_404_Solution_CronScheduler instead of inside it: it decides WHICH event
13 * should exist and in what order the replacement and the removal must happen,
14 * then asks the scheduler to perform each step.
15 *
16 * Takes NO recurrence parameter by design: hardcoding the target here makes it
17 * structurally impossible for a future caller to reintroduce a variable-driven
18 * recurrence, which is the root shape of the original bug (a WP-Cron event's
19 * own recurrence tied to a user-configurable interval instead of a fixed,
20 * frequent trigger -- see EmailDigest::scheduleNextDigest() and WP.org support
21 * topic weekly-digest-3). Without this migration, a site upgrading from a build
22 * that scheduled `abj404_send_digest` at `weekly` recurrence would keep that
23 * stale recurrence forever: scheduleRecurringIfMissing()'s next-scheduled guard
24 * only checks whether ANY event exists for the hook, not whether it matches the
25 * intended cadence.
26 */
27 class ABJ_404_Solution_CronRecurrenceMigration {
28
29 /** The one cadence this policy migrates hooks onto. */
30 const TARGET_RECURRENCE = 'daily';
31
32 /** @var ABJ_404_Solution_CronScheduler */
33 private $scheduler;
34
35 /** @var ABJ_404_Solution_ScheduledEventInspector */
36 private $inspector;
37
38 /** @var ABJ_404_Solution_Logging|null */
39 private $logger;
40
41 /**
42 * @param ABJ_404_Solution_CronScheduler $scheduler
43 * @param ABJ_404_Solution_ScheduledEventInspector $inspector
44 * @param ABJ_404_Solution_Logging|null $logger
45 */
46 public function __construct(
47 ABJ_404_Solution_CronScheduler $scheduler,
48 ABJ_404_Solution_ScheduledEventInspector $inspector,
49 $logger = null
50 ) {
51 $this->scheduler = $scheduler;
52 $this->inspector = $inspector;
53 $this->logger = $logger;
54 }
55
56 /**
57 * Ensure the hook has an event whose recurrence is exactly `daily`.
58 *
59 * The replacement is scheduled BEFORE the stale event is removed, so a
60 * request that dies between the two steps leaves the hook over-scheduled
61 * rather than unscheduled. If the removal then fails, the replacement is
62 * rolled back so the site is left exactly as it was found.
63 *
64 * @param array<int, mixed> $args
65 * @return bool True when the hook is left running at the target recurrence.
66 */
67 public function ensureDailyRecurrence(string $hook, int $delaySeconds = 0, array $args = array()): bool {
68 $lockKey = 'cron-recurrence-' . hash('sha256', serialize(array($hook, array_values($args))));
69 $synchronizer = abj_service('sync_utils');
70 $owner = $synchronizer->synchronizerAcquireLockTry($lockKey);
71 if ($owner === '') {
72 if ($this->logger !== null && method_exists($this->logger, 'debugMessage')) {
73 $this->logger->debugMessage('Cron recurrence migration already in progress for ' . $hook . '.');
74 }
75 return false;
76 }
77
78 try {
79 return $this->ensureDailyRecurrenceWhileLocked($hook, $delaySeconds, $args);
80 } finally {
81 $synchronizer->synchronizerReleaseLock($owner, $lockKey);
82 }
83 }
84
85 /**
86 * Converge every exact hook/args event while the migration lock is held.
87 *
88 * @param array<int, mixed> $args
89 */
90 private function ensureDailyRecurrenceWhileLocked(string $hook, int $delaySeconds, array $args): bool {
91 $events = $this->inspector->eventsForHook($hook, $args);
92 if ($events === array()) {
93 return $this->scheduler->scheduleRecurringAt(array(
94 'hook' => $hook,
95 'recurrence' => self::TARGET_RECURRENCE,
96 'timestamp' => $this->scheduler->timestampAfter($delaySeconds),
97 'args' => $args,
98 ));
99 }
100
101 $dailyEvents = array_values(array_filter($events, static function(array $event): bool {
102 return $event['recurrence'] === self::TARGET_RECURRENCE;
103 }));
104 if ($dailyEvents !== array()) {
105 return $this->removeEventsExcept(array(
106 'events' => $events,
107 'keeper' => $dailyEvents[0],
108 'hook' => $hook,
109 'args' => $args,
110 ));
111 }
112
113 $current = $events[0];
114 if ($current['recurrence'] === null) {
115 $this->logWarning('[CRON_RECURRENCE_UNAVAILABLE] Cannot migrate cron hook ' . $hook
116 . ': existing recurrence is unavailable. Recovery: inspect and recreate the event in WP-Cron.');
117 return false;
118 }
119 if (!function_exists('wp_unschedule_event')) {
120 $this->logWarning('[CRON_UNSCHEDULE_UNAVAILABLE] Cannot migrate cron hook ' . $hook
121 . ': wp_unschedule_event is unavailable. Recovery: restore the WordPress cron API and retry.');
122 return false;
123 }
124
125 $replacementTimestamp = $this->replacementTimestamp(array(
126 'delaySeconds' => $delaySeconds,
127 'staleTimestamps' => array_values(array_map(static function(array $event): int {
128 return $event['timestamp'];
129 }, $events)),
130 ));
131
132 if (!$this->scheduler->scheduleRecurringAt(array(
133 'hook' => $hook,
134 'recurrence' => self::TARGET_RECURRENCE,
135 'timestamp' => $replacementTimestamp,
136 'args' => $args,
137 ))) {
138 return false;
139 }
140 $removedCount = 0;
141 foreach ($events as $event) {
142 if (!$this->scheduler->unscheduleAt(array(
143 'timestamp' => $event['timestamp'],
144 'hook' => $hook,
145 'args' => $args,
146 'expectedNextTimestamp' => $replacementTimestamp,
147 ))) {
148 break;
149 }
150 $removedCount++;
151 }
152 if ($removedCount === count($events)) {
153 return true;
154 }
155
156 if ($removedCount > 0) {
157 $this->logWarning('[CRON_PARTIAL_MIGRATION] Removed ' . $removedCount . ' stale event(s) for ' . $hook
158 . ' before a later removal failed. The daily replacement was retained so the hook remains '
159 . 'scheduled. Recovery: the next migration run will remove the remaining stale event(s).');
160 return false;
161 }
162
163 if (!$this->scheduler->unscheduleAt(array(
164 'timestamp' => $replacementTimestamp,
165 'hook' => $hook,
166 'args' => $args,
167 'expectedNextTimestamp' => $current['timestamp'],
168 ))) {
169 $this->logWarning('[CRON_ROLLBACK_FAILED] Failed to roll back replacement cron hook ' . $hook
170 . ' after stale-event removal failed. Recovery: inspect duplicate events in WP-Cron and keep the daily event.');
171 }
172 return false;
173 }
174
175 /**
176 * Remove stale and duplicate events while retaining exactly one daily event.
177 *
178 * @param array{events: list<array{timestamp: int, recurrence: string|null}>, keeper: array{timestamp: int, recurrence: string|null}, hook: string, args: array<int, mixed>} $request
179 */
180 private function removeEventsExcept(array $request): bool {
181 $keeperSkipped = false;
182 foreach ($request['events'] as $event) {
183 if (!$keeperSkipped && $event === $request['keeper']) {
184 $keeperSkipped = true;
185 continue;
186 }
187 if (!$this->scheduler->unscheduleAt(array(
188 'timestamp' => $event['timestamp'],
189 'hook' => $request['hook'],
190 'args' => $request['args'],
191 'expectedNextTimestamp' => $request['keeper']['timestamp'],
192 ))) {
193 $this->logWarning('[CRON_DUPLICATE_REMOVAL_FAILED] Could not remove a duplicate event for ' .
194 $request['hook'] . '. Recovery: inspect the hook in WP-Cron and keep one daily event.');
195 return false;
196 }
197 }
198 return true;
199 }
200
201 /**
202 * The instant the replacement event goes at, kept distinct from the stale
203 * event's own timestamp so the two can be told apart afterwards.
204 */
205 /** @param array{delaySeconds: int, staleTimestamps: list<int>} $request */
206 private function replacementTimestamp(array $request): int {
207 $timestamp = $this->scheduler->timestampAfter($request['delaySeconds']);
208 if (!function_exists('wp_get_scheduled_event')) {
209 if ($request['staleTimestamps'] === array()) {
210 throw new InvalidArgumentException(
211 'Cron recurrence migration requires at least one stale event timestamp.'
212 );
213 }
214 // WordPress 5.0's unschedule primitive returns void on success, so
215 // the replacement has to sit AFTER every stale event for the
216 // next-scheduled read to prove the old one was really removed.
217 return max($timestamp, max($request['staleTimestamps']) + 1);
218 }
219 while (in_array($timestamp, $request['staleTimestamps'], true)) {
220 $timestamp++;
221 }
222 return $timestamp;
223 }
224
225 private function logWarning(string $message): void {
226 if ($this->logger !== null && method_exists($this->logger, 'warn')) {
227 $this->logger->warn($message);
228 return;
229 }
230 abj404_logPhpFallback('service-resolution-fallback', $message);
231 }
232 }
233