From b5be9fe9e86826471984f5b15eb5ff4a0e1dd5c2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 26 May 2026 14:12:56 +0200 Subject: [PATCH] fix(sentinel): lock push dedupe decisions Guard Sentinel push hash checks and cache updates with a server-scoped atomic cache lock to prevent concurrent duplicate dispatches. --- .../Controllers/Api/SentinelController.php | 21 +++++++++++-------- .../Feature/SentinelPushDeduplicationTest.php | 10 +++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index 734353386..ca47e9f9d 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -117,19 +117,22 @@ private function shouldDispatchUpdate(Server $server, array $data): bool $hash = $this->containerStateHash($data); $hashKey = "sentinel:push-hash:{$server->id}"; $forceKey = "sentinel:push-force:{$server->id}"; + $lockKey = "sentinel:push-lock:{$server->id}"; - $cachedHash = Cache::get($hashKey); - $forceActive = Cache::has($forceKey); + return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool { + $cachedHash = Cache::get($hashKey); + $forceActive = Cache::has($forceKey); - $shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive; + $shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive; - if ($shouldDispatch) { - // Day-long TTL bounds memory if a server stops pushing entirely. - Cache::put($hashKey, $hash, now()->addDay()); - Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300)); - } + if ($shouldDispatch) { + // Day-long TTL bounds memory if a server stops pushing entirely. + Cache::put($hashKey, $hash, now()->addDay()); + Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300)); + } - return $shouldDispatch; + return $shouldDispatch; + }); } /** diff --git a/tests/Feature/SentinelPushDeduplicationTest.php b/tests/Feature/SentinelPushDeduplicationTest.php index c4a887da4..9d20851ed 100644 --- a/tests/Feature/SentinelPushDeduplicationTest.php +++ b/tests/Feature/SentinelPushDeduplicationTest.php @@ -90,6 +90,16 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra 'empty containers' => [['containers' => []]], ]); +it('guards the dedupe decision with a server scoped atomic cache lock', function () { + $controller = file_get_contents(app_path('Http/Controllers/Api/SentinelController.php')); + + expect($controller) + ->toContain('$lockKey = "sentinel:push-lock:{$server->id}";') + ->toContain('Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool') + ->toContain('Cache::put($hashKey, $hash, now()->addDay())') + ->toContain("Cache::put(\$forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300))"); +}); + it('dispatches the job when container state changes', function () use ($running) { pushSentinel($this->token, sentinelPayload($running()))->assertOk();