Root cause (trace msg 1541417073245290638): - Race-guard upload-pending balik results:[] diperlakukan sbg SUKSES -> row yatam 'processing' sampai cleanup 300s mengembalikan - Vision gagal 3x utk GIF besar (SSE truncation) tanpa fallback Fix: - Sinyal eksplisit uploadPending dari worker race guard - Classifier murni classifyIndividualWorkerResult(): upload_pending -> requeue pending + reschedule segera (250ms), bukan error palsu; empty-results ok:true kini error transien (bug silent-success mati) - llmVision fallback stream:false sekali saat SSE truncation - Safety-net cleanup stuck processing 300s -> 120s
94 lines
3.1 KiB
TypeScript
94 lines
3.1 KiB
TypeScript
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
import { createChildLogger, type Logger } from "@/shared/logger/index";
|
|
import type * as schema from "../../shared/database/schema.js";
|
|
import { messagesTable } from "../../shared/database/schema.js";
|
|
import type { MessageRecord } from "../message-capture/types.js";
|
|
|
|
// ─── MessagesCleanup Class ────────────────────────────────────────────────────
|
|
|
|
export class MessagesCleanup {
|
|
private logger: Logger;
|
|
|
|
constructor(
|
|
private db: NodePgDatabase<typeof schema>,
|
|
_parentLogger?: Logger,
|
|
) {
|
|
this.logger = createChildLogger("messages-cleanup");
|
|
}
|
|
|
|
async getExpiredMessages(retentionDays: number): Promise<MessageRecord[]> {
|
|
if (retentionDays <= 0) return [];
|
|
this.logger.debug({ retentionDays }, "getExpiredMessages entry");
|
|
try {
|
|
const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
|
|
const rows = await this.db
|
|
.select()
|
|
.from(messagesTable)
|
|
.where(
|
|
and(
|
|
sql`${messagesTable.created_at} < ${cutoffTime}`,
|
|
isNull(messagesTable.deleted_at),
|
|
),
|
|
)
|
|
.limit(1000);
|
|
|
|
return rows as MessageRecord[];
|
|
} catch (error) {
|
|
this.logger.error(
|
|
{
|
|
retentionDays,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Failed to get expired messages",
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async revertStuckProcessingMessages(
|
|
// 2026-08-24: lowered from 300000 — this cleanup is the last-resort
|
|
// recoverer for rows stuck in `processing`. With the upload-pending
|
|
// race-guard now requeueing properly (fallbackResultClassifier), any row
|
|
// that still sits here for >2min is a genuine leak; reverting sooner
|
|
// bounds the worst-case delay without racing legitimate in-flight work
|
|
// (media batches can legitimately take ~60s+).
|
|
timeoutMs: number = 120000,
|
|
): Promise<number> {
|
|
this.logger.debug({ timeoutMs }, "revertStuckProcessingMessages entry");
|
|
try {
|
|
const cutoffTime = Date.now() - timeoutMs;
|
|
|
|
const rows = await this.db
|
|
.update(messagesTable)
|
|
.set({ ai_status: "pending", ai_analyzed_at: null })
|
|
.where(
|
|
and(
|
|
eq(messagesTable.ai_status, "processing"),
|
|
sql`${messagesTable.ai_analyzed_at} < ${cutoffTime}`,
|
|
),
|
|
)
|
|
.returning({ id: messagesTable.id });
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
this.logger.info(
|
|
{
|
|
count: rows.length,
|
|
messageIds: rows.map((r: { id: string }) => r.id),
|
|
},
|
|
"Reverted stuck processing messages back to pending",
|
|
);
|
|
}
|
|
|
|
return Array.isArray(rows) ? rows.length : 0;
|
|
} catch (error) {
|
|
this.logger.error(
|
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
"Failed to revert stuck processing messages",
|
|
);
|
|
return 0;
|
|
}
|
|
}
|
|
}
|