PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.2
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.2
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 2 months ago BackgroundProcessing 1 year ago Dto 2 weeks ago Exception 3 months ago Interfaces 2 weeks ago Jobs 5 months ago Task 2 weeks ago Traits 9 months ago AbstractJob.php 1 day ago JobProvider.php 1 year ago JobServiceProvider.php 4 months ago JobTransientCache.php 2 months ago ProcessLock.php 1 year ago
AbstractJob.php
548 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 * @return void
442 */
443 protected function deleteJobDataCache()
444 {
445 $this->jobDataCache->delete();
446 }
447
448 /**
449 * @param TaskResponseDto $response
450 *
451 * @return TaskResponseDto
452 */
453 protected function getResponse(TaskResponseDto $response)
454 {
455 $this->jobDataDto->setTaskHealthResponded(true);
456 $this->jobDataDto->setTaskHealthSequentialFailedRetries(0);
457
458 $response->setJob(substr($this->findCurrentJob(), 3));
459
460 // Task is not done yet, add it to beginning of the queue again
461 if ($response->isRunning()) {
462 $className = get_class($this->currentTask);
463 }
464
465 try {
466 if (!$response->isRunning()) {
467 $this->jobDataDto->moveToNextTask();
468 // Persist the updated task index immediately while the process lock is still held.
469 // This prevents a race condition where a concurrent background process could read
470 // the stale currentTaskIndex from cache and re-execute the just-completed task.
471 $this->persistJobDataDto();
472 }
473 } catch (FinishedQueueException $e) {
474 $this->jobDataDto->setFinished(true);
475 // Persist completion state immediately to prevent stale task index reads by concurrent requests.
476 $this->persistJobDataDto();
477
478 return $response;
479 }
480
481 $response->setIsRunning(true);
482
483 return $response;
484 }
485
486 private function findCurrentJob()
487 {
488 $class = explode('\\', static::class);
489
490 return end($class);
491 }
492
493 protected function addTasks(array $tasks = [])
494 {
495 $this->jobDataDto->setTaskQueue($tasks);
496 }
497
498 protected function getJobCancelResponse(): TaskResponseDto
499 {
500 $response = new TaskResponseDto();
501 $response->setIsRunning(false);
502 $response->setJobStatus('JOB_CANCEL');
503 $response->addMessage([
504 'type' => 'critical',
505 'date' => $this->getFormattedDate(),
506 'message' => esc_html__('Job is cancelled', 'wp-staging'),
507 ]);
508
509 return $response;
510 }
511
512 protected function getJobFailResponse(string $message): TaskResponseDto
513 {
514 $response = new TaskResponseDto();
515 $response->setIsRunning(false);
516 $response->setJobStatus('JOB_FAIL');
517 $response->addMessage([
518 'type' => 'critical',
519 'date' => $this->getFormattedDate(),
520 'message' => esc_html($message, 'wp-staging'),
521 ]);
522
523 return $response;
524 }
525
526 protected function getJobRetryResponse(string $message): TaskResponseDto
527 {
528 $response = new TaskResponseDto();
529 $response->setIsRunning(true);
530 $response->setJobStatus('JOB_RETRY');
531 $response->addMessage([
532 'type' => 'warning',
533 'date' => $this->getFormattedDate(),
534 'message' => esc_html($message, 'wp-staging'),
535 ]);
536
537 return $response;
538 }
539
540 /**
541 * @return string Formatted date string
542 */
543 private function getFormattedDate()
544 {
545 return current_time(Logger::LOG_DATETIME_FORMAT);
546 }
547 }
548