#!/usr/bin/env php getMessage()); exit(1); } // Process a bounded batch per run so one cron tick can't run indefinitely if // the queue has backed up. $batchLimit = 50; $stmt = $conn->prepare( "SELECT retry_id, payload, attempts, max_attempts FROM notification_retry_queue WHERE next_attempt_at <= NOW() AND attempts < max_attempts ORDER BY retry_id ASC LIMIT ?" ); $stmt->bind_param('i', $batchLimit); $stmt->execute(); $dueRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); $stmt->close(); if (empty($dueRows)) { logMessage('No notifications due for retry.'); exit(0); } $succeeded = 0; $failed = 0; $exhausted = 0; foreach ($dueRows as $row) { $payload = json_decode($row['payload'], true); if (!is_array($payload)) { // Corrupt row — can't retry something unparseable. Remove it rather // than retrying forever against a row that will never succeed. $del = $conn->prepare("DELETE FROM notification_retry_queue WHERE retry_id = ?"); $del->bind_param('i', $row['retry_id']); $del->execute(); $del->close(); logMessage("Discarded retry #{$row['retry_id']}: payload is not valid JSON"); continue; } $result = NotificationHelper::attemptDelivery($webhookUrl, $payload); if ($result['success']) { $del = $conn->prepare("DELETE FROM notification_retry_queue WHERE retry_id = ?"); $del->bind_param('i', $row['retry_id']); $del->execute(); $del->close(); $succeeded++; logMessage("Retry #{$row['retry_id']} succeeded (attempt " . ((int)$row['attempts'] + 1) . ')'); continue; } $newAttempts = (int)$row['attempts'] + 1; if ($newAttempts >= (int)$row['max_attempts']) { // Exhausted: leave the row (attempts is now == max_attempts, so the // WHERE clause above naturally excludes it from future runs) rather // than deleting it, so it stays visible for manual investigation. $upd = $conn->prepare( "UPDATE notification_retry_queue SET attempts = ?, last_error = ? WHERE retry_id = ?" ); $upd->bind_param('isi', $newAttempts, $result['error'], $row['retry_id']); $upd->execute(); $upd->close(); $exhausted++; logMessage("Retry #{$row['retry_id']} exhausted after {$newAttempts} attempts: {$result['error']}"); continue; } $delayMinutes = nextAttemptDelayMinutes($newAttempts); $upd = $conn->prepare( "UPDATE notification_retry_queue SET attempts = ?, last_error = ?, next_attempt_at = DATE_ADD(NOW(), INTERVAL ? MINUTE) WHERE retry_id = ?" ); $upd->bind_param('isii', $newAttempts, $result['error'], $delayMinutes, $row['retry_id']); $upd->execute(); $upd->close(); $failed++; logMessage("Retry #{$row['retry_id']} failed (attempt {$newAttempts}), next attempt in {$delayMinutes}m: {$result['error']}"); } logMessage("Done: {$succeeded} succeeded, {$failed} rescheduled, {$exhausted} exhausted (of " . count($dueRows) . ' processed)');