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();