diff --git a/services/backend/src/modules/messages/messages.routes.ts b/services/backend/src/modules/messages/messages.routes.ts index 6cdd5a7..06001a6 100644 --- a/services/backend/src/modules/messages/messages.routes.ts +++ b/services/backend/src/modules/messages/messages.routes.ts @@ -13,6 +13,15 @@ import { messagesService } from "./messages.service.js"; const logger = createChildLogger("messages.routes"); +/** + * Per-message in-flight guard for the reanalyze endpoint. + * Prevents concurrent spam-clicks from issuing duplicate UPDATE + recovery + * worker triggers for the same message. Released as soon as the DB write + * completes (or fails), which is fast enough that false-positive blocking + * is not a practical concern. + */ +const reanalyzeInFlight = new Set(); + export function createMessagesRouter(): Router { const router = express.Router(); @@ -62,11 +71,26 @@ export function createMessagesRouter(): Router { return; } - const pool = getPool(); - await pool.query( - `UPDATE messages SET ai_status = 'pending' WHERE id = $1`, - [id], - ); + // Idempotency guard: reject concurrent duplicate requests for the same ID. + if (reanalyzeInFlight.has(id)) { + res.status(409).json({ error: "REANALYZE_IN_PROGRESS", messageId: id }); + return; + } + + reanalyzeInFlight.add(id); + try { + const pool = getPool(); + await pool.query( + // Only revert to pending if the message is not currently being + // processed (pending) already — prevents write amplification when + // the recovery worker already picked it up between UI clicks. + `UPDATE messages SET ai_status = 'pending' + WHERE id = $1 AND ai_status != 'pending'`, + [id], + ); + } finally { + reanalyzeInFlight.delete(id); + } logger.debug({ id }, "Message marked for re-analysis"); res.status(200).json({ ok: true }); diff --git a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts index c2bb9a0..ce7e13f 100644 --- a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts @@ -800,6 +800,22 @@ async function processBatch( conversationKey, Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS, ); + + // FIX: Release the processing lock immediately so the cooldown timer + // (not the processing-timeout expiry) controls when this conversation + // is next eligible. Without this the lock would hold for the full + // AI_ANALYSIS_PROCESSING_TIMEOUT_MS before the recovery worker could + // pick the reverted-pending messages back up. + if (conversationProcessing.get(conversationKey) === processingStartedAt) { + conversationProcessing.delete(conversationKey); + } + + // FIX: Do NOT set shouldScheduleNext = true here. The reverted messages + // are now 'pending' again. scheduleConversationAnalysis would race with + // the recovery worker and schedule the same conversation twice — once + // immediately (via shouldScheduleNext) and once after the cooldown + // (via recovery worker). Let the cooldown gate the next attempt. + shouldScheduleNext = false; } if (apiFailedMessages.length === 0) {