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