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
+45 -40
View File
@@ -339,55 +339,60 @@ 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;
switch ($destination) {
case Dest::SLACK:
$departments = (array)$goal->departments->value();
$sentToDept = false;
if (count($departments) > 0) {
foreach ($departments as $deptId) {
if (!is_numeric($deptId)) { continue; }
$dept = (new departments_o())->select((int)$deptId);
if (!$dept->exists()) { continue; }
$webhook = (string)$dept->slack_webhook->value();
if (empty($webhook)) { continue; }
(new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria, $dept), $webhook);
$sentToDept = true;
$sent = false;
try {
switch ($destination) {
case Dest::SLACK:
$departments = (array)$goal->departments->value();
$sentToDept = false;
if (count($departments) > 0) {
foreach ($departments as $deptId) {
if (!is_numeric($deptId)) { continue; }
$dept = (new departments_o())->select((int)$deptId);
if (!$dept->exists()) { continue; }
$webhook = (string)$dept->slack_webhook->value();
if (empty($webhook)) { continue; }
(new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria, $dept), $webhook);
$sentToDept = true;
}
}
}
if (!$sentToDept) {
// Fallback to default webhook
(new Slack())->send_message($message);
}
break;
case Dest::EMAIL:
// No recipient context in goal for automated cron
warn('GoalsProgressAlertsCron: EMAIL destination requires explicit recipients; skipping goal #' . $goalId);
break;
case Dest::SMS:
// No recipient context in goal for automated cron
warn('GoalsProgressAlertsCron: SMS destination requires explicit recipients; skipping goal #' . $goalId);
break;
default:
// 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);
if (!$sentToDept) {
// Fallback to default webhook
(new Slack())->send_message($message);
}
$sent = true;
break;
case Dest::EMAIL:
// No recipient context in goal for automated cron
warn('GoalsProgressAlertsCron: EMAIL destination requires explicit recipients; skipping goal #' . $goalId);
break;
case Dest::SMS:
// No recipient context in goal for automated cron
warn('GoalsProgressAlertsCron: SMS destination requires explicit recipients; skipping goal #' . $goalId);
break;
default:
// Unsupported or NONE
warn('GoalsProgressAlertsCron: Unsupported destination for goal #' . $goalId);
}
} catch (Throwable $dispatchError) {
if (!$sent) {
redis->delete($redisKey);
}
throw $dispatchError;
}
// Track last progress for CHANGED