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