Add atomic reservation support in Redis for goal alert deduplication with expiration. Update Cron logic and add unit tests for validation.

This commit is contained in:
Jeppe Bundgaard
2026-03-17 15:23:02 +01:00
parent bd4a90cdaa
commit 979b0f8fac
3 changed files with 134 additions and 41 deletions
+15
View File
@@ -450,6 +450,21 @@ class redis implements redis_i
return 'temporary_cache_' . uniqid();
}
/**
* Atomically set a key with TTL only when it does not already exist.
*/
public function set_if_absent_with_expiration(string $key, string $value, int $seconds): bool
{
if (!self::is_connected()) {
self::connect();
}
$seconds = max(1, $seconds);
$result = $this->redis->set($key, $value, 'EX', $seconds, 'NX');
return $result === true || strtoupper((string)$result) === 'OK';
}
public function mget(array $array_map): array
{
// Get multiple keys from Redis
+14 -9
View File
@@ -339,17 +339,22 @@ function GoalsProgressAlertsCron(): void
if (!$dueInfo['due']) { continue; }
}
// Deduplicate per goal per slot via Redis
// Deduplicate per goal per slot via Redis using an atomic reservation.
// This prevents two concurrent cron runners from both sending the same alert.
$slotKey = $dueInfo['slot'];
$redisKey = 'goal_alert_sent:' . $goalId . ':' . $slotKey;
$already = redis->get($redisKey) ?? null;
if ($already) { continue; }
$ttl = max(1, (int)$dueInfo['ttl']);
if (!redis->set_if_absent_with_expiration($redisKey, '1', $ttl)) {
continue;
}
// Render message
$message = goals_progress_alert_renderer::render($criteria);
// Dispatch according to destination
$destination = $criteria->progress_alert_destination ?? Dest::SLACK;
$sent = false;
try {
switch ($destination) {
case Dest::SLACK:
$departments = (array)$goal->departments->value();
@@ -369,6 +374,7 @@ function GoalsProgressAlertsCron(): void
// Fallback to default webhook
(new Slack())->send_message($message);
}
$sent = true;
break;
case Dest::EMAIL:
// No recipient context in goal for automated cron
@@ -382,12 +388,11 @@ function GoalsProgressAlertsCron(): void
// Unsupported or NONE
warn('GoalsProgressAlertsCron: Unsupported destination for goal #' . $goalId);
}
// Mark slot as sent (expire in a reasonable window)
$ttl = $dueInfo['ttl'];
redis->set('' . $redisKey, 1);
if (method_exists(redis, 'expire')) {
redis->expire($redisKey, $ttl);
} catch (Throwable $dispatchError) {
if (!$sent) {
redis->delete($redisKey);
}
throw $dispatchError;
}
// Track last progress for CHANGED
@@ -0,0 +1,73 @@
<?php
app_require('classes/redis.php');
use classes\redis;
use Predis\Client as PredisClient;
class RedisAtomicReservationTestClient extends PredisClient
{
public array $calls = [];
public function __construct(private mixed $returnValue)
{
}
public function set(...$arguments): mixed
{
$this->calls[] = $arguments;
return $this->returnValue;
}
public function disconnect(): void
{
}
}
function redis_test_inject_client(redis $redis, PredisClient $client): void
{
$reflection = new ReflectionClass($redis);
$property = $reflection->getProperty('redis');
$property->setAccessible(true);
$property->setValue($redis, $client);
}
it('claims a slot atomically with nx and expiration', function (): void {
global $REDIS_CONFIG;
$REDIS_CONFIG = [
'host' => 'redis',
'database' => 0,
'password' => '',
];
$client = new RedisAtomicReservationTestClient('OK');
$redis = new redis();
redis_test_inject_client($redis, $client);
expect($redis->set_if_absent_with_expiration('goal_alert_sent:22:2026-03-17', '1', 86400))->toBeTrue();
expect($client->calls)->toBe([
['goal_alert_sent:22:2026-03-17', '1', 'EX', 86400, 'NX'],
]);
});
it('returns false when the slot is already claimed and clamps ttl to one second', function (): void {
global $REDIS_CONFIG;
$REDIS_CONFIG = [
'host' => 'redis',
'database' => 0,
'password' => '',
];
$client = new RedisAtomicReservationTestClient(null);
$redis = new redis();
redis_test_inject_client($redis, $client);
expect($redis->set_if_absent_with_expiration('goal_alert_sent:22:2026-03-17', '1', 0))->toBeFalse();
expect($client->calls)->toBe([
['goal_alert_sent:22:2026-03-17', '1', 'EX', 1, 'NX'],
]);
});