fix(gateway): separate text and media lanes in AI analysis queue (#86)
Image messages previously blocked the whole analysis pipeline:
- conversationProcessing was a single lock per conversation; processBatch
awaited BOTH text and media worker jobs before releasing it, so a fast
text verdict sat unused until the slow vision/media batch finished
- one global LLM semaphore (AI_LLM_MAX_CONCURRENT) was shared by text and
media, so a vision backlog could starve text inference
- recovery worker gated on conversationProcessing.size
Now the queue is split into independent text/media lanes:
- conversationProcessing maps key -> Partial<Record<lane, startedAt>>;
each lane holds its own lock and frees it the moment ITS worker job
resolves (ownership-guarded clear prevents stale timers clearing newer
slots)
- two LLM semaphores: AI_LLM_MAX_CONCURRENT (text, default 8) and
AI_LLM_MEDIA_MAX_CONCURRENT (media, default 4) via
withLlmConcurrency(fn, { lane })
- batchScheduler schedules per conversation+lane (timer keys
'<key>::<lane>'); splitMessagesByLane/laneOfMessage moved to pure
analysisLanes.ts (unit-testable without Piscina)
- ai-analysis-worker batch jobs carry a lane field; per-lane active
request gauges (active_text_requests / active_media_requests)
- added tests/analysisLaneLock.test.ts (7 tests: independent lane locks,
preserving other-lane lock, clear-all, ownership guard, lane split)
Docs: ARCHITECTURE.md + AGENTS.md concurrency model updated.
typecheck/lint/test(138)/build all green.
This commit is contained in:
1 parent
f9fecfc144
commit
e34dcd6bc6
15 files changed
+692
-205
No files matched your search
@@ -2,6 +2,7 @@ import { createChildLogger } from "@/shared/logger/index";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
import type { MessageRecord } from "../message-capture/types.js";
|
||||
import { type AnalysisLane, splitMessagesByLane } from "./analysisLanes.js";
|
||||
import {
|
||||
pickBatchWithinBudget,
|
||||
processBatch,
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
skipAnalysisUserMessages,
|
||||
} from "./batchProcessor.js";
|
||||
import {
|
||||
clearConversationProcessing,
|
||||
conversationConsecutiveErrors,
|
||||
conversationDebounceTimers,
|
||||
conversationErrorCooldown,
|
||||
conversationProcessing,
|
||||
getConversationProcessingStartedAt,
|
||||
isConversationProcessingLocked,
|
||||
MAX_CONSECUTIVE_ERRORS,
|
||||
setConversationProcessing,
|
||||
} from "./conversationState.js";
|
||||
|
||||
const logger = createChildLogger("batch-scheduler");
|
||||
@@ -23,18 +26,35 @@ const logger = createChildLogger("batch-scheduler");
|
||||
// Scheduling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Timer key namespaced by lane so one conversation can hold a text timer AND
|
||||
* a media timer independently.
|
||||
*/
|
||||
function timerKey(conversationKey: string, lane: AnalysisLane): string {
|
||||
return `${conversationKey}::${lane}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a debounced analysis run for a conversation.
|
||||
*
|
||||
* `lane` optional:
|
||||
* - With a lane: takes that lane's processing lock; if the SAME lane is
|
||||
* already processing, skip. The OTHER lane's lock does not block this one —
|
||||
* text and media of one conversation never block each other.
|
||||
* - Without a lane (recovery worker / whole-conversation): takes BOTH lane
|
||||
* locks (each lock independently) and dispatches both lanes concurrently.
|
||||
* Each lane releases its own lock when its worker job finishes.
|
||||
*
|
||||
* The async work inside setTimeout is wrapped in an explicit .catch() so
|
||||
* DB errors don't produce unhandled promise rejections. Uses a unified
|
||||
* single-timer path: always clear-and-reset one timer per conversation key
|
||||
* single-timer path: always clear-and-reset one timer per conversation+lane
|
||||
* regardless of whether a cooldown is active.
|
||||
*/
|
||||
export function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
if (isConversationProcessingLocked(conversationKey)) {
|
||||
return;
|
||||
}
|
||||
export function scheduleConversationAnalysis(
|
||||
conversationKey: string,
|
||||
lane?: AnalysisLane,
|
||||
): void {
|
||||
const lanesToSchedule: AnalysisLane[] = lane ? [lane] : ["text", "media"];
|
||||
|
||||
const convoCooldown = conversationErrorCooldown.get(conversationKey) ?? 0;
|
||||
const convoErrors = conversationConsecutiveErrors.get(conversationKey) ?? 0;
|
||||
@@ -45,27 +65,41 @@ export function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
}
|
||||
|
||||
// Unified delay: honour the cooldown window if active, otherwise use the
|
||||
// normal debounce interval. Always clear-and-reset so only ONE timer is
|
||||
// ever pending per conversation key regardless of call source.
|
||||
// normal debounce interval. Always clear-and-reset so only ONE timer is
|
||||
// ever pending per conversation+lane regardless of call source.
|
||||
const now = Date.now();
|
||||
const delayMs =
|
||||
convoCooldown > now
|
||||
? convoCooldown - now + 500
|
||||
: config.AI_ANALYSIS_DEBOUNCE_MS;
|
||||
|
||||
const existingTimer = conversationDebounceTimers.get(conversationKey);
|
||||
for (const targetLane of lanesToSchedule) {
|
||||
if (isConversationProcessingLocked(conversationKey, targetLane)) {
|
||||
continue;
|
||||
}
|
||||
scheduleLaneTimer(conversationKey, targetLane, delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLaneTimer(
|
||||
conversationKey: string,
|
||||
lane: AnalysisLane,
|
||||
delayMs: number,
|
||||
): void {
|
||||
const tKey = timerKey(conversationKey, lane);
|
||||
const existingTimer = conversationDebounceTimers.get(tKey);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
conversationDebounceTimers.delete(conversationKey);
|
||||
conversationDebounceTimers.delete(tKey);
|
||||
|
||||
if (isConversationProcessingLocked(conversationKey)) {
|
||||
if (isConversationProcessingLocked(conversationKey, lane)) {
|
||||
return;
|
||||
}
|
||||
const processingStartedAt = Date.now();
|
||||
conversationProcessing.set(conversationKey, processingStartedAt);
|
||||
setConversationProcessing(conversationKey, lane, processingStartedAt);
|
||||
|
||||
messageStore
|
||||
.getPendingMessagesByConversation(
|
||||
@@ -73,24 +107,22 @@ export function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
config.AI_ANALYSIS_MAX_BATCH_SIZE,
|
||||
)
|
||||
.then(async (messages: MessageRecord[]) => {
|
||||
if (messages.length === 0) {
|
||||
if (
|
||||
conversationProcessing.get(conversationKey) === processingStartedAt
|
||||
) {
|
||||
conversationProcessing.delete(conversationKey);
|
||||
}
|
||||
// Filter to THIS lane only. The DB fetch is lane-agnostic (a
|
||||
// conversation key can have both text and media pending); each lane
|
||||
// picks its own subset so text and media never share a worker job.
|
||||
const { [lane]: laneMessages } = splitMessagesByLane(messages);
|
||||
if (laneMessages.length === 0) {
|
||||
// No work for this lane — the other lane (if scheduled) owns the
|
||||
// rest. Clear this lane's lock so the debounce can re-arm.
|
||||
releaseLaneSlot(conversationKey, lane, processingStartedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
const processableMessages = await skipAnalysisUserMessages(
|
||||
await skipAgeRestrictedMessages(messages),
|
||||
await skipAgeRestrictedMessages(laneMessages),
|
||||
);
|
||||
if (processableMessages.length === 0) {
|
||||
if (
|
||||
conversationProcessing.get(conversationKey) === processingStartedAt
|
||||
) {
|
||||
conversationProcessing.delete(conversationKey);
|
||||
}
|
||||
releaseLaneSlot(conversationKey, lane, processingStartedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,6 +139,7 @@ export function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
logger.warn(
|
||||
{
|
||||
conversationKey,
|
||||
lane,
|
||||
messageId: processableMessages[0]?.id,
|
||||
tokenBudget: config.AI_ANALYSIS_MAX_TARGET_TOKENS,
|
||||
},
|
||||
@@ -114,17 +147,22 @@ export function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
return processBatch(conversationKey, trimmed, processingStartedAt);
|
||||
// processBatch releases THIS lane's lock the moment its worker job
|
||||
// finishes and re-schedules the same lane — independent of the other
|
||||
// lane's (possibly much slower) media batch.
|
||||
return processBatch(
|
||||
conversationKey,
|
||||
lane,
|
||||
trimmed,
|
||||
processingStartedAt,
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (
|
||||
conversationProcessing.get(conversationKey) === processingStartedAt
|
||||
) {
|
||||
conversationProcessing.delete(conversationKey);
|
||||
}
|
||||
releaseLaneSlot(conversationKey, lane, processingStartedAt);
|
||||
logger.error(
|
||||
{
|
||||
conversationKey,
|
||||
lane,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"Failed to fetch or dispatch pending messages for scheduled analysis",
|
||||
@@ -132,5 +170,23 @@ export function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
});
|
||||
}, delayMs);
|
||||
|
||||
conversationDebounceTimers.set(conversationKey, timer);
|
||||
conversationDebounceTimers.set(tKey, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the processing lock for a lane, but ONLY if this timer still owns it
|
||||
* (processingStartedAt matches). Guards against clearing a newer slot that was
|
||||
* taken after this timer's window expired.
|
||||
*/
|
||||
function releaseLaneSlot(
|
||||
conversationKey: string,
|
||||
lane: AnalysisLane,
|
||||
processingStartedAt: number,
|
||||
): void {
|
||||
if (
|
||||
getConversationProcessingStartedAt(conversationKey, lane) ===
|
||||
processingStartedAt
|
||||
) {
|
||||
clearConversationProcessing(conversationKey, lane);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user