fix(moderation): patch 6 aiAnalyzer audit vulnerabilities

#1+#5 - Individual fallback circuit breaker
  - Add individualConsecutiveErrors + individualCooldownUntil (30s)
  - On success: reset counter; on failure: increment + trip at
    AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD (default 10) consecutive errors
  - enqueueIndividualFallbacks checks CB before admitting any work

#1 - Individual fallback concurrency cap
  - enqueueIndividualFallbacks enforces AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT
    (default 20); overflow stays as error/analysis_incomplete in DB and is
    recovered by the recovery worker on the next interval

#3 - Unhandled rejection in async setTimeout
  - scheduleConversationAnalysis no longer uses async arrow in setTimeout;
    all async work is chained with .then()/.catch() explicitly

#4 - Recovery worker ignores individualInFlight
  - Add individualInFlightByConversation Map<conversationKey, count>
  - processIndividualFallback increments/decrements it in try/finally
  - startPendingAIAnalysisWorker skips conversations present in the map
  - Recovery worker also processes error/analysis_incomplete messages via
    two new messageStore queries: getConversationKeysWithIncompleteAnalysis
    and getIncompleteMessagesByConversation

#6 - pickBatchWithinBudget never called
  - scheduleConversationAnalysis now calls pickBatchWithinBudget with
    AI_ANALYSIS_MAX_TARGET_TOKENS (default 4000) + 50-token per-msg overhead
    after fetching messages, before passing to processBatch

#7 - AI_PROCESSING_OVERLAP_MS 30s shorter than max LLM retry window
  - Replace hardcoded 30 000 ms constant with configurable
    AI_ANALYSIS_PROCESSING_TIMEOUT_MS (default 120 000 ms)
  - LLM client: 30s timeout × 3 retries + backoff ≈ 90-100s; 120s is safe

New config keys:
  AI_ANALYSIS_PROCESSING_TIMEOUT_MS   (default: 120000)
  AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT (default: 20)
  AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD  (default: 10)
  AI_ANALYSIS_MAX_TARGET_TOKENS        (default: 4000)

New AnalysisQueueStatus fields:
  activeIndividualRequests, individualInFlightCount,
  individualCircuitBreakerActive
