PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.3
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.3
4.9.3 4.9.2 4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Framework / BackgroundProcessing / BackgroundProcessingServiceProvider.php
wp-staging / Framework / BackgroundProcessing Last commit date
Exceptions 5 years ago Job 1 month ago Action.php 2 years ago BackgroundProcessingServiceProvider.php 5 days ago Demo.php 4 years ago FeatureDetection.php 4 months ago Queue.php 1 month ago QueueActionAware.php 7 months ago QueueProcessor.php 1 month ago WithQueueAwareness.php 1 month ago
BackgroundProcessingServiceProvider.php
344 lines
1 <?php
2
3 /**
4 * Manages the registration and hooking of the Background Processing support feature.
5 *
6 * @see dev/docs/background-processing/self-healing.md for the layered
7 * queue recovery architecture.
8 *
9 * @package WPStaging\Framework\BackgroundProcessing
10 */
11
12 namespace WPStaging\Framework\BackgroundProcessing;
13
14 use WPStaging\Core\Cron\Cron;
15 use WPStaging\Framework\Adapter\Database;
16 use WPStaging\Framework\Adapter\Database\InterfaceDatabaseClient;
17 use WPStaging\Framework\DI\FeatureServiceProvider;
18
19 use function WPStaging\functions\debug_log;
20
21 /**
22 * Class BackgroundProcessingServiceProvider
23 *
24 * @property \tad_DI52_Container container
25 * @package WPStaging\Framework\BackgroundProcessing
26 */
27 class BackgroundProcessingServiceProvider extends FeatureServiceProvider
28 {
29 /** @var string */
30 const ACTION_QUEUE_MAINTAIN = 'wpstg_queue_maintain';
31
32 /** @var string */
33 const TRANSIENT_STALL_PROBE_LOCK = 'wpstg_queue_stall_probe_lock';
34
35 /** @var string */
36 const TRANSIENT_QUEUE_HAS_WORK = 'wpstg_queue_has_work';
37
38 /** @var int */
39 const QUEUE_HAS_WORK_TTL = DAY_IN_SECONDS;
40
41 /** @var int */
42 const STALL_PROBE_THROTTLE_SECONDS = 15;
43
44 /** @var int */
45 const STALL_IDLE_SECONDS = 20;
46
47 /** @var int Age in seconds after which a STATUS_PROCESSING row is considered abandoned. Must exceed the longest expected dispatch window. */
48 const STUCK_PROCESSING_SECONDS = 60;
49
50 /**
51 * {@inheritdoc}
52 */
53 public static function getFeatureTrigger()
54 {
55 return 'WPSTG_FEATURE_ENABLE_BACKGROUND_PROCESSING';
56 }
57
58 /**
59 * Registers the required Cron actions and the classes used by the feature provider.
60 *
61 * @return bool Whether the feature registration was actually done or not.
62 */
63 public function register()
64 {
65 // This allows us to disable or enable the feature by setting WPSTG_FEATURE_ENABLE_BACKGROUND_PROCESSING to false/true in wp-config.php
66 if (!static::isEnabledInProduction()) {
67 return false;
68 }
69
70 $database = $this->container->make(Database::class)->getClient();
71
72 // See if there is better way than this to handle this code?
73 $this->container->when(Queue::class)
74 ->needs(InterfaceDatabaseClient::class)
75 ->give($database);
76
77 // For caching purposes, have one single instance of the Queue around.
78 $this->container->singleton(Queue::class, Queue::class);
79 // For concurrency purposes, have one single instance of the Queue processor around.
80 $this->container->singleton(QueueProcessor::class, QueueProcessor::class);
81
82 $this->registerFeatureDetection();
83 $this->scheduleQueueMaintenance();
84 $this->setupQueueProcessingEntrypoints();
85 $this->setupStallDetector();
86
87 // Defer to init so translating the schedule labels doesn't happen too early.
88 if (did_action('init')) {
89 $this->scheduleStaticCronEvents();
90 } else {
91 add_action('init', [$this, 'scheduleStaticCronEvents']);
92 }
93
94 return true;
95 }
96
97 /**
98 * Runs the Queue maintenance routines.
99 *
100 * @return void The method will not return any value.
101 */
102 public function runQueueMaintenance()
103 {
104 debug_log('Running Queue Maintenance.', 'info', false);
105
106 /** @var Queue $queue */
107 $queue = $this->container->make(Queue::class);
108
109 // Mark all dangling Actions as Failed.
110 $queue->markDanglingAs(Queue::STATUS_FAILED);
111 // Remove old Actions.
112 $queue->cleanup();
113 }
114
115 /**
116 * Schedules the plugin's recurring background-processing Cron events.
117 * @return void
118 */
119 public function scheduleStaticCronEvents()
120 {
121 /** @var Cron $cron */
122 $cron = $this->container->make(Cron::class);
123
124 // Once a day fire an action to run the Queue maintenance routines.
125 if (!wp_next_scheduled(self::ACTION_QUEUE_MAINTAIN)) {
126 wp_schedule_event($cron->getFirstRunTimestamp(Cron::DAILY), Cron::DAILY, self::ACTION_QUEUE_MAINTAIN);
127 }
128
129 // Once every hour (kinda, it's Cron), fire the queue processing action.
130 if (!wp_next_scheduled(QueueProcessor::ACTION_QUEUE_PROCESS)) {
131 wp_schedule_event($cron->getFirstRunTimestamp(Cron::HOURLY), Cron::HOURLY, QueueProcessor::ACTION_QUEUE_PROCESS);
132 }
133
134 // Once a week re-run the AJAX support feature detection.
135 if (!wp_next_scheduled(FeatureDetection::ACTION_AJAX_SUPPORT_FEATURE_DETECTION)) {
136 wp_schedule_event($cron->getFirstRunTimestamp(Cron::WEEKLY), Cron::WEEKLY, FeatureDetection::ACTION_AJAX_SUPPORT_FEATURE_DETECTION);
137 }
138 }
139
140 /**
141 * Schedules the Queue maintenance by means of the Cron. The Cron is not
142 * a really reliable method to execute timely tasks in WordPress, especially
143 * if not powered by a real cron, but it's fine for addressing the maintenance
144 * operations of the Queue that do not require to be timely and are fine happening
145 * when possible.
146 *
147 * @since TBD
148 *
149 * @return void
150 */
151 private function scheduleQueueMaintenance()
152 {
153 // When the action fires, run the maintenance routines.
154 add_action(self::ACTION_QUEUE_MAINTAIN, [$this, 'runQueueMaintenance']); // phpcs:ignore WPStaging.Security.FirstArgNotAString
155 }
156
157 /**
158 * Sets up the Queue processing entry points.
159 *
160 * The Queue, when loaded with Actions, has the potential to soak resources.
161 * The Queue Processor will have safeguards in place to avoid this, but we should
162 * be careful about the entrypoints of the queue processing to make sure it will
163 * process Actions only when doing that will not compromise the user experience.
164 * This is why we rely on side-processes that we can trigger while the main PHP process
165 * that is handling the user interaction with the site stays fast and snappy.
166 *
167 * @return void The method does not return any value.
168 */
169 private function setupQueueProcessingEntrypoints()
170 {
171 /**
172 * This is the core of how the Queue works: when the `wpstg_queue_process`, or the AJAX version of it, fires, we'll process some
173 * Actions.
174 * Setting up how we make these WordPress actions fire is what we take care of next.
175 */
176 $wpActions = [
177 QueueProcessor::ACTION_QUEUE_PROCESS,
178 'wp_ajax_nopriv_' . QueueProcessor::ACTION_QUEUE_PROCESS,
179 'wp_ajax_' . QueueProcessor::ACTION_QUEUE_PROCESS,
180 ];
181 $queueProcessorProcess = $this->container->callback(QueueProcessor::class, 'process');
182
183 foreach ($wpActions as $wpAction) {
184 if (!has_action($wpAction, $queueProcessorProcess)) {
185 add_action($wpAction, $queueProcessorProcess); // phpcs:ignore WPStaging.Security.FirstArgNotAString -- Queue action callbacks should not take input from request.
186 }
187 }
188
189 /*
190 * This is currently deactivated while we decide if supporting this is something we would like to do at all.
191 if (is_admin() && !wp_doing_ajax()) {
192 $ajaxAvailable = $this->container->make(FeatureDetection::class)->isAjaxAvailable(false);
193
194 if (!$ajaxAvailable) {
195 // add_action('shutdown', $queueProcessorProcess, -10000);
196 }
197 }
198 */
199 }
200
201 /**
202 * Throttled init-time recovery for environments where the non-blocking loopback
203 * is silently dropped (WAF, hairpin DNS, proxy) and WP-Cron is unavailable.
204 */
205 private function setupStallDetector()
206 {
207 add_action('init', [$this, 'detectAndRecoverStall'], 100);
208 }
209
210 /**
211 * @return void
212 */
213 public function detectAndRecoverStall()
214 {
215 if (!get_site_transient(self::TRANSIENT_QUEUE_HAS_WORK)) {
216 return;
217 }
218
219 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
220 return;
221 }
222
223 if (defined('DOING_AJAX') && DOING_AJAX) {
224 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
225 $requestAction = isset($_REQUEST['action']) ? sanitize_text_field(wp_unslash($_REQUEST['action'])) : '';
226 if ($requestAction === QueueProcessor::ACTION_QUEUE_PROCESS || $requestAction === FeatureDetection::ACTION_AJAX_TEST) {
227 return;
228 }
229 }
230
231 if (get_site_transient(self::TRANSIENT_STALL_PROBE_LOCK)) {
232 return;
233 }
234
235 set_site_transient(self::TRANSIENT_STALL_PROBE_LOCK, 1, self::STALL_PROBE_THROTTLE_SECONDS);
236
237 try {
238 /** @var Queue $queue */
239 $queue = $this->container->make(Queue::class);
240 } catch (\Throwable $e) {
241 return;
242 }
243
244 $revived = 0;
245 try {
246 $breakpoint = $this->getStuckProcessingBreakpoint();
247 if ($breakpoint !== null) {
248 $revived = (int)$queue->markDanglingAs(Queue::STATUS_READY, $breakpoint, true);
249 if ($revived > 0) {
250 debug_log('[Background Processing] Revived ' . $revived . ' stuck-in-processing action(s). Claim age threshold: ' . self::STUCK_PROCESSING_SECONDS . 's.', 'info', true);
251 }
252 }
253 } catch (\Throwable $e) {
254 // best-effort
255 }
256
257 if ((int)$queue->count(Queue::STATUS_READY) === 0) {
258 if ((int)$queue->count(Queue::STATUS_PROCESSING) === 0) {
259 delete_site_transient(self::TRANSIENT_QUEUE_HAS_WORK);
260 }
261
262 return;
263 }
264
265 if ($revived > 0) {
266 $this->recoverStalledQueue($queue, 0, $revived);
267 return;
268 }
269
270 $lastUpdate = $queue->getLastUpdatedAtTimestamp();
271 if ($lastUpdate === 0) {
272 // Legacy row predating insert-time updated_at stamping; treat as stalled.
273 $this->recoverStalledQueue($queue, 0, 0);
274 return;
275 }
276
277 $idleSeconds = time() - $lastUpdate;
278 if ($idleSeconds < self::STALL_IDLE_SECONDS) {
279 return;
280 }
281
282 $this->recoverStalledQueue($queue, $idleSeconds, 0);
283 }
284
285 /**
286 * @return void
287 */
288 private function recoverStalledQueue(Queue $queue, $idleSeconds, $revivedCount)
289 {
290 debug_log('[Background Processing] Stall detected: ready=' . $queue->count(Queue::STATUS_READY) . ' idle_seconds=' . (int)$idleSeconds . ' revived=' . (int)$revivedCount . '. Recovering via inline process().', 'info', true);
291
292 try {
293 /** @var QueueProcessor $processor */
294 $processor = $this->container->make(QueueProcessor::class);
295 } catch (\Throwable $e) {
296 return;
297 }
298
299 $processor->process();
300 }
301
302 /**
303 * @return \DateTimeImmutable|null Null when caller should skip the revive pass this cycle.
304 */
305 private function getStuckProcessingBreakpoint()
306 {
307 try {
308 $breakpoint = new \DateTimeImmutable(current_time('mysql'));
309 return $breakpoint->setTimestamp($breakpoint->getTimestamp() - self::STUCK_PROCESSING_SECONDS);
310 } catch (\Exception $e) {
311 return null;
312 }
313 }
314
315 /**
316 * Registers the two actions that will be called by the AJAX support
317 * feature detection.
318 *
319 * @since TBD
320 * @return void
321 */
322 private function registerFeatureDetection()
323 {
324 // Register the method that will handle the AJAX check.
325 $updateOption = $this->container->callback(FeatureDetection::class, 'updateAjaxTestOption');
326 // Hook on authenticated AJAX endpoint to handle the check.
327 add_action('wp_ajax_' . FeatureDetection::ACTION_AJAX_TEST, $updateOption); // phpcs:ignore WPStaging.Security.AuthorizationChecked -- Public
328 add_action('wp_ajax_nopriv_' . FeatureDetection::ACTION_AJAX_TEST, $updateOption); // phpcs:ignore WPStaging.Security.AuthorizationChecked -- Public
329
330 $runAjaxFeatureTest = $this->container->callback(FeatureDetection::class, 'runAjaxFeatureTest');
331 add_action(FeatureDetection::ACTION_AJAX_SUPPORT_FEATURE_DETECTION, $runAjaxFeatureTest);
332
333 // Run the test again if requested by link, e.g. from the notice.
334 if (
335 is_admin()
336 && filter_input(INPUT_GET, FeatureDetection::AJAX_REQUEST_QUERY_VAR, FILTER_SANITIZE_NUMBER_INT)
337 ) {
338 $runAjaxFeatureTest();
339 wp_redirect(remove_query_arg(FeatureDetection::AJAX_REQUEST_QUERY_VAR));
340 die();
341 }
342 }
343 }
344