PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backups, Restore, Migration & Clone / 4.10.0
WP STAGING – WordPress Backups, Restore, Migration & Clone v4.10.0
4.10.0 4.9.5 4.9.4 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 / Job / AbstractJob.php
wp-staging / Framework / Job Last commit date
Ajax 5 days ago BackgroundProcessing 1 year ago Dto 2 weeks ago Exception 5 days ago Interfaces 1 month ago Jobs 7 months ago Task 1 month ago Traits 10 months ago AbstractJob.php 5 days ago JobProvider.php 1 year ago JobServiceProvider.php 6 months ago JobTransientCache.php 3 months ago ProcessLock.php 5 days ago
AbstractJob.php
596 lines
1 <?php
2
3 // TODO PHP7.x; declare(strict_types=1);
4 // TODO PHP7.x; return types && type-hints
5
6 namespace WPStaging\Framework\Job;
7
8 use RuntimeException;
9 use WPStaging\Core\Utils\Logger;
10 use WPStaging\Core\WPStaging;
11 use WPStaging\Framework\Adapter\Directory;
12 use WPStaging\Framework\Assets\Assets;
13 use WPStaging\Framework\Exceptions\WPStagingException;
14 use WPStaging\Framework\Filesystem\DiskWriteCheck;
15 use WPStaging\Framework\Filesystem\Filesystem;
16 use WPStaging\Framework\Interfaces\ShutdownableInterface;
17 use WPStaging\Framework\Job\Dto\AbstractDto;
18 use WPStaging\Framework\Job\Dto\JobDataDto;
19 use WPStaging\Framework\Job\Dto\TaskResponseDto;
20 use WPStaging\Framework\Job\Exception\DiskNotWritableException;
21 use WPStaging\Framework\Job\Exception\TaskHealthException;
22 use WPStaging\Framework\Job\Task\AbstractTask;
23 use WPStaging\Framework\Traits\BenchmarkTrait;
24 use WPStaging\Framework\Utils\Cache\Cache;
25 use WPStaging\Framework\Queue\FinishedQueueException;
26
27 use function WPStaging\functions\debug_log;
28
29 abstract class AbstractJob implements ShutdownableInterface
30 {
31 use BenchmarkTrait;
32
33 /** @var JobDataDto */
34 protected $jobDataDto;
35
36 /** @var Cache $jobDataCache Persists the JobDataDto in the filesystem. */
37 private $jobDataCache;
38
39 /** @var bool Whether this request already wrote the job state out. */
40 private $hasPersisted = false;
41
42 /** @var bool Whether the last-chance shutdown function is registered. */
43 private $hasShutdownBackstop = false;
44
45 /** @var string */
46 protected $currentTaskName;
47
48 /** @var AbstractTask */
49 protected $currentTask;
50
51 /** @var Filesystem */
52 protected $filesystem;
53
54 /** @var Directory */
55 protected $directory;
56
57 /** @var ProcessLock */
58 protected $processLock;
59
60 /** @var DiskWriteCheck */
61 protected $diskFullCheck;
62
63 /** @var JobTransientCache */
64 protected $jobTransientCache;
65
66 /** @var string|false */
67 protected $memoryExhaustErrorTmpFile = false;
68
69 protected $maxRetries = 10;
70
71 /**
72 * @var bool
73 */
74 protected $isCancelJob = false;
75
76 public function __construct(
77 Cache $jobDataCache,
78 JobDataDto $jobDataDto,
79 Filesystem $filesystem,
80 Directory $directory,
81 ProcessLock $processLock,
82 DiskWriteCheck $diskFullCheck,
83 JobTransientCache $jobTransientCache
84 ) {
85 $this->jobDataDto = $jobDataDto;
86 $this->jobDataCache = $jobDataCache;
87 $this->filesystem = $filesystem;
88 $this->directory = $directory;
89
90 $this->jobDataCache->setLifetime(HOUR_IN_SECONDS);
91 $this->jobDataCache->setFilename('jobCache_' . $this::getJobName());
92
93 $this->processLock = $processLock;
94 $this->diskFullCheck = $diskFullCheck;
95 $this->maxRetries = apply_filters(Assets::FILTER_TESTS_MAXIMUM_RETRIES, $this->maxRetries);
96
97 $this->jobTransientCache = $jobTransientCache;
98 }
99
100 /**
101 * Persists the Job status to the current cross-request caching system.
102 *
103 * This method will be invoked in the context of the WordPress `shutdown` hook and should
104 * not be invoked out of that context if not with full knowledge of its side-effects.
105 *
106 * @return void The method has the side-effect of persisting the Job status to the caching
107 * system.
108 */
109 public function persist()
110 {
111 if ($this->jobDataDto->isStatusCheck()) {
112 return;
113 }
114
115 try {
116 $this->diskFullCheck->testDiskIsWriteable();
117 } catch (DiskNotWritableException $e) {
118 // no-op, this is handled on the beginning of the next request
119 }
120
121 if ($this->jobDataDto->isFinished() && !$this->jobDataDto->isCleaned()) {
122 $this->cleanup();
123 $this->jobDataDto->setCleaned();
124 $this->hasPersisted = true;
125 return;
126 }
127
128 if ($this->currentTask instanceof AbstractTask) {
129 $this->jobDataDto->setQueueOffset($this->currentTask->getQueue()->getOffset());
130 $this->currentTask->persistStepsDto();
131 }
132
133 $this->persistJobDataDto();
134
135 $this->hasPersisted = true;
136 }
137
138 /**
139 * @return void
140 */
141 public function persistJobDataDto()
142 {
143 $data = $this->jobDataDto->toArray();
144
145 try {
146 // save() reports a refused write by returning false rather than throwing. Letting
147 // that pass lets a caller publish a checkpoint that depends on this write.
148 if ($this->jobDataCache->save($data, true) === false) {
149 throw new \RuntimeException('Could not persist Job data to cache.');
150 }
151 } catch (\Exception $e) {
152 debug_log("Could not persist Job data to cache:" . $e->getMessage());
153 throw new \RuntimeException('Could not persist Job data to cache: ' . $e->getMessage(), 0, $e);
154 }
155 }
156
157 /**
158 * This method will be called in the context of the WordPress `shutdown` action to
159 * persist the Job status once and only once.
160 *
161 * @return void The method has the side-effect of persisting the Job status to the caching
162 * system.
163 */
164 public function onWpShutdown()
165 {
166 $this->persist();
167 }
168
169 /**
170 * Last chance to write the job state, after the `shutdown` action has had its turn.
171 *
172 * A fatal in a callback ahead of this job abandons the rest of the action, and the
173 * state that never reaches disk is the state the next request resumes from. PHP still
174 * calls the remaining shutdown functions after such a fatal, which is the hole this
175 * closes.
176 *
177 * It is a backstop, not a guarantee. A callback ahead of us calling exit() ends the
178 * shutdown sequence outright, and a request killed by the process manager or the OOM
179 * killer reaches no PHP handler at all — which is why the restore checkpoints its
180 * progress as it goes rather than relying on being told it is about to die.
181 *
182 * @return void
183 */
184 public function persistIfShutdownActionDidNotRun()
185 {
186 if ($this->hasPersisted) {
187 return;
188 }
189
190 try {
191 $this->persist();
192 } catch (\Throwable $e) {
193 // Nothing above can report an error this late, and throwing out of a shutdown
194 // function turns a lost checkpoint into a fatal on top of it.
195 debug_log('Job state could not be persisted on shutdown: ' . $e->getMessage());
196 }
197 }
198
199 /**
200 * @return void
201 */
202 protected function registerShutdownBackstop()
203 {
204 if ($this->hasShutdownBackstop) {
205 return;
206 }
207
208 $this->hasShutdownBackstop = true;
209
210 register_shutdown_function([$this, 'persistIfShutdownActionDidNotRun']);
211 }
212
213 /**
214 * @return string
215 * @throws WPStagingException
216 */
217 public static function getJobName()
218 {
219 throw new WPStagingException('Any extending class MUST override the getJobName method.');
220 }
221
222 /** @return array */
223 abstract protected function getJobTasks();
224
225 /** @return TaskResponseDto */
226 abstract protected function execute();
227
228 /** @return void */
229 abstract protected function init();
230
231 /** @return TaskResponseDto */
232 public function prepareAndExecute()
233 {
234 // Acquired before anything reads or writes the job state, including the two exits below
235 // that delete the job cache: a request that owns none of the state must not be able to
236 // throw away the state another worker is running on, and the persist this request does
237 // on shutdown has to be covered by the same ownership. It is released when the request
238 // ends, so one acquisition covers hydrating, executing and persisting alike.
239 $this->processLock->lockProcess();
240
241 try {
242 // Check if the last request bailed with a Disk Write failure flag.
243 $this->diskFullCheck->hasDiskWriteTestFailed();
244 } catch (DiskNotWritableException $e) {
245 $this->jobDataCache->delete();
246
247 return $this->getJobFailResponse($e->getMessage());
248 }
249
250 if ($this->getIsCancelled()) {
251 $this->jobDataCache->delete();
252
253 return $this->getJobCancelResponse();
254 }
255
256 try {
257 try {
258 $this->prepare();
259 } catch (TaskHealthException $e) {
260 if ($e->getCode() === TaskHealthException::CODE_TASK_FAILED_TOO_MANY_TIMES) {
261 $this->jobDataCache->delete();
262
263 return $this->getJobFailResponse($e->getMessage());
264 } else {
265 return $this->getJobRetryResponse($e->getMessage());
266 }
267 } catch (RuntimeException $ex) {
268 $this->jobDataCache->delete();
269
270 return $this->getJobFailResponse($ex->getMessage());
271 }
272
273 $this->registerShutdownBackstop();
274
275 /** @var TaskResponseDto $response */
276 $response = $this->execute();
277
278 /*
279 * Let's display the name of the task running now, instead
280 * of the task that just run to the user.
281 *
282 * Since we already popped from the queue to get here,
283 * the current item now is the next.
284 */
285 $nextTask = $this->jobDataDto->getCurrentTask();
286
287 if (is_subclass_of($nextTask, AbstractTask::class)) {
288 $response->setStatusTitle(call_user_func("$nextTask::getTaskTitle"));
289 }
290
291 $this->removeMemoryExhaustErrorTmpFile();
292
293 if ($this->getIsCancelled()) {
294 $this->jobDataCache->delete();
295
296 return $this->getJobCancelResponse();
297 }
298
299 return $response;
300 } catch (DiskNotWritableException $e) {
301 /**
302 * Assume a DiskWriteCheck flag has been set, so the next request can pick it up.
303 *
304 * @see DiskWriteCheck::testDiskIsWriteable()
305 * @see DiskWriteCheck::hasDiskWriteTestFailed()
306 */
307 return $this->getJobRetryResponse($e->getMessage());
308 }
309 }
310
311 /**
312 * @return void
313 */
314 public function updateTasks()
315 {
316 $this->init();
317 $this->addTasks($this->getJobTasks());
318 }
319
320 /**
321 * @return JobTransientCache
322 */
323 public function getTransientCache(): JobTransientCache
324 {
325 return $this->jobTransientCache;
326 }
327
328 /**
329 * @return JobDataDto
330 */
331 public function getJobDataDto()
332 {
333 return $this->jobDataDto;
334 }
335
336 /**
337 * @var $jobDataDto JobDataDto
338 */
339 public function setJobDataDto($jobDataDto)
340 {
341 $this->jobDataDto = $jobDataDto;
342 }
343
344 public function getIsCancelled(): bool
345 {
346 if ($this->isCancelJob) {
347 return false;
348 }
349
350 try {
351 return $this->jobTransientCache->getJobStatus() === JobTransientCache::STATUS_CANCELLED;
352 } catch (\Throwable $e) {
353 // If the job transient cache is not set, we assume the job is not cancelled.
354 return false;
355 }
356 }
357
358 /**
359 * @return void
360 */
361 protected function checkLastTaskHealth()
362 {
363 // Early bail: No task health on a task that is retrying a failed request. We will evaluate that on the next request.
364 if ($this->jobDataDto->getTaskHealthIsRetrying()) {
365 $this->jobDataDto->setTaskHealthIsRetrying(false);
366
367 return;
368 }
369
370 if (!$this->jobDataDto->getTaskHealthResponded()) {
371 // This happens when the previous task started but never generated a response.
372 $this->jobDataDto->setTaskHealthSequentialFailedRetries($this->jobDataDto->getTaskHealthSequentialFailedRetries() + 1);
373 $this->jobDataCache->save($this->jobDataDto);
374
375 if ($this->jobDataDto->getTaskHealthSequentialFailedRetries() >= $this->maxRetries) {
376 throw TaskHealthException::taskFailedTooManyTimes();
377 } else {
378 $this->jobDataDto->setTaskHealthIsRetrying(true);
379 throw TaskHealthException::retryingTask($this->jobDataDto->getTaskHealthSequentialFailedRetries(), $this->maxRetries);
380 }
381 }
382 }
383
384 public function prepare()
385 {
386 $data = $this->jobDataCache->get([]);
387
388 if ($data) {
389 $this->jobDataDto->hydrate($data);
390 }
391
392 // From now on, classes that require a JobDataDto will receive this instance.
393 WPStaging::getInstance()->getContainer()->singleton(JobDataDto::class, $this->jobDataDto);
394
395 $action = empty($_GET['action']) ? '' : sanitize_text_field($_GET['action']);
396 if (empty($action)) {
397 $action = empty($_POST['action']) ? '' : sanitize_text_field($_POST['action']);
398 }
399
400 $this->jobDataDto->setStatusCheck(in_array($action, ['wpstg--backups--status', 'wpstg--job--status'], true));
401 if ($this->jobDataDto->isStatusCheck()) {
402 return;
403 }
404
405 if ($this->jobDataDto->isInit()) {
406 $this->cleanup();
407 $this->init();
408 $this->jobDataDto->setCurrentTaskIndex(0);
409 $this->jobDataDto->setCurrentTaskData([]);
410 $this->addTasks($this->getJobTasks());
411 } else {
412 $this->checkLastTaskHealth();
413 }
414
415 $this->jobDataDto->setInit(false);
416
417 $this->currentTaskName = $this->jobDataDto->getCurrentTask();
418
419 if (empty($this->currentTaskName)) {
420 throw new \RuntimeException('Internal error: Next task of queue job is null or invalid.');
421 }
422
423 /** @var AbstractTask currentTask */
424 $this->currentTask = WPStaging::getInstance()->get($this->currentTaskName);
425
426 if (!$this->currentTask instanceof AbstractTask) {
427 throw new \RuntimeException('Is there enough free disk space? Please free up some space. Delete old backup files and staging sites and try again. Error: Next task of queue job is null or invalid. Task name: ' . $this->currentTaskName . ' Task: ' . print_r($this->currentTask, true));
428 }
429
430 if (!$this->jobDataDto instanceof AbstractDto) {
431 throw new \RuntimeException('Job Queue DTO is null or invalid.');
432 }
433
434 $this->currentTask->setJobContext($this);
435 $this->currentTask->setJobDataDto($this->jobDataDto);
436 $this->currentTask->setJobId($this->jobDataDto->getId());
437 $this->currentTask->setJobName($this::getJobName());
438 $this->currentTask->setDebug(defined('WPSTG_DEBUG') && WPSTG_DEBUG);
439 $this->currentTask->setupLogger();
440
441 // Initialize Task Health Status
442 $this->jobDataDto->setTaskHealthName($this->currentTaskName);
443 $this->jobDataDto->setTaskHealthResponded(false);
444 }
445
446 public function commitLogs()
447 {
448 if ($this->currentTask instanceof AbstractTask) {
449 $this->currentTask->commitLogs();
450 }
451 }
452
453 /** @return AbstractTask */
454 public function getCurrentTask()
455 {
456 return $this->currentTask;
457 }
458
459 /**
460 * @param string $memoryExhaustErrorTmpFile
461 * @return void
462 */
463 public function setMemoryExhaustErrorTmpFile(string $memoryExhaustErrorTmpFile)
464 {
465 $this->memoryExhaustErrorTmpFile = $memoryExhaustErrorTmpFile;
466 }
467
468 protected function removeMemoryExhaustErrorTmpFile()
469 {
470 if ($this->memoryExhaustErrorTmpFile === '') {
471 return;
472 }
473
474 if (file_exists($this->memoryExhaustErrorTmpFile)) {
475 unlink($this->memoryExhaustErrorTmpFile);
476 }
477 }
478
479 protected function cleanup()
480 {
481 // This excludes all files except cache files from deleting i.e. only delete .cache files
482 $this->filesystem->setExcludePaths(['*.*', '!*.cache.php', '!*.cache', '!*.wpstg', '!*.sql']);
483 $this->filesystem->delete($this->directory->getCacheDirectory(), $deleteSelf = false);
484 $this->filesystem->setExcludePaths([]);
485 $this->filesystem->mkdir($this->directory->getCacheDirectory(), true);
486 }
487
488 /**
489 * @return void
490 */
491 protected function deleteJobDataCache()
492 {
493 $this->jobDataCache->delete();
494 }
495
496 /**
497 * @param TaskResponseDto $response
498 *
499 * @return TaskResponseDto
500 */
501 protected function getResponse(TaskResponseDto $response)
502 {
503 $this->jobDataDto->setTaskHealthResponded(true);
504 $this->jobDataDto->setTaskHealthSequentialFailedRetries(0);
505
506 $response->setJob(substr($this->findCurrentJob(), 3));
507
508 // Task is not done yet, add it to beginning of the queue again
509 if ($response->isRunning()) {
510 $className = get_class($this->currentTask);
511 }
512
513 try {
514 if (!$response->isRunning()) {
515 $this->jobDataDto->moveToNextTask();
516 // Persist the updated task index immediately while the process lock is still held.
517 // This prevents a race condition where a concurrent background process could read
518 // the stale currentTaskIndex from cache and re-execute the just-completed task.
519 $this->persistJobDataDto();
520 }
521 } catch (FinishedQueueException $e) {
522 $this->jobDataDto->setFinished(true);
523 // Persist completion state immediately to prevent stale task index reads by concurrent requests.
524 $this->persistJobDataDto();
525
526 return $response;
527 }
528
529 $response->setIsRunning(true);
530
531 return $response;
532 }
533
534 private function findCurrentJob()
535 {
536 $class = explode('\\', static::class);
537
538 return end($class);
539 }
540
541 protected function addTasks(array $tasks = [])
542 {
543 $this->jobDataDto->setTaskQueue($tasks);
544 }
545
546 protected function getJobCancelResponse(): TaskResponseDto
547 {
548 $response = new TaskResponseDto();
549 $response->setIsRunning(false);
550 $response->setJobStatus('JOB_CANCEL');
551 $response->addMessage([
552 'type' => 'critical',
553 'date' => $this->getFormattedDate(),
554 'message' => esc_html__('Job is cancelled', 'wp-staging'),
555 ]);
556
557 return $response;
558 }
559
560 protected function getJobFailResponse(string $message): TaskResponseDto
561 {
562 $response = new TaskResponseDto();
563 $response->setIsRunning(false);
564 $response->setJobStatus('JOB_FAIL');
565 $response->addMessage([
566 'type' => 'critical',
567 'date' => $this->getFormattedDate(),
568 'message' => esc_html($message, 'wp-staging'),
569 ]);
570
571 return $response;
572 }
573
574 protected function getJobRetryResponse(string $message): TaskResponseDto
575 {
576 $response = new TaskResponseDto();
577 $response->setIsRunning(true);
578 $response->setJobStatus('JOB_RETRY');
579 $response->addMessage([
580 'type' => 'warning',
581 'date' => $this->getFormattedDate(),
582 'message' => esc_html($message, 'wp-staging'),
583 ]);
584
585 return $response;
586 }
587
588 /**
589 * @return string Formatted date string
590 */
591 private function getFormattedDate()
592 {
593 return current_time(Logger::LOG_DATETIME_FORMAT);
594 }
595 }
596