From fc3ce85f8883947cf9a0c6b4700e6f5f6ffa93ef Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:40:01 +0200 Subject: [PATCH] feat(horizon): suppress failed job entries for deployment/timeout errors on cloud On cloud, DeploymentException and TimeoutExceededException are expected failure modes that pollute the Horizon failed jobs UI. Listen to JobFailed events and scrub the entry via JobRepository::deleteFailed so operators are not alerted for noise failures. Self-hosted instances are unaffected. --- app/Providers/HorizonServiceProvider.php | 23 +++++++ .../SuppressHorizonJobFailuresTest.php | 65 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/Feature/SuppressHorizonJobFailuresTest.php diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index 0caa3a3a9..c29f7fc41 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -3,9 +3,12 @@ namespace App\Providers; use App\Contracts\CustomJobRepositoryInterface; +use App\Exceptions\DeploymentException; use App\Models\ApplicationDeploymentQueue; use App\Models\User; use App\Repositories\CustomJobRepository; +use Illuminate\Queue\Events\JobFailed; +use Illuminate\Queue\TimeoutExceededException; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Laravel\Horizon\Contracts\JobRepository; @@ -48,6 +51,26 @@ public function boot(): void ]); } }); + + Event::listen(function (JobFailed $event) { + if (! isCloud()) { + return; + } + + $exception = $event->exception; + if (! ($exception instanceof DeploymentException) && ! ($exception instanceof TimeoutExceededException)) { + return; + } + + try { + $uuid = $event->job->uuid(); + if ($uuid) { + app(JobRepository::class)->deleteFailed($uuid); + } + } catch (\Throwable $e) { + // Best-effort scrub; never mask the original failure. + } + }); } protected function gate(): void diff --git a/tests/Feature/SuppressHorizonJobFailuresTest.php b/tests/Feature/SuppressHorizonJobFailuresTest.php new file mode 100644 index 000000000..ead342c31 --- /dev/null +++ b/tests/Feature/SuppressHorizonJobFailuresTest.php @@ -0,0 +1,65 @@ +shouldIgnoreMissing(); + $job->shouldReceive('uuid')->andReturn($uuid); + $job->shouldReceive('getJobId')->andReturn($uuid); + + return $job; +} + +function fireJobFailed(Job $job, Throwable $exception): void +{ + event(new JobFailed('redis', $job, $exception)); +} + +beforeEach(function () { + config(['constants.coolify.self_hosted' => false]); +}); + +test('scrubs Horizon failed entry for DeploymentException on cloud', function () { + $uuid = 'uuid-deployment-1'; + + $this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) { + $mock->shouldReceive('deleteFailed')->once()->with($uuid); + }); + + fireJobFailed(fakeJob($uuid), new DeploymentException('build failed')); +}); + +test('scrubs Horizon failed entry for TimeoutExceededException on cloud', function () { + $uuid = 'uuid-timeout-1'; + + $this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) { + $mock->shouldReceive('deleteFailed')->once()->with($uuid); + }); + + fireJobFailed(fakeJob($uuid), new TimeoutExceededException('worker timeout')); +}); + +test('does not scrub generic exceptions on cloud', function () { + $this->mock(JobRepository::class, function (MockInterface $mock) { + $mock->shouldNotReceive('deleteFailed'); + }); + + fireJobFailed(fakeJob('uuid-generic-1'), new RuntimeException('boom')); +}); + +test('does not scrub when self-hosted even for filtered exceptions', function () { + config(['constants.coolify.self_hosted' => true]); + + $this->mock(JobRepository::class, function (MockInterface $mock) { + $mock->shouldNotReceive('deleteFailed'); + }); + + fireJobFailed(fakeJob('uuid-deployment-2'), new DeploymentException('build failed')); + fireJobFailed(fakeJob('uuid-timeout-2'), new TimeoutExceededException('worker timeout')); +});