diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index e805355..2ff95bc 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -4,6 +4,7 @@ use PhpCsFixer\Finder; $finder = Finder::create() + ->notPath(['tests/Stubs/PhpParseErrorJob.php']) ->exclude(['.github', 'bin', 'git-hooks']) ->in(__DIR__); $config = new Config(); diff --git a/CHANGELOG.md b/CHANGELOG.md index b47b08e..f2e6529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2024-08-03 +### Added +* Error handling functionality. You can now add a `error_handler` in your config, like this: `['error_handler' => ['class' => MyErrorHandler::class, 'active' => true]`. The class needs to extend the new abstract class `Otsch\Ppq\AbstractErrorHandler`. In its `boot()` method you can register one or multiple error handlers via its own `registerHandler()` method. The handlers are automatically called with any uncaught exception or PHP warnings and errors (turned into `ErrorException`s) occuring during a PPQ job execution. + ## [0.1.2] - 2022-07-13 ### Fixed * Fix identifying Sub-Processes that need to be killed, when cancelling a running Process. diff --git a/README.md b/README.md index b0079b5..9f9a0b9 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,19 @@ return [ */ 'active' => false, ], + + /** + * Provide your own error handler, that will be called with all + * uncaught exceptions and PHP errors (and optionally warnings). + */ + 'error_handler' => [ + 'class' => MyErrorHandler::class, + + /** + * Same as in the scheduler setting above. + */ + 'active' => true, + ], ]; ``` @@ -390,3 +403,41 @@ So, if the `php vendor/bin/ppq check-schedule` command is run exactly at 15 minu ``` * * * * * php /path/to/your/project/vendor/bin/ppq check-schedule ``` + +### Error Handling + +To catch and handle errors (Exceptions and PHP errors/warnings) happening in your PPQ background jobs, you can define an `error_handler` in your config (see example config at the top). Your error handler class must extend the abstract `Otsch\Ppq\AbstractErrorHandler` class and in the `boot()` method you can register your handler. + +```php +use Otsch\Ppq\AbstractErrorHandler; + +class MyErrorHandler extends AbstractErrorHandler +{ + public function boot(): void + { + $this->registerHandler( + function (Throwable $exception) { + if ($exception instanceof ErrorException) { + // This is a PHP error. You can get the error level via: + + if ($exception->getSeverity() === E_ERROR) { + // Handle PHP error. + } elseif ($exception->getSeverity() === E_WARNING) { + // Handle PHP warning. + // If you don't want this error handler to be called + // with PHP warnings at all, you can provide bool false + // as the second argument in the registerHandler() call + // (see further below). + } else { + // Handle other PHP error (E_PARSE or others). + } + } else { + // Handle an uncaught exception thrown somewhere + // in your application code. + } + }, + // true, // If you want to completely ignore PHP warnings (as mentioned above). + ); + } +} +``` diff --git a/phpstan.neon b/phpstan.neon index 72c95a4..1ccf9b3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,5 +3,7 @@ parameters: paths: - src - tests + excludePaths: + - tests/Stubs/PhpParseErrorJob.php ignoreErrors: - "#^Call to an undefined method Pest\\\\Expectation\\|Pest\\\\Support\\\\Extendable\\:\\:\\S+\\(\\)\\.$#" diff --git a/src/AbstractErrorHandler.php b/src/AbstractErrorHandler.php new file mode 100644 index 0000000..4d5bee6 --- /dev/null +++ b/src/AbstractErrorHandler.php @@ -0,0 +1,84 @@ +boot(); + } + + abstract public function boot(): void; + + public function handleException(Throwable $exception): void + { + if ($this->active) { + foreach ($this->handlers as $handler) { + $handler($exception); + } + } + } + + public function deactivate(): void + { + $this->active = false; + + if (!empty($this->handlers) && !empty($this->initialHandlers)) { + set_error_handler($this->initialHandlers); + } + } + + protected function registerHandler(Closure $handler, bool $ignoreWarnings = false): static + { + $firstHandler = empty($this->handlers); + + $this->handlers[] = $handler; + + $initialHandlers = set_error_handler( + function ( + int $errno, + string $errstr, + string $errfile, + int $errline, + ) use ($handler) { + $handler(new ErrorException($errstr, 0, $errno, $errfile, $errline)); + + return false; + }, + $ignoreWarnings ? E_ALL & ~E_WARNING & ~E_NOTICE : E_ALL & ~E_NOTICE, + ); + + if ($firstHandler) { + $this->initialHandlers = $initialHandlers; + + register_shutdown_function(function () use ($handler) { + if ($this->active) { + $error = error_get_last(); + + if ($error && in_array($error['type'], [E_ERROR, E_PARSE], true)) { + $handler(new ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line'])); + } + } + }); + } + + return $this; + } +} diff --git a/src/Kernel.php b/src/Kernel.php index dc81fa4..65c95b0 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -110,7 +110,7 @@ protected function runJob(): void { $job = $this->getJobByIdOrFail(); - /** @var QueueRecord $job */ + $errorHandler = $this->getErrorHandler(); try { $job = new $job->jobClass(...$job->args); @@ -123,16 +123,22 @@ protected function runJob(): void $job->invoke(); } catch (Exception $exception) { - $this->fail->withMessage($exception->getMessage()); + $errorHandler?->handleException($exception); + + $this->fail->withMessage( + 'Uncaught ' . get_class($exception) . ': ' . $exception->getMessage() . PHP_EOL . ' in ' . + $exception->getFile() . ' on line ' . $exception->getLine() + ); } } + /** + * @throws Exception + */ protected function cancelJob(): void { $job = $this->getJobByIdOrFail(); - /** @var QueueRecord $job */ - Ppq::cancel($job->id); } @@ -202,13 +208,14 @@ protected function flushAllQueues(): void $this->logger->info('Flushed all queues'); } + /** + * @throws Exception + */ protected function showLog(): void { if ($this->argv->jobId()) { $job = $this->getJobByIdOrFail(); - /** @var QueueRecord $job */ - $numberOfLines = $this->argv->lines(); if (is_numeric($numberOfLines)) { @@ -223,7 +230,34 @@ protected function showLog(): void } } - protected function getJobByIdOrFail(): ?QueueRecord + /** + * @throws Exception + */ + protected function getErrorHandler(): ?AbstractErrorHandler + { + $mainErrorHandler = Config::get('error_handler'); + + if ( + isset($mainErrorHandler['class']) && + isset($mainErrorHandler['active']) && + $mainErrorHandler['active'] === true + ) { + $handler = new $mainErrorHandler['class'](); + + if (!$handler instanceof AbstractErrorHandler) { + throw new Exception('Configured error_handler class doesn\'t extend the AbstractErrorHandler class.'); + } + + return $handler; + } + + return null; + } + + /** + * @throws Exception + */ + protected function getJobByIdOrFail(): QueueRecord { if (!$this->argv->jobId()) { throw new Exception('No or invalid job id.'); @@ -233,6 +267,8 @@ protected function getJobByIdOrFail(): ?QueueRecord if ($job === null) { $this->fail->withMessage('Job with id ' . $this->argv->jobId() . ' not found'); + + throw new Exception('This exception should only be possible in the unit tests when mocking $this->fail'); } return $job; diff --git a/tests/ErrorHandlerTest.php b/tests/ErrorHandlerTest.php new file mode 100644 index 0000000..10c8510 --- /dev/null +++ b/tests/ErrorHandlerTest.php @@ -0,0 +1,37 @@ +registerHandler(function (Throwable $exception) { + $this->_exceptions[] = $exception; + }); + + $this->registerHandler(function (Throwable $exception) { + $this->_exceptions2[] = $exception; + }); + } + }; + + $exception = new InvalidArgumentException('test test'); + + $handler->handleException($exception); + + expect($handler->_exceptions[0])->toBe($exception) + ->and($handler->_exceptions2[0])->toBe($exception); + + $handler->deactivate(); // So it does not influence other tests running in the same process. +}); diff --git a/tests/KernelTest.php b/tests/KernelTest.php index a801576..14ec314 100644 --- a/tests/KernelTest.php +++ b/tests/KernelTest.php @@ -83,13 +83,19 @@ it('fails when the job ID to run is not on the queue', function () { $failMock = Mockery::mock(Fail::class); - $failMock->shouldReceive('withMessage')->twice(); // @phpstan-ignore-line + $failMock->shouldReceive('withMessage')->once(); // @phpstan-ignore-line $queueJob = new QueueRecord('default', TestJob::class); $kernel = new Kernel(['vendor/bin/ppq', 'run', $queueJob->id], fail: $failMock); // @phpstan-ignore-line - $kernel->run(); + try { + $kernel->run(); + } catch (Exception $exception) { + expect($exception->getMessage())->toBe( + 'This exception should only be possible in the unit tests when mocking $this->fail', + ); + } }); it('fails when the job class to run does not implement the QueueableJob interface', function () { diff --git a/tests/Pest.php b/tests/Pest.php index ad6ce14..f445741 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -130,4 +130,27 @@ function helper_containsInOneLine(string $string, array $contains): bool } return false; -}; +} + +function helper_emptyHandlerEventsFile(): void +{ + $handlerEventsFile = helper_testDataPath('error-handler-events'); + + if (!file_exists($handlerEventsFile)) { + touch($handlerEventsFile); + } else { + file_put_contents($handlerEventsFile, ''); + } +} + +function helper_dump(mixed $var): void +{ + error_log(var_export($var, true)); +} + +function helper_dieDump(mixed $var): void +{ + error_log(var_export($var, true)); + + exit; +} diff --git a/tests/Stubs/AbstractTestErrorHandler.php b/tests/Stubs/AbstractTestErrorHandler.php new file mode 100644 index 0000000..cc8fcb5 --- /dev/null +++ b/tests/Stubs/AbstractTestErrorHandler.php @@ -0,0 +1,33 @@ +getSeverity() === E_WARNING) { + $message = 'PHP Warning: ' . $exception->getMessage(); + } elseif ($exception->getSeverity() === E_ERROR) { + $message = 'PHP Error: ' . $exception->getMessage(); + } elseif ($exception->getSeverity() === E_PARSE) { + $message = 'PHP Parse Error: ' . $exception->getMessage(); + } else { + $message = 'PHP Error (Severity ' . $exception->getSeverity() . '): ' . $exception->getMessage(); + } + } else { + $message = get_class($exception) . ': ' . $exception->getMessage(); + } + + file_put_contents( + __DIR__ . '/../_testdata/datapath/error-handler-events', + $message . PHP_EOL, + FILE_APPEND, + ); + } +} diff --git a/tests/Stubs/ErrorHandler.php b/tests/Stubs/ErrorHandler.php new file mode 100644 index 0000000..9c69a3b --- /dev/null +++ b/tests/Stubs/ErrorHandler.php @@ -0,0 +1,15 @@ +registerHandler(function (Throwable $exception) { + $this->logErrorEvent($exception); + }); + } +} diff --git a/tests/Stubs/ErrorHandlerIgnoreWarnings.php b/tests/Stubs/ErrorHandlerIgnoreWarnings.php new file mode 100644 index 0000000..26fea94 --- /dev/null +++ b/tests/Stubs/ErrorHandlerIgnoreWarnings.php @@ -0,0 +1,15 @@ +registerHandler(function (Throwable $exception) { + $this->logErrorEvent($exception); + }, true); // Set second parameter "ignoreWarnings" to true. + } +} diff --git a/tests/Stubs/ExceptionJob.php b/tests/Stubs/ExceptionJob.php new file mode 100644 index 0000000..1c78fa7 --- /dev/null +++ b/tests/Stubs/ExceptionJob.php @@ -0,0 +1,17 @@ +nonExistingFunction(); // @phpstan-ignore-line + } +} diff --git a/tests/Stubs/PhpParseErrorJob.php b/tests/Stubs/PhpParseErrorJob.php new file mode 100644 index 0000000..1663bde --- /dev/null +++ b/tests/Stubs/PhpParseErrorJob.php @@ -0,0 +1,16 @@ +job(PhpWarningJob::class) + ->dispatch(); + + Utils::tryUntil(function () use ($job) { + return Ppq::find($job->id)?->status === QueueJobStatus::finished; + }); + + $job = Ppq::find($job->id); + + $handlerEvents = file_get_contents(helper_testDataPath('error-handler-events')); + + expect($job?->status)->toBe(QueueJobStatus::finished) + ->and($handlerEvents)->not->toContain('PHP Warning: unserialize(): Error at offset 0 of 3 bytes'); +}); diff --git a/tests/_integration/ErrorHandlerTest.php b/tests/_integration/ErrorHandlerTest.php new file mode 100644 index 0000000..63694f3 --- /dev/null +++ b/tests/_integration/ErrorHandlerTest.php @@ -0,0 +1,106 @@ +job(ExceptionJob::class) + ->dispatch(); + + Utils::tryUntil(function () use ($job) { + return Ppq::find($job->id)?->status === QueueJobStatus::failed; + }); + + $job = Ppq::find($job->id); + + $handlerEvents = file_get_contents(helper_testDataPath('error-handler-events')); + + expect($job?->status)->toBe(QueueJobStatus::failed) + ->and($handlerEvents)->toContain('Exception: This is an uncaught test exception'); +}); + +it('handles a PHP warning', function () { + $job = Dispatcher::queue('default') + ->job(PhpWarningJob::class) + ->dispatch(); + + Utils::tryUntil(function () use ($job) { + return Ppq::find($job->id)?->status === QueueJobStatus::failed; + }); + + $job = Ppq::find($job->id); + + $handlerEvents = file_get_contents(helper_testDataPath('error-handler-events')); + + expect($job?->status)->toBe(QueueJobStatus::finished) + ->and($handlerEvents)->toContain('PHP Warning: unserialize(): Error at offset 0 of 3 bytes'); +}); + +it('handles a PHP error', function () { + $job = Dispatcher::queue('default') + ->job(PhpErrorJob::class) + ->dispatch(); + + Utils::tryUntil(function () use ($job) { + return Ppq::find($job->id)?->status === QueueJobStatus::failed; + }); + + $job = Ppq::find($job->id); + + $handlerEvents = file_get_contents(helper_testDataPath('error-handler-events')); + + expect($job?->status)->toBe(QueueJobStatus::failed) + ->and($handlerEvents)->toContain('PHP Error: Uncaught Error: Call to undefined method'); +}); + +it('handles a PHP parse error', function () { + $job = Dispatcher::queue('default') + ->job(PhpParseErrorJob::class) // @phpstan-ignore-line + ->dispatch(); + + Utils::tryUntil(function () use ($job) { + return Ppq::find($job->id)?->status === QueueJobStatus::failed; + }); + + $job = Ppq::find($job->id); + + $handlerEvents = file_get_contents(helper_testDataPath('error-handler-events')); + + expect($job?->status)->toBe(QueueJobStatus::failed) + ->and($handlerEvents)->toContain('PHP Parse Error: syntax error, unexpected identifier "error"'); +}); diff --git a/tests/_testdata/config/error-handler-ignore-warnings.php b/tests/_testdata/config/error-handler-ignore-warnings.php new file mode 100644 index 0000000..dc10c86 --- /dev/null +++ b/tests/_testdata/config/error-handler-ignore-warnings.php @@ -0,0 +1,18 @@ + __DIR__ . '/../datapath', + + 'queues' => [ + 'default' => [ + 'concurrent_jobs' => 2, + ], + ], + + 'error_handler' => [ + 'class' => ErrorHandlerIgnoreWarnings::class, + 'active' => true, + ], +]; diff --git a/tests/_testdata/config/error-handlers.php b/tests/_testdata/config/error-handlers.php new file mode 100644 index 0000000..791dfe5 --- /dev/null +++ b/tests/_testdata/config/error-handlers.php @@ -0,0 +1,18 @@ + __DIR__ . '/../datapath', + + 'queues' => [ + 'default' => [ + 'concurrent_jobs' => 2, + ], + ], + + 'error_handler' => [ + 'class' => ErrorHandler::class, + 'active' => true, + ], +];