refactor(server): dispatch event for reachability notifications, drop retry loop
Move reachability notification triggering out of isReachableChanged into a dedicated ServerReachabilityChanged event dispatched by ServerConnectionCheckJob. Remove the blocking 3-attempt sleep loop from isReachableChanged — unreachable_count threshold alone now gates the Unreachable notification. Add feature and unit tests covering all notification dispatch paths.
This commit is contained in:
parent
5c89a707cf
commit
b8226be774
5 changed files with 253 additions and 24 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Events\ServerReachabilityChanged;
|
||||||
use App\Helpers\SshMultiplexingHelper;
|
use App\Helpers\SshMultiplexingHelper;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Services\ConfigurationRepository;
|
use App\Services\ConfigurationRepository;
|
||||||
|
|
@ -43,6 +44,9 @@ private function disableSshMux(): void
|
||||||
|
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
|
$wasReachable = (bool) $this->server->settings->is_reachable;
|
||||||
|
$wasNotified = (bool) $this->server->unreachable_notification_sent;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if server is disabled
|
// Check if server is disabled
|
||||||
if ($this->server->settings->force_disabled) {
|
if ($this->server->settings->force_disabled) {
|
||||||
|
|
@ -84,6 +88,8 @@ public function handle()
|
||||||
'server_ip' => $this->server->ip,
|
'server_ip' => $this->server->ip,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,6 +105,8 @@ public function handle()
|
||||||
$this->server->update(['unreachable_count' => 0]);
|
$this->server->update(['unreachable_count' => 0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
Log::error('ServerConnectionCheckJob failed', [
|
Log::error('ServerConnectionCheckJob failed', [
|
||||||
|
|
@ -111,6 +119,8 @@ public function handle()
|
||||||
]);
|
]);
|
||||||
$this->server->increment('unreachable_count');
|
$this->server->increment('unreachable_count');
|
||||||
|
|
||||||
|
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,17 +128,41 @@ public function handle()
|
||||||
public function failed(?\Throwable $exception): void
|
public function failed(?\Throwable $exception): void
|
||||||
{
|
{
|
||||||
if ($exception instanceof TimeoutExceededException) {
|
if ($exception instanceof TimeoutExceededException) {
|
||||||
|
$wasReachable = (bool) $this->server->settings->is_reachable;
|
||||||
|
$wasNotified = (bool) $this->server->unreachable_notification_sent;
|
||||||
|
|
||||||
$this->server->settings->update([
|
$this->server->settings->update([
|
||||||
'is_reachable' => false,
|
'is_reachable' => false,
|
||||||
'is_usable' => false,
|
'is_usable' => false,
|
||||||
]);
|
]);
|
||||||
$this->server->increment('unreachable_count');
|
$this->server->increment('unreachable_count');
|
||||||
|
|
||||||
|
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
|
||||||
|
|
||||||
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
||||||
$this->job?->delete();
|
$this->job?->delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2)
|
||||||
|
* or when a previously-notified server recovers. Skips noise from single transient flaps.
|
||||||
|
*/
|
||||||
|
private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void
|
||||||
|
{
|
||||||
|
if ($isReachable) {
|
||||||
|
if (! $wasReachable || $wasNotified) {
|
||||||
|
ServerReachabilityChanged::dispatch($this->server);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->server->unreachable_count >= 2 && ! $wasNotified) {
|
||||||
|
ServerReachabilityChanged::dispatch($this->server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function checkHetznerStatus(): void
|
private function checkHetznerStatus(): void
|
||||||
{
|
{
|
||||||
$status = null;
|
$status = null;
|
||||||
|
|
|
||||||
|
|
@ -1236,10 +1236,8 @@ public function isReachableChanged()
|
||||||
$this->refresh();
|
$this->refresh();
|
||||||
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
|
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
|
||||||
$isReachable = (bool) $this->settings->is_reachable;
|
$isReachable = (bool) $this->settings->is_reachable;
|
||||||
if ($isReachable === true) {
|
|
||||||
$this->unreachable_count = 0;
|
|
||||||
$this->save();
|
|
||||||
|
|
||||||
|
if ($isReachable === true) {
|
||||||
if ($unreachableNotificationSent === true) {
|
if ($unreachableNotificationSent === true) {
|
||||||
$this->sendReachableNotification();
|
$this->sendReachableNotification();
|
||||||
}
|
}
|
||||||
|
|
@ -1247,28 +1245,8 @@ public function isReachableChanged()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->increment('unreachable_count');
|
|
||||||
|
|
||||||
if ($this->unreachable_count === 1) {
|
|
||||||
$this->settings->is_reachable = true;
|
|
||||||
$this->settings->save();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
|
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
|
||||||
$failedChecks = 0;
|
$this->sendUnreachableNotification();
|
||||||
for ($i = 0; $i < 3; $i++) {
|
|
||||||
$status = $this->serverStatus();
|
|
||||||
sleep(5);
|
|
||||||
if (! $status) {
|
|
||||||
$failedChecks++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($failedChecks === 3 && ! $unreachableNotificationSent) {
|
|
||||||
$this->sendUnreachableNotification();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
105
tests/Feature/ServerReachabilityNotificationTest.php
Normal file
105
tests/Feature/ServerReachabilityNotificationTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Events\ServerReachabilityChanged;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Notifications\Channels\EmailChannel;
|
||||||
|
use App\Notifications\Server\Reachable;
|
||||||
|
use App\Notifications\Server\Unreachable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$this->team->emailNotificationSettings()->update([
|
||||||
|
'use_instance_email_settings' => true,
|
||||||
|
'server_unreachable_email_notifications' => true,
|
||||||
|
'server_reachable_email_notifications' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
|
||||||
|
Notification::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends Unreachable notification when threshold reached and not yet notified', function () {
|
||||||
|
$this->server->settings()->update(['is_reachable' => false]);
|
||||||
|
$this->server->forceFill([
|
||||||
|
'unreachable_count' => 2,
|
||||||
|
'unreachable_notification_sent' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||||
|
|
||||||
|
Notification::assertSentTo($this->team, Unreachable::class);
|
||||||
|
expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send Unreachable on first transient failure (count=1)', function () {
|
||||||
|
$this->server->settings()->update(['is_reachable' => false]);
|
||||||
|
$this->server->forceFill([
|
||||||
|
'unreachable_count' => 1,
|
||||||
|
'unreachable_notification_sent' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send Unreachable when already notified', function () {
|
||||||
|
$this->server->settings()->update(['is_reachable' => false]);
|
||||||
|
$this->server->forceFill([
|
||||||
|
'unreachable_count' => 5,
|
||||||
|
'unreachable_notification_sent' => true,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends Reachable notification on recovery when previously notified', function () {
|
||||||
|
$this->server->settings()->update(['is_reachable' => true]);
|
||||||
|
$this->server->forceFill([
|
||||||
|
'unreachable_count' => 0,
|
||||||
|
'unreachable_notification_sent' => true,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$fresh = $this->server->fresh();
|
||||||
|
expect($fresh->unreachable_notification_sent)->toBeTrue();
|
||||||
|
expect((bool) $fresh->settings->is_reachable)->toBeTrue();
|
||||||
|
|
||||||
|
ServerReachabilityChanged::dispatch($fresh);
|
||||||
|
|
||||||
|
Notification::assertSentTo($this->team, Reachable::class);
|
||||||
|
expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send Reachable when never notified', function () {
|
||||||
|
$this->server->settings()->update(['is_reachable' => true]);
|
||||||
|
$this->server->forceFill([
|
||||||
|
'unreachable_count' => 0,
|
||||||
|
'unreachable_notification_sent' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes Unreachable notification through EmailChannel when email toggle is on', function () {
|
||||||
|
$this->server->settings()->update(['is_reachable' => false]);
|
||||||
|
$this->server->forceFill([
|
||||||
|
'unreachable_count' => 2,
|
||||||
|
'unreachable_notification_sent' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||||
|
|
||||||
|
Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) {
|
||||||
|
return in_array(EmailChannel::class, $channels);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
tests/Unit/IsReachableChangedTest.php
Normal file
61
tests/Unit/IsReachableChangedTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
Mockery::close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeServerForReachabilityTest(bool $isReachable, bool $notificationSent, int $unreachableCount): Server
|
||||||
|
{
|
||||||
|
$settings = Mockery::mock();
|
||||||
|
$settings->is_reachable = $isReachable;
|
||||||
|
|
||||||
|
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||||
|
$server->shouldReceive('refresh')->andReturnSelf();
|
||||||
|
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||||
|
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent);
|
||||||
|
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
|
||||||
|
|
||||||
|
return $server;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends Reachable notification when reachable and notification was previously sent', function () {
|
||||||
|
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0);
|
||||||
|
$server->shouldReceive('sendReachableNotification')->once();
|
||||||
|
$server->shouldNotReceive('sendUnreachableNotification');
|
||||||
|
|
||||||
|
$server->isReachableChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send any notification when reachable and notification was never sent', function () {
|
||||||
|
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0);
|
||||||
|
$server->shouldNotReceive('sendReachableNotification');
|
||||||
|
$server->shouldNotReceive('sendUnreachableNotification');
|
||||||
|
|
||||||
|
$server->isReachableChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends Unreachable notification when count >= 2 and not yet notified', function () {
|
||||||
|
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2);
|
||||||
|
$server->shouldReceive('sendUnreachableNotification')->once();
|
||||||
|
$server->shouldNotReceive('sendReachableNotification');
|
||||||
|
|
||||||
|
$server->isReachableChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send Unreachable notification on first transient failure (count=1)', function () {
|
||||||
|
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1);
|
||||||
|
$server->shouldNotReceive('sendUnreachableNotification');
|
||||||
|
$server->shouldNotReceive('sendReachableNotification');
|
||||||
|
|
||||||
|
$server->isReachableChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not double-send Unreachable when already notified', function () {
|
||||||
|
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5);
|
||||||
|
$server->shouldNotReceive('sendUnreachableNotification');
|
||||||
|
$server->shouldNotReceive('sendReachableNotification');
|
||||||
|
|
||||||
|
$server->isReachableChanged();
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Events\ServerReachabilityChanged;
|
||||||
use App\Jobs\ServerCheckJob;
|
use App\Jobs\ServerCheckJob;
|
||||||
use App\Jobs\ServerConnectionCheckJob;
|
use App\Jobs\ServerConnectionCheckJob;
|
||||||
use App\Jobs\ServerManagerJob;
|
use App\Jobs\ServerManagerJob;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Queue\TimeoutExceededException;
|
use Illuminate\Queue\TimeoutExceededException;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
uses(TestCase::class);
|
uses(TestCase::class);
|
||||||
|
|
@ -126,16 +128,21 @@
|
||||||
|
|
||||||
describe('ServerConnectionCheckJob unreachable_count', function () {
|
describe('ServerConnectionCheckJob unreachable_count', function () {
|
||||||
it('increments unreachable_count on timeout', function () {
|
it('increments unreachable_count on timeout', function () {
|
||||||
|
Event::fake([ServerReachabilityChanged::class]);
|
||||||
|
|
||||||
$settings = Mockery::mock();
|
$settings = Mockery::mock();
|
||||||
|
$settings->is_reachable = true;
|
||||||
$settings->shouldReceive('update')
|
$settings->shouldReceive('update')
|
||||||
->with(['is_reachable' => false, 'is_usable' => false])
|
->with(['is_reachable' => false, 'is_usable' => false])
|
||||||
->once();
|
->once();
|
||||||
|
|
||||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||||
|
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false);
|
||||||
$server->shouldReceive('increment')->with('unreachable_count')->once();
|
$server->shouldReceive('increment')->with('unreachable_count')->once();
|
||||||
$server->id = 1;
|
$server->id = 1;
|
||||||
$server->name = 'test-server';
|
$server->name = 'test-server';
|
||||||
|
$server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is
|
||||||
|
|
||||||
$job = new ServerConnectionCheckJob($server);
|
$job = new ServerConnectionCheckJob($server);
|
||||||
$job->failed(new TimeoutExceededException);
|
$job->failed(new TimeoutExceededException);
|
||||||
|
|
@ -152,6 +159,50 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () {
|
||||||
|
// ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that
|
||||||
|
// call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy
|
||||||
|
// through the event dispatcher (which trips Eloquent static method lookups on the proxy class).
|
||||||
|
$invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) {
|
||||||
|
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||||
|
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
|
||||||
|
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||||
|
if ($expectDispatch) {
|
||||||
|
$server->shouldReceive('isReachableChanged')->once()->andReturnNull();
|
||||||
|
} else {
|
||||||
|
$server->shouldNotReceive('isReachableChanged');
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = new ServerConnectionCheckJob($server);
|
||||||
|
$method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded');
|
||||||
|
$method->invoke($job, $wasReachable, $wasNotified, $isReachable);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('dispatches event when count crosses unreachable threshold', function () use ($invoke) {
|
||||||
|
$invoke(true, false, false, 2, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch on first transient failure (count=1)', function () use ($invoke) {
|
||||||
|
$invoke(true, false, false, 1, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch when already notified and still unreachable', function () use ($invoke) {
|
||||||
|
$invoke(false, true, false, 5, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches recovery event when previously unreachable', function () use ($invoke) {
|
||||||
|
$invoke(false, false, true, 0, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches recovery event when previously notified', function () use ($invoke) {
|
||||||
|
$invoke(true, true, true, 0, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch when consistently reachable and never notified', function () use ($invoke) {
|
||||||
|
$invoke(true, false, true, 0, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('ServerCheckJob unreachable_count', function () {
|
describe('ServerCheckJob unreachable_count', function () {
|
||||||
it('increments unreachable_count on timeout', function () {
|
it('increments unreachable_count on timeout', function () {
|
||||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue