PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.0
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.0
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 1 week ago Demo.php 4 years ago FeatureDetection.php 3 months ago Queue.php 1 month ago QueueActionAware.php 6 months ago QueueProcessor.php 1 month ago WithQueueAwareness.php 1 month ago
BackgroundProcessingServiceProvider.php
338 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 /** @var Cron $cron */
83 $cron = $this->container->make(Cron::class);
84
85 $this->registerFeatureDetection($cron);
86 $this->scheduleQueueMaintenance($cron);
87 $this->setupQueueProcessingEntrypoints($cron);
88 $this->setupStallDetector();
89
90 return true;
91 }
92
93 /**
94 * Runs the Queue maintenance routines.
95 *
96 * @return void The method will not return any value.
97 */
98 public function runQueueMaintenance()
99 {
100 debug_log('Running Queue Maintenance.', 'info', false);
101
102 /** @var Queue $queue */
103 $queue = $this->container->make(Queue::class);
104
105 // Mark all dangling Actions as Failed.
106 $queue->markDanglingAs(Queue::STATUS_FAILED);
107 // Remove old Actions.
108 $queue->cleanup();
109 }
110
111 /**
112 * Schedules the Queue maintenance by means of the Cron. The Cron is not
113 * a really reliable method to execute timely tasks in WordPress, especially
114 * if not powered by a real cron, but it's fine for addressing the maintenance
115 * operations of the Queue that do not require to be timely and are fine happening
116 * when possible.
117 *
118 * @since TBD
119 *
120 * @param Cron $cron
121 * @return void
122 */
123 private function scheduleQueueMaintenance(Cron $cron)
124 {
125 // Once a day fire an action to run the Queue maintenance routines.
126 if (!wp_next_scheduled(self::ACTION_QUEUE_MAINTAIN)) {
127 wp_schedule_event($cron->getFirstRunTimestamp(Cron::DAILY), Cron::DAILY, self::ACTION_QUEUE_MAINTAIN);
128 }
129
130 // When the action fires, run the maintenance routines.
131 add_action(self::ACTION_QUEUE_MAINTAIN, [$this, 'runQueueMaintenance']); // phpcs:ignore WPStaging.Security.FirstArgNotAString
132 }
133
134 /**
135 * Sets up the Queue processing entry points.
136 *
137 * The Queue, when loaded with Actions, has the potential to soak resources.
138 * The Queue Processor will have safeguards in place to avoid this, but we should
139 * be careful about the entrypoints of the queue processing to make sure it will
140 * process Actions only when doing that will not compromise the user experience.
141 * This is why we rely on side-processes that we can trigger while the main PHP process
142 * that is handling the user interaction with the site stays fast and snappy.
143 *
144 * @param Cron $cron
145 * @return void The method does not return any value.
146 */
147 private function setupQueueProcessingEntrypoints(Cron $cron)
148 {
149 /**
150 * 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
151 * Actions.
152 * Setting up how we make these WordPress actions fire is what we take care of next.
153 */
154 $wpActions = [
155 QueueProcessor::ACTION_QUEUE_PROCESS,
156 'wp_ajax_nopriv_' . QueueProcessor::ACTION_QUEUE_PROCESS,
157 'wp_ajax_' . QueueProcessor::ACTION_QUEUE_PROCESS,
158 ];
159 $queueProcessorProcess = $this->container->callback(QueueProcessor::class, 'process');
160
161 foreach ($wpActions as $wpAction) {
162 if (!has_action($wpAction, $queueProcessorProcess)) {
163 add_action($wpAction, $queueProcessorProcess); // phpcs:ignore WPStaging.Security.FirstArgNotAString -- Queue action callbacks should not take input from request.
164 }
165 }
166
167 /*
168 * The first way we trigger the action that will make the Queue Processor process Actions is a Cron schedule.
169 * With full-knowledge of the fact that it will not be reliable, we still try to get some work done
170 * on Cron calls.
171 * Once every hour (kinda, it's Cron), fire the `wpstg_queue_process` action.
172 */
173 if (!wp_next_scheduled(QueueProcessor::ACTION_QUEUE_PROCESS)) {
174 wp_schedule_event($cron->getFirstRunTimestamp(Cron::HOURLY), Cron::HOURLY, QueueProcessor::ACTION_QUEUE_PROCESS);
175 }
176
177 /*
178 * This is currently deactivated while we decide if supporting this is something we would like to do at all.
179 if (is_admin() && !wp_doing_ajax()) {
180 $ajaxAvailable = $this->container->make(FeatureDetection::class)->isAjaxAvailable(false);
181
182 if (!$ajaxAvailable) {
183 // add_action('shutdown', $queueProcessorProcess, -10000);
184 }
185 }
186 */
187 }
188
189 /**
190 * Throttled init-time recovery for environments where the non-blocking loopback
191 * is silently dropped (WAF, hairpin DNS, proxy) and WP-Cron is unavailable.
192 */
193 private function setupStallDetector()
194 {
195 add_action('init', [$this, 'detectAndRecoverStall'], 100);
196 }
197
198 /**
199 * @return void
200 */
201 public function detectAndRecoverStall()
202 {
203 if (!get_site_transient(self::TRANSIENT_QUEUE_HAS_WORK)) {
204 return;
205 }
206
207 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
208 return;
209 }
210
211 if (defined('DOING_AJAX') && DOING_AJAX) {
212 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
213 $requestAction = isset($_REQUEST['action']) ? sanitize_text_field(wp_unslash($_REQUEST['action'])) : '';
214 if ($requestAction === QueueProcessor::ACTION_QUEUE_PROCESS || $requestAction === FeatureDetection::ACTION_AJAX_TEST) {
215 return;
216 }
217 }
218
219 if (get_site_transient(self::TRANSIENT_STALL_PROBE_LOCK)) {
220 return;
221 }
222
223 set_site_transient(self::TRANSIENT_STALL_PROBE_LOCK, 1, self::STALL_PROBE_THROTTLE_SECONDS);
224
225 try {
226 /** @var Queue $queue */
227 $queue = $this->container->make(Queue::class);
228 } catch (\Throwable $e) {
229 return;
230 }
231
232 $revived = 0;
233 try {
234 $breakpoint = $this->getStuckProcessingBreakpoint();
235 if ($breakpoint !== null) {
236 $revived = (int)$queue->markDanglingAs(Queue::STATUS_READY, $breakpoint, true);
237 if ($revived > 0) {
238 debug_log('[Background Processing] Revived ' . $revived . ' stuck-in-processing action(s). Claim age threshold: ' . self::STUCK_PROCESSING_SECONDS . 's.', 'info', true);
239 }
240 }
241 } catch (\Throwable $e) {
242 // best-effort
243 }
244
245 if ((int)$queue->count(Queue::STATUS_READY) === 0) {
246 if ((int)$queue->count(Queue::STATUS_PROCESSING) === 0) {
247 delete_site_transient(self::TRANSIENT_QUEUE_HAS_WORK);
248 }
249
250 return;
251 }
252
253 if ($revived > 0) {
254 $this->recoverStalledQueue($queue, 0, $revived);
255 return;
256 }
257
258 $lastUpdate = $queue->getLastUpdatedAtTimestamp();
259 if ($lastUpdate === 0) {
260 // Legacy row predating insert-time updated_at stamping; treat as stalled.
261 $this->recoverStalledQueue($queue, 0, 0);
262 return;
263 }
264
265 $idleSeconds = time() - $lastUpdate;
266 if ($idleSeconds < self::STALL_IDLE_SECONDS) {
267 return;
268 }
269
270 $this->recoverStalledQueue($queue, $idleSeconds, 0);
271 }
272
273 /**
274 * @return void
275 */
276 private function recoverStalledQueue(Queue $queue, $idleSeconds, $revivedCount)
277 {
278 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);
279
280 try {
281 /** @var QueueProcessor $processor */
282 $processor = $this->container->make(QueueProcessor::class);
283 } catch (\Throwable $e) {
284 return;
285 }
286
287 $processor->process();
288 }
289
290 /**
291 * @return \DateTimeImmutable|null Null when caller should skip the revive pass this cycle.
292 */
293 private function getStuckProcessingBreakpoint()
294 {
295 try {
296 $breakpoint = new \DateTimeImmutable(current_time('mysql'));
297 return $breakpoint->setTimestamp($breakpoint->getTimestamp() - self::STUCK_PROCESSING_SECONDS);
298 } catch (\Exception $e) {
299 return null;
300 }
301 }
302
303 /**
304 * Registers the two actions that will be called by the AJAX support
305 * feature detection.
306 *
307 * @since TBD
308 * @param Cron $cron
309 * @return void
310 */
311 private function registerFeatureDetection(Cron $cron)
312 {
313 // Register the method that will handle the AJAX check.
314 $updateOption = $this->container->callback(FeatureDetection::class, 'updateAjaxTestOption');
315 // Hook on authenticated AJAX endpoint to handle the check.
316 add_action('wp_ajax_' . FeatureDetection::ACTION_AJAX_TEST, $updateOption); // phpcs:ignore WPStaging.Security.AuthorizationChecked -- Public
317 add_action('wp_ajax_nopriv_' . FeatureDetection::ACTION_AJAX_TEST, $updateOption); // phpcs:ignore WPStaging.Security.AuthorizationChecked -- Public
318
319 // Once a week re-run the check.
320 if (!wp_next_scheduled(FeatureDetection::ACTION_AJAX_SUPPORT_FEATURE_DETECTION)) {
321 wp_schedule_event($cron->getFirstRunTimestamp(Cron::WEEKLY), Cron::WEEKLY, FeatureDetection::ACTION_AJAX_SUPPORT_FEATURE_DETECTION);
322 }
323
324 $runAjaxFeatureTest = $this->container->callback(FeatureDetection::class, 'runAjaxFeatureTest');
325 add_action(FeatureDetection::ACTION_AJAX_SUPPORT_FEATURE_DETECTION, $runAjaxFeatureTest);
326
327 // Run the test again if requested by link, e.g. from the notice.
328 if (
329 is_admin()
330 && filter_input(INPUT_GET, FeatureDetection::AJAX_REQUEST_QUERY_VAR, FILTER_SANITIZE_NUMBER_INT)
331 ) {
332 $runAjaxFeatureTest();
333 wp_redirect(remove_query_arg(FeatureDetection::AJAX_REQUEST_QUERY_VAR));
334 die();
335 }
336 }
337 }
338