This commit is contained in:
MythEclipse
2026-05-27 23:32:38 +07:00
parent 5925c11c54
commit 61045aabc8
4 changed files with 371 additions and 87 deletions
+29
View File
@@ -80,11 +80,40 @@ const configSchema = z
AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000), AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000),
AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(25), AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(25),
AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000), AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000),
/** Token budget for target messages specifically (separate from context window). */
AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(4000),
AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce
.number() .number()
.int() .int()
.positive() .positive()
.default(20), .default(20),
/**
* How long a conversation is considered locked while being processed.
* Must exceed (LLM timeout × max retries) + network overhead.
* LLM client timeout=30s, retries=3 → minimum safe value ≈ 100s.
*/
AI_ANALYSIS_PROCESSING_TIMEOUT_MS: z.coerce
.number()
.positive()
.default(120000),
/**
* Maximum number of concurrent individual-fallback LLM calls.
* Prevents OOM/connection exhaustion when many messages miss a batch.
*/
AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT: z.coerce
.number()
.int()
.positive()
.default(20),
/**
* How many consecutive individual-fallback errors trigger the individual
* circuit breaker (separate from the batch circuit breaker).
*/
AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD: z.coerce
.number()
.int()
.positive()
.default(10),
DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"), DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"),
DATABASE_URL: z.string().optional(), DATABASE_URL: z.string().optional(),
POSTGRES_HOST: z.string().default("localhost"), POSTGRES_HOST: z.string().default("localhost"),
+255 -87
View File
@@ -13,6 +13,8 @@ import { runModerationAnalysis } from "./llmModerationClient.js";
import { import {
getAttachmentsForMessages, getAttachmentsForMessages,
getConversationContextBefore, getConversationContextBefore,
getConversationKeysWithIncompleteAnalysis,
getIncompleteMessagesByConversation,
getMessageById, getMessageById,
getPendingConversationKeys, getPendingConversationKeys,
getPendingMessagesByConversation, getPendingMessagesByConversation,
@@ -34,19 +36,21 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
return (globalThis as ModerationGlobal).moderationBroadcaster; return (globalThis as ModerationGlobal).moderationBroadcaster;
} }
// Debounce state per conversation key // ---------------------------------------------------------------------------
const conversationDebounceTimers = new Map<string, NodeJS.Timeout>(); // Batch pipeline state
// Track conversations currently being processed // ---------------------------------------------------------------------------
const conversationProcessing = new Map<string, number>();
// Track conversations in error cooldown (failed recently)
const conversationErrorCooldown = new Map<string, number>();
const AI_PROCESSING_OVERLAP_MS = 30000; /** Debounce timer handle per conversation key. */
const conversationDebounceTimers = new Map<string, NodeJS.Timeout>();
/** Timestamp of when processing started per conversation key. */
const conversationProcessing = new Map<string, number>();
/** Cooldown expiry timestamp per conversation key after an error. */
const conversationErrorCooldown = new Map<string, number>();
let activeRequests = 0; let activeRequests = 0;
let lastError: string | null = null; let lastError: string | null = null;
// Global circuit breaker state // Batch circuit breaker
let consecutiveErrors = 0; let consecutiveErrors = 0;
const MAX_CONSECUTIVE_ERRORS = 5; const MAX_CONSECUTIVE_ERRORS = 5;
let globalCooldownUntil = 0; let globalCooldownUntil = 0;
@@ -54,18 +58,38 @@ let globalCooldownUntil = 0;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Individual fallback queue — runs PARALLEL to the batch pipeline. // Individual fallback queue — runs PARALLEL to the batch pipeline.
// //
// When a batch LLM call returns but some message IDs are absent from the // Design guarantees:
// response (analysis_incomplete), those IDs are enqueued here. Each message // • Concurrency is capped at config.AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT.
// is processed independently and concurrently: there is no serialisation // • A flat Set<messageId> de-duplicates so the same message can't be
// per-conversation, and a dedup Set prevents the same ID being in-flight twice. // in-flight twice (Discord snowflakes are globally unique, but be safe).
// • A Map<conversationKey, count> lets the recovery worker skip conversations
// that already have individual work in progress (#4 fix).
// • A separate circuit breaker prevents a cascade of individual failures
// from hammering a down/rate-limited LLM endpoint (#1+#5 fix).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** IDs currently being processed one-by-one (in-flight or waiting to start). */ /** IDs currently being processed one-by-one. */
const individualInFlight = new Set<string>(); const individualInFlight = new Set<string>();
/** Counter for observability (mirrors activeRequests but for individual path). */ /**
* Per-conversation count of in-flight individual messages.
* Used by the recovery worker to avoid re-scheduling a conversation that
* already has individual fallback work running for it.
*/
const individualInFlightByConversation = new Map<string, number>();
/** Counter for observability. */
let activeIndividualRequests = 0; let activeIndividualRequests = 0;
// Individual fallback circuit breaker (independent of batch CB)
let individualConsecutiveErrors = 0;
let individualCooldownUntil = 0;
const INDIVIDUAL_COOLDOWN_MS = 30000;
// ---------------------------------------------------------------------------
// Piscina worker pool (batch path only)
// ---------------------------------------------------------------------------
function getAnalysisWorkerUrl(): URL { function getAnalysisWorkerUrl(): URL {
const candidates = [ const candidates = [
new URL("./aiAnalysisWorker.js", import.meta.url), new URL("./aiAnalysisWorker.js", import.meta.url),
@@ -94,15 +118,20 @@ interface AnalysisWorkerResponse {
error?: string; error?: string;
} }
// ---------------------------------------------------------------------------
// Exported helpers
// ---------------------------------------------------------------------------
/** /**
* Gets the conversation key for a message (thread_id or channel_id) * Gets the conversation key for a message (thread_id or channel_id).
*/ */
export function getConversationKey(message: MessageRecord): string { export function getConversationKey(message: MessageRecord): string {
return message.thread_id || message.channel_id; return message.thread_id || message.channel_id;
} }
/** /**
* Picks a batch of messages within token budget * Picks a batch of messages within a token budget.
* `tokensPerMessage` accounts for JSON structure overhead around each entry.
*/ */
export function pickBatchWithinBudget( export function pickBatchWithinBudget(
messages: MessageRecord[], messages: MessageRecord[],
@@ -125,26 +154,44 @@ export function pickBatchWithinBudget(
return batch; return batch;
} }
// ---------------------------------------------------------------------------
// Conversation lock helpers
// ---------------------------------------------------------------------------
function isConversationProcessingLocked(conversationKey: string): boolean { function isConversationProcessingLocked(conversationKey: string): boolean {
const startedAt = conversationProcessing.get(conversationKey); const startedAt = conversationProcessing.get(conversationKey);
// FIX #7: use configurable timeout that exceeds (LLM timeout × max retries).
// Old hardcoded value was 30 000 ms — shorter than a single LLM call under retries.
return Boolean( return Boolean(
startedAt && Date.now() - startedAt < AI_PROCESSING_OVERLAP_MS, startedAt &&
Date.now() - startedAt < config.AI_ANALYSIS_PROCESSING_TIMEOUT_MS,
); );
} }
// ---------------------------------------------------------------------------
// Individual fallback pipeline
// ---------------------------------------------------------------------------
/** /**
* Processes a batch of messages for a conversation * Processes a single message directly in the main process (no IPC/worker
*/ * pool overhead). Never called from the batch path.
/** *
* Processes a single message through the LLM moderation pipeline directly * FIX #1+#5: Increments the individual circuit breaker on failure so a
* (no worker pool — avoids IPC overhead for a single-item call). Called from * sustained outage stops hammering the LLM endpoint.
* the individual fallback queue; never from the batch path.
*/ */
async function processIndividualFallback( async function processIndividualFallback(
message: MessageRecord, message: MessageRecord,
): Promise<void> { ): Promise<void> {
const { id: messageId } = message; const { id: messageId } = message;
const conversationKey = getConversationKey(message);
activeIndividualRequests++; activeIndividualRequests++;
// Increment per-conversation counter so the recovery worker can see it.
individualInFlightByConversation.set(
conversationKey,
(individualInFlightByConversation.get(conversationKey) ?? 0) + 1,
);
try { try {
const contextBefore = await getConversationContextBefore({ const contextBefore = await getConversationContextBefore({
channelId: message.channel_id, channelId: message.channel_id,
@@ -198,11 +245,29 @@ async function processIndividualFallback(
getModerationBroadcaster()?.messageAnalyzed(row); getModerationBroadcaster()?.messageAnalyzed(row);
} }
// Reset individual CB on success.
individualConsecutiveErrors = 0;
logger.info( logger.info(
{ messageId, status: analysisResult.results[0]?.status }, { messageId, status: analysisResult.results[0]?.status },
"Individual fallback analysis complete", "Individual fallback analysis complete",
); );
} catch (error) { } catch (error) {
// FIX #5: individual failures now feed their own circuit breaker.
individualConsecutiveErrors++;
if (
individualConsecutiveErrors >= config.AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD
) {
individualCooldownUntil = Date.now() + INDIVIDUAL_COOLDOWN_MS;
logger.warn(
{
threshold: config.AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD,
cooldownUntil: new Date(individualCooldownUntil).toISOString(),
},
"Individual fallback circuit breaker triggered",
);
}
lastError = error instanceof Error ? error.message : String(error); lastError = error instanceof Error ? error.message : String(error);
logger.error( logger.error(
{ {
@@ -215,48 +280,99 @@ async function processIndividualFallback(
} finally { } finally {
activeIndividualRequests--; activeIndividualRequests--;
individualInFlight.delete(messageId); individualInFlight.delete(messageId);
// Decrement per-conversation counter; remove key when it hits zero.
const prev = individualInFlightByConversation.get(conversationKey) ?? 1;
if (prev <= 1) {
individualInFlightByConversation.delete(conversationKey);
} else {
individualInFlightByConversation.set(conversationKey, prev - 1);
}
} }
} }
/** /**
* Fans out a list of message records to the individual fallback queue. * Fans out message records to the individual fallback queue.
* Each message starts processing concurrently (fire-and-forget per message). *
* De-duplicated by message ID so no double-processing even if called repeatedly. * FIX #1: Checks concurrency cap before admitting new work.
* FIX #5: Checks individual circuit breaker before admitting new work.
* Messages that cannot be admitted remain as `error/analysis_incomplete` in
* the DB and will be picked up by the recovery worker on the next interval.
*/ */
function enqueueIndividualFallbacks(messages: MessageRecord[]): void { function enqueueIndividualFallbacks(messages: MessageRecord[]): void {
// FIX #5: Honour the individual circuit breaker.
if (Date.now() < individualCooldownUntil) {
logger.warn(
{
until: new Date(individualCooldownUntil).toISOString(),
skipped: messages.length,
},
"Individual fallback circuit breaker active — messages will be recovered later",
);
return;
}
const newMessages = messages.filter((m) => !individualInFlight.has(m.id)); const newMessages = messages.filter((m) => !individualInFlight.has(m.id));
if (newMessages.length === 0) return; if (newMessages.length === 0) return;
// FIX #1: Enforce concurrency cap.
const availableSlots =
config.AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT - individualInFlight.size;
if (availableSlots <= 0) {
logger.warn(
{
cap: config.AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT,
inFlight: individualInFlight.size,
skipped: newMessages.length,
},
"Individual fallback concurrency cap reached — messages will be recovered by recovery worker",
);
return;
}
const toProcess = newMessages.slice(0, availableSlots);
const skipped = newMessages.length - toProcess.length;
logger.info( logger.info(
{ {
count: newMessages.length, count: toProcess.length,
messageIds: newMessages.map((m) => m.id), skipped,
messageIds: toProcess.map((m) => m.id),
}, },
"Enqueueing individual fallback analysis for batch-incomplete messages", "Enqueueing individual fallback analysis for batch-incomplete messages",
); );
for (const msg of newMessages) { for (const msg of toProcess) {
individualInFlight.add(msg.id); individualInFlight.add(msg.id);
// Fire-and-forget: each message runs concurrently, errors are handled inside. // Fire-and-forget: processIndividualFallback handles all errors internally.
processIndividualFallback(msg).catch((err) => { processIndividualFallback(msg).catch((err) => {
// Belt-and-suspenders: processIndividualFallback catches internally, // Belt-and-suspenders guard — should never reach here.
// but guard against any uncaught rejection bubbling here.
logger.error( logger.error(
{ messageId: msg.id, error: String(err) }, { messageId: msg.id, error: String(err) },
"Unexpected error in individual fallback promise", "Unexpected uncaught error escaping processIndividualFallback",
); );
individualInFlight.delete(msg.id); individualInFlight.delete(msg.id);
const ck = getConversationKey(msg);
const prev = individualInFlightByConversation.get(ck) ?? 1;
if (prev <= 1) {
individualInFlightByConversation.delete(ck);
} else {
individualInFlightByConversation.set(ck, prev - 1);
}
}); });
} }
} }
// ---------------------------------------------------------------------------
// Batch pipeline
// ---------------------------------------------------------------------------
async function processBatch( async function processBatch(
conversationKey: string, conversationKey: string,
messages: MessageRecord[], messages: MessageRecord[],
): Promise<void> { ): Promise<void> {
if (messages.length === 0) return; if (messages.length === 0) return;
if (Date.now() < globalCooldownUntil) { if (Date.now() < globalCooldownUntil) {
// Should not normally hit here due to checks in scheduleConversationAnalysis, but just in case
return; return;
} }
@@ -348,7 +464,7 @@ async function processBatch(
enqueueIndividualFallbacks(incompleteMessages); enqueueIndividualFallbacks(incompleteMessages);
} }
consecutiveErrors = 0; // Reset circuit breaker consecutiveErrors = 0; // Reset batch circuit breaker
conversationErrorCooldown.delete(conversationKey); conversationErrorCooldown.delete(conversationKey);
shouldScheduleNext = true; shouldScheduleNext = true;
} catch (error) { } catch (error) {
@@ -396,76 +512,96 @@ async function processBatch(
} }
} }
// ---------------------------------------------------------------------------
// Scheduling
// ---------------------------------------------------------------------------
/** /**
* Debounced analysis trigger for a conversation * Schedules a debounced analysis run for a conversation.
*
* FIX #3: The async work inside setTimeout is now wrapped in an explicit
* .catch() so DB errors don't produce unhandled promise rejections.
* FIX #6: Calls pickBatchWithinBudget after fetching messages so token budget
* is respected before handing the batch to the LLM.
*/ */
function scheduleConversationAnalysis(conversationKey: string): void { function scheduleConversationAnalysis(conversationKey: string): void {
// Skip if already processing
if (isConversationProcessingLocked(conversationKey)) { if (isConversationProcessingLocked(conversationKey)) {
return; return;
} }
// Check cooldowns
const convoCooldown = conversationErrorCooldown.get(conversationKey) || 0; const convoCooldown = conversationErrorCooldown.get(conversationKey) || 0;
const activeCooldown = Math.max(convoCooldown, globalCooldownUntil); const activeCooldown = Math.max(convoCooldown, globalCooldownUntil);
if (activeCooldown && Date.now() < activeCooldown) { if (activeCooldown && Date.now() < activeCooldown) {
// Instead of dropping, re-schedule for after cooldown if not already scheduled
if (!conversationDebounceTimers.has(conversationKey)) { if (!conversationDebounceTimers.has(conversationKey)) {
const remaining = activeCooldown - Date.now(); const remaining = activeCooldown - Date.now();
const timer = setTimeout(() => { const timer = setTimeout(() => {
conversationDebounceTimers.delete(conversationKey); conversationDebounceTimers.delete(conversationKey);
scheduleConversationAnalysis(conversationKey); scheduleConversationAnalysis(conversationKey);
}, remaining + 500); // 500ms buffer after cooldown }, remaining + 500);
conversationDebounceTimers.set(conversationKey, timer); conversationDebounceTimers.set(conversationKey, timer);
} }
return; return;
} }
// Clear existing timer
const existingTimer = conversationDebounceTimers.get(conversationKey); const existingTimer = conversationDebounceTimers.get(conversationKey);
if (existingTimer) { if (existingTimer) {
clearTimeout(existingTimer); clearTimeout(existingTimer);
} }
// Always use shorter debounce for immediate processing (no concurrency limit) const timer = setTimeout(() => {
const debounceTime = config.AI_ANALYSIS_DEBOUNCE_MS;
// Set new debounced timer
const timer = setTimeout(async () => {
conversationDebounceTimers.delete(conversationKey); conversationDebounceTimers.delete(conversationKey);
// Get pending messages for this conversation // FIX #3: explicit .catch() — no async arrow function to avoid unhandled rejection.
const messages = await getPendingMessagesByConversation( getPendingMessagesByConversation(
conversationKey, conversationKey,
config.AI_ANALYSIS_MAX_BATCH_SIZE, config.AI_ANALYSIS_MAX_BATCH_SIZE,
); )
.then((messages) => {
if (messages.length === 0) return;
if (messages.length > 0) { // FIX #6: trim to token budget before sending to LLM.
await processBatch(conversationKey, messages); // 50 tokens overhead accounts for JSON structure + id/username fields.
} const trimmed = pickBatchWithinBudget(
}, debounceTime); messages,
config.AI_ANALYSIS_MAX_TARGET_TOKENS,
50,
);
if (trimmed.length === 0) return;
return processBatch(conversationKey, trimmed);
})
.catch((err) => {
logger.error(
{
conversationKey,
error: err instanceof Error ? err.message : String(err),
},
"Failed to fetch or dispatch pending messages for scheduled analysis",
);
});
}, config.AI_ANALYSIS_DEBOUNCE_MS);
conversationDebounceTimers.set(conversationKey, timer); conversationDebounceTimers.set(conversationKey, timer);
} }
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** /**
* Queues a message for analysis (debounced by conversation) * Queues a message for analysis (debounced by conversation).
*/ */
export async function queueMessageAnalysis(messageId: string): Promise<void> { export async function queueMessageAnalysis(messageId: string): Promise<void> {
if (!config.AI_ANALYSIS_ENABLED) return; if (!config.AI_ANALYSIS_ENABLED) return;
try { try {
// Look up the message to get its conversation key
const message = await getMessageById(messageId); const message = await getMessageById(messageId);
if (!message) { if (!message) {
logger.warn({ messageId }, "Message not found for analysis queue"); logger.warn({ messageId }, "Message not found for analysis queue");
return; return;
} }
queueConversationAnalysis(getConversationKey(message));
// Schedule its conversation for analysis
const conversationKey = getConversationKey(message);
queueConversationAnalysis(conversationKey);
} catch (error) { } catch (error) {
logger.error( logger.error(
{ {
@@ -478,17 +614,15 @@ export async function queueMessageAnalysis(messageId: string): Promise<void> {
} }
/** /**
* Queues a conversation for analysis (debounced) * Queues a conversation for analysis (debounced).
*/ */
export function queueConversationAnalysis(conversationKey: string): void { export function queueConversationAnalysis(conversationKey: string): void {
if (!config.AI_ANALYSIS_ENABLED) return; if (!config.AI_ANALYSIS_ENABLED) return;
// Schedule debounced analysis
scheduleConversationAnalysis(conversationKey); scheduleConversationAnalysis(conversationKey);
} }
/** /**
* Gets current analysis queue status * Returns current status of both the batch and individual fallback queues.
*/ */
export function getAnalysisQueueStatus(): AnalysisQueueStatus { export function getAnalysisQueueStatus(): AnalysisQueueStatus {
return { return {
@@ -496,42 +630,76 @@ export function getAnalysisQueueStatus(): AnalysisQueueStatus {
activeRequests, activeRequests,
activeIndividualRequests, activeIndividualRequests,
individualInFlightCount: individualInFlight.size, individualInFlightCount: individualInFlight.size,
individualCircuitBreakerActive: Date.now() < individualCooldownUntil,
lastError, lastError,
}; };
} }
/** /**
* Starts the pending AI analysis recovery worker * Starts the periodic recovery worker.
*
* FIX #4: Now also recovers messages stuck in `error/analysis_incomplete`
* state (not just `pending`), and skips conversations that already have
* individual fallback work in progress to avoid DB last-write-wins races.
*/ */
export function startPendingAIAnalysisWorker(): void { export function startPendingAIAnalysisWorker(): void {
if (!config.AI_ANALYSIS_ENABLED) return; if (!config.AI_ANALYSIS_ENABLED) return;
setInterval(async () => { setInterval(() => {
try { // FIX #3 pattern: no async arrow — chain promises explicitly.
// Get pending conversation keys Promise.all([
const conversationKeys = await getPendingConversationKeys(100); getPendingConversationKeys(100),
getConversationKeysWithIncompleteAnalysis(50),
for (const key of conversationKeys) { ])
// Skip if already scheduled .then(([pendingKeys, incompleteKeys]) => {
if (conversationDebounceTimers.has(key)) { // --- Batch recovery for `pending` messages ---
continue; for (const key of pendingKeys) {
if (conversationDebounceTimers.has(key)) continue;
if (isConversationProcessingLocked(key)) continue;
// FIX #4: skip if individual fallback already running for this conversation.
if (individualInFlightByConversation.has(key)) continue;
const cooldownUntil = conversationErrorCooldown.get(key);
if (cooldownUntil && Date.now() < cooldownUntil) continue;
scheduleConversationAnalysis(key);
} }
// Skip if currently processing // --- Individual recovery for `error/analysis_incomplete` messages ---
if (isConversationProcessingLocked(key)) { // Circuit breaker check: no point iterating if individual CB is active.
continue; if (Date.now() >= individualCooldownUntil) {
} const promises: Promise<void>[] = [];
for (const key of incompleteKeys) {
// Skip if individual work is already running for this conversation.
if (individualInFlightByConversation.has(key)) continue;
// Skip if batch processing is running (it will fan-out if it finds more incomplete).
if (isConversationProcessingLocked(key)) continue;
// Skip if in error cooldown promises.push(
const cooldownUntil = conversationErrorCooldown.get(key); getIncompleteMessagesByConversation(
if (cooldownUntil && Date.now() < cooldownUntil) { key,
continue; config.AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT,
)
.then((msgs) => {
if (msgs.length > 0) {
enqueueIndividualFallbacks(msgs);
}
})
.catch((err) => {
logger.error(
{ key, error: String(err) },
"Failed to fetch incomplete messages for recovery",
);
}),
);
}
// Errors are handled per-key; return the combined promise for observability.
return Promise.all(promises);
} }
})
scheduleConversationAnalysis(key); .catch((err) => {
} logger.error(
} catch (error) { { error: err instanceof Error ? err.message : String(err) },
logger.error({ error }, "Pending AI analysis recovery worker failed"); "Pending AI analysis recovery worker failed",
} );
});
}, config.AI_ANALYSIS_RECOVERY_INTERVAL_MS); }, config.AI_ANALYSIS_RECOVERY_INTERVAL_MS);
} }
+85
View File
@@ -715,3 +715,88 @@ export async function searchMessages(input: {
throw error; throw error;
} }
} }
/**
* Returns distinct conversation keys (thread_id or channel_id) that have at
* least one message stuck in `error` status with the `analysis_incomplete`
* flag set. Used by the recovery worker to re-feed those messages through
* the individual-fallback queue.
*/
export async function getConversationKeysWithIncompleteAnalysis(
limit: number = 50,
): Promise<string[]> {
try {
const database = db();
const rows = await database
.selectDistinct<Array<{ thread_id: string | null; channel_id: string }>>({
thread_id: messagesTable.thread_id,
channel_id: messagesTable.channel_id,
})
.from(messagesTable)
.where(
and(
eq(messagesTable.ai_status, "error"),
sql`${messagesTable.ai_moderation_flags} LIKE ${"%analysis_incomplete%"}`,
isNull(messagesTable.deleted_at),
),
)
.limit(limit);
const keys: string[] = [];
for (const row of rows) {
const key = row.thread_id || row.channel_id;
if (key && !keys.includes(key)) {
keys.push(key);
}
}
return keys;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get conversation keys with incomplete analysis",
);
throw error;
}
}
/**
* Returns MessageRecords for a given conversation key whose AI analysis is
* stuck in `error` + `analysis_incomplete`. Used to feed those records
* directly into the individual-fallback queue without touching their status
* (the individual pipeline will overwrite status on success).
*/
export async function getIncompleteMessagesByConversation(
conversationKey: string,
limit: number = 20,
): Promise<MessageRecord[]> {
try {
const database = db();
const rows = await database
.select()
.from(messagesTable)
.where(
and(
or(
eq(messagesTable.thread_id, conversationKey),
eq(messagesTable.channel_id, conversationKey),
),
eq(messagesTable.ai_status, "error"),
sql`${messagesTable.ai_moderation_flags} LIKE ${"%analysis_incomplete%"}`,
isNull(messagesTable.deleted_at),
),
)
.orderBy(asc(messagesTable.created_at))
.limit(limit);
return rows as MessageRecord[];
} catch (error) {
logger.error(
{
conversationKey,
error: error instanceof Error ? error.message : String(error),
},
"Failed to get incomplete messages by conversation",
);
throw error;
}
}
+2
View File
@@ -141,5 +141,7 @@ export interface AnalysisQueueStatus {
activeIndividualRequests: number; activeIndividualRequests: number;
/** Number of message IDs sitting in the dedup set (in-flight or about to start). */ /** Number of message IDs sitting in the dedup set (in-flight or about to start). */
individualInFlightCount: number; individualInFlightCount: number;
/** True when the individual-fallback circuit breaker is tripped. */
individualCircuitBreakerActive: boolean;
lastError: string | null; lastError: string | null;
} }