fix(ai-moderation): matikan hot requeue loop saat upload attachment in-flight
Batch race guard balikin {ok:true, rows:[]} tanpa sinyal saat semua target
masih upload-pending -> processor klasifikasi semua incomplete -> fanout ke
individual queue -> di situ requeue + reschedule 250ms -> balik ke batch:
hot loop ~300ms sepanjang upload (10 siklus/3 dtk di log prod 08:13).
Fix: worker batch kini return uploadPendingIds eksplisit; classifier pure
baru (partitionBatchOutcome) partisi completed/upload_pending/incomplete/
parse_failed/api_failed; target upload-pending DEFERRED dengan poll backoff
linear (AI_ANALYSIS_UPLOAD_POLL_MS 1500 base, cap AI_ANALYSIS_MAX_UPLOAD_POLL_MS
8000), tidak pernah masuk fanout; tail shouldScheduleNext tak menimpa defer.
Test: tests/batchOutcomeClassifier.test.ts (8 kasus, pure tanpa DB/Piscina).
This commit is contained in:
@@ -93,6 +93,12 @@ type BatchOkResponse = {
|
|||||||
ok: true;
|
ok: true;
|
||||||
conversationKey: string;
|
conversationKey: string;
|
||||||
rows: MessageRecord[];
|
rows: MessageRecord[];
|
||||||
|
/**
|
||||||
|
* Race-guard signal (2026-08-25): target ids whose attachment upload is
|
||||||
|
* still in-flight — NO analysis ran for them. The processor must defer
|
||||||
|
* these (requeue + poll), never fan them out as failures.
|
||||||
|
*/
|
||||||
|
uploadPendingIds?: string[];
|
||||||
};
|
};
|
||||||
type BatchErrorResponse = {
|
type BatchErrorResponse = {
|
||||||
ok: false;
|
ok: false;
|
||||||
@@ -319,7 +325,16 @@ async function processBatch(job: {
|
|||||||
? messages
|
? messages
|
||||||
: messages.filter((m) => !pendingUploadTargetIds.has(m.id));
|
: messages.filter((m) => !pendingUploadTargetIds.has(m.id));
|
||||||
if (readyMessages.length === 0) {
|
if (readyMessages.length === 0) {
|
||||||
return { ok: true, conversationKey, rows: [] };
|
// Explicit signal (2026-08-25): every target is still upload-pending.
|
||||||
|
// Returning bare {ok:true, rows:[]} made the processor classify all of
|
||||||
|
// them "incomplete" and fan out to the individual queue — a hot ~300ms
|
||||||
|
// requeue loop for the whole upload duration.
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
conversationKey,
|
||||||
|
rows: [],
|
||||||
|
uploadPendingIds: messages.map((m) => m.id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The orchestrator handles text/media split + caching + parallel paths
|
// The orchestrator handles text/media split + caching + parallel paths
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* batchOutcomeClassifier.ts
|
||||||
|
*
|
||||||
|
* Pure partitioner of the batch worker response (2026-08-25).
|
||||||
|
*
|
||||||
|
* Bug history: the batch race guard returned `{ok:true, rows:[]}` when every
|
||||||
|
* target's attachment upload was still in-flight. The processor classified all
|
||||||
|
* of them as "incomplete" and fanned out to the individual queue, where the
|
||||||
|
* guard there requeued + rescheduled at the 250ms debounce — a hot ~300ms loop
|
||||||
|
* for the entire upload duration (~10 cycles in 3s in prod logs). Root fix:
|
||||||
|
* the worker now reports `uploadPendingIds` explicitly and this pure function
|
||||||
|
* partitions the outcome so upload-pending targets NEVER enter the fanout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BatchRowLike {
|
||||||
|
id?: string;
|
||||||
|
ai_status?: string | null;
|
||||||
|
ai_moderation_flags?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchWorkerResponseLike {
|
||||||
|
ok?: boolean;
|
||||||
|
rows?: BatchRowLike[];
|
||||||
|
/** Explicit race-guard signal from the worker (2026-08-25). */
|
||||||
|
uploadPendingIds?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One target's per-message disposition after a batch attempt. */
|
||||||
|
export type BatchTargetKind =
|
||||||
|
| "completed"
|
||||||
|
| "upload_pending"
|
||||||
|
| "incomplete"
|
||||||
|
| "parse_failed"
|
||||||
|
| "api_failed";
|
||||||
|
|
||||||
|
function flagsOf(row: { ai_moderation_flags?: string | null }): string[] {
|
||||||
|
if (!row.ai_moderation_flags) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.ai_moderation_flags) as unknown;
|
||||||
|
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partition the input message ids into per-message dispositions for one batch
|
||||||
|
* worker response. Pure: no DB/Piscina/logger — unit-testable directly.
|
||||||
|
*
|
||||||
|
* Priority per id: explicit uploadPendingIds → completed row → flag-based
|
||||||
|
* failure kinds → unexplained missing (treated like incomplete).
|
||||||
|
*/
|
||||||
|
export function partitionBatchOutcome(
|
||||||
|
messages: ReadonlyArray<{ id: string }>,
|
||||||
|
response: BatchWorkerResponseLike,
|
||||||
|
): Map<string, BatchTargetKind> {
|
||||||
|
const pendingSet = new Set(response.uploadPendingIds ?? []);
|
||||||
|
const rowsById = new Map(
|
||||||
|
(response.rows ?? [])
|
||||||
|
.filter((r): r is BatchRowLike & { id: string } => Boolean(r?.id))
|
||||||
|
.map((r) => [r.id, r]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const out = new Map<string, BatchTargetKind>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (pendingSet.has(msg.id)) {
|
||||||
|
out.set(msg.id, "upload_pending");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const row = rowsById.get(msg.id);
|
||||||
|
if (!row) {
|
||||||
|
// Unexplained drop: LLM silently omitted it. Same retryable bucket as
|
||||||
|
// analysis_incomplete — never a silent success.
|
||||||
|
out.set(msg.id, "incomplete");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.ai_status !== "error") {
|
||||||
|
out.set(msg.id, "completed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const flags = flagsOf(row);
|
||||||
|
if (flags.includes("analysis_incomplete")) {
|
||||||
|
out.set(msg.id, "incomplete");
|
||||||
|
} else if (flags.includes("analysis_parse_failed")) {
|
||||||
|
out.set(msg.id, "parse_failed");
|
||||||
|
} else if (flags.includes("analysis_api_failed")) {
|
||||||
|
out.set(msg.id, "api_failed");
|
||||||
|
} else {
|
||||||
|
out.set(msg.id, "incomplete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linear backoff ramp for consecutive upload-pending polls:
|
||||||
|
* poll N (1-based) waits min(base × N, cap). Keeps latency low for fast
|
||||||
|
* uploads while bounding total polling cost for long uploads.
|
||||||
|
*/
|
||||||
|
export function computeUploadPollDelayMs(
|
||||||
|
consecutivePolls: number,
|
||||||
|
baseMs: number,
|
||||||
|
capMs: number,
|
||||||
|
): number {
|
||||||
|
const n = Math.max(1, Math.floor(consecutivePolls));
|
||||||
|
return Math.min(Math.round(baseMs * n), Math.round(capMs));
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ import { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js";
|
|||||||
import { messageStore } from "../message-capture/messageStore.js";
|
import { messageStore } from "../message-capture/messageStore.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.js";
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
import { pickBatchWithinBudget as pickBatchWithinBudgetPure } from "./batchBudget.js";
|
import { pickBatchWithinBudget as pickBatchWithinBudgetPure } from "./batchBudget.js";
|
||||||
|
import {
|
||||||
|
computeUploadPollDelayMs,
|
||||||
|
partitionBatchOutcome,
|
||||||
|
} from "./batchOutcomeClassifier.js";
|
||||||
import { workerPool } from "./circuitBreaker.js";
|
import { workerPool } from "./circuitBreaker.js";
|
||||||
import { estimateTokens } from "./conversationContext.js";
|
import { estimateTokens } from "./conversationContext.js";
|
||||||
import {
|
import {
|
||||||
@@ -21,11 +25,20 @@ import {
|
|||||||
|
|
||||||
const logger = createChildLogger("batch-processor");
|
const logger = createChildLogger("batch-processor");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consecutive upload-pending poll counter per conversation (2026-08-25).
|
||||||
|
* Drives the linear backoff ramp while attachments are still uploading;
|
||||||
|
* cleared as soon as a batch comes back with no upload-pending targets.
|
||||||
|
*/
|
||||||
|
const conversationUploadPolls = new Map<string, number>();
|
||||||
|
|
||||||
export interface AnalysisWorkerResponse {
|
export interface AnalysisWorkerResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
conversationKey: string;
|
conversationKey: string;
|
||||||
rows: MessageRecord[];
|
rows: MessageRecord[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** Explicit upload-in-flight signal from the batch race guard (2026-08-25). */
|
||||||
|
uploadPendingIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -141,6 +154,8 @@ export async function processBatch(
|
|||||||
|
|
||||||
activeRequests++;
|
activeRequests++;
|
||||||
let shouldScheduleNext = false;
|
let shouldScheduleNext = false;
|
||||||
|
/** Set when upload-pending targets defer the next cycle by this many ms. */
|
||||||
|
let deferredUploadRescheduleMs: number | null = null;
|
||||||
try {
|
try {
|
||||||
const result = (await workerPool.run({
|
const result = (await workerPool.run({
|
||||||
type: "batch",
|
type: "batch",
|
||||||
@@ -199,37 +214,85 @@ export async function processBatch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch succeeded -- check for messages the LLM silently dropped or failed
|
// Batch succeeded -- partition per-message outcome explicitly (2026-08-25).
|
||||||
const incompleteMessages: MessageRecord[] = [];
|
// upload_pending targets are DEFERRED (never fanned out): the old code
|
||||||
const parseFailedMessages: MessageRecord[] = [];
|
// treated them as incomplete -> individual queue -> requeue+250ms
|
||||||
|
// reschedule -> hot ~300ms loop for the whole upload duration.
|
||||||
|
const outcomeById = partitionBatchOutcome(messages, result);
|
||||||
|
const messagesForIndividualQueue: MessageRecord[] = [];
|
||||||
const apiFailedMessages: MessageRecord[] = [];
|
const apiFailedMessages: MessageRecord[] = [];
|
||||||
|
const uploadPendingMessages: MessageRecord[] = [];
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const row = result.rows.find((r) => r.id === msg.id);
|
switch (outcomeById.get(msg.id)) {
|
||||||
if (!row) {
|
case "upload_pending":
|
||||||
incompleteMessages.push(msg);
|
uploadPendingMessages.push(msg);
|
||||||
continue;
|
break;
|
||||||
}
|
case "api_failed":
|
||||||
if (row.ai_status === "error") {
|
// Preserve the dedicated api-failure semantics below: revert +
|
||||||
let flags: string[] = [];
|
// conversation cooldown instead of an immediate individual retry.
|
||||||
try {
|
|
||||||
flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (flags.includes("analysis_incomplete")) {
|
|
||||||
incompleteMessages.push(msg);
|
|
||||||
} else if (flags.includes("analysis_parse_failed")) {
|
|
||||||
parseFailedMessages.push(msg);
|
|
||||||
} else if (flags.includes("analysis_api_failed")) {
|
|
||||||
apiFailedMessages.push(msg);
|
apiFailedMessages.push(msg);
|
||||||
}
|
break;
|
||||||
|
default:
|
||||||
|
// incomplete / parse_failed / unexplained drops stay retryable via
|
||||||
|
// the individual fallback queue (same semantics as before).
|
||||||
|
messagesForIndividualQueue.push(msg);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagesForIndividualQueue = [
|
if (uploadPendingMessages.length > 0) {
|
||||||
...incompleteMessages,
|
const polls = (conversationUploadPolls.get(conversationKey) ?? 0) + 1;
|
||||||
...parseFailedMessages,
|
conversationUploadPolls.set(conversationKey, polls);
|
||||||
];
|
const delayMs = computeUploadPollDelayMs(
|
||||||
|
polls,
|
||||||
|
config.AI_ANALYSIS_UPLOAD_POLL_MS,
|
||||||
|
config.AI_ANALYSIS_MAX_UPLOAD_POLL_MS,
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
conversationKey,
|
||||||
|
count: uploadPendingMessages.length,
|
||||||
|
ids: uploadPendingMessages.map((m) => m.id),
|
||||||
|
pollAttempt: polls,
|
||||||
|
delayMs,
|
||||||
|
},
|
||||||
|
"Attachment upload in-flight for batch targets — deferring with poll backoff",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Put the rows back to `pending` so the scheduler owns them again.
|
||||||
|
await messageStore
|
||||||
|
.updateMessagesAIAnalysisBulk(
|
||||||
|
uploadPendingMessages.map((msg) => ({
|
||||||
|
messageId: msg.id,
|
||||||
|
result: {
|
||||||
|
status: "pending",
|
||||||
|
flags: null,
|
||||||
|
score: null,
|
||||||
|
analysis: null,
|
||||||
|
categories: null,
|
||||||
|
severity: null,
|
||||||
|
confidence: null,
|
||||||
|
recommendedAction: null,
|
||||||
|
analyzedAt: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
logger.error(
|
||||||
|
{ error: String(err), ids: uploadPendingMessages.map((m) => m.id) },
|
||||||
|
"Failed to revert upload-pending batch targets to pending",
|
||||||
|
);
|
||||||
|
return [] as MessageRecord[];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll backoff instead of the 250ms debounce: the finally-block
|
||||||
|
// schedules the next cycle after this delay instead of immediately.
|
||||||
|
deferredUploadRescheduleMs = delayMs;
|
||||||
|
} else {
|
||||||
|
conversationUploadPolls.delete(conversationKey);
|
||||||
|
}
|
||||||
|
|
||||||
if (messagesForIndividualQueue.length > 0) {
|
if (messagesForIndividualQueue.length > 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -307,7 +370,11 @@ export async function processBatch(
|
|||||||
resetConversationBatchFailures(conversationKey);
|
resetConversationBatchFailures(conversationKey);
|
||||||
conversationErrorCooldown.delete(conversationKey);
|
conversationErrorCooldown.delete(conversationKey);
|
||||||
}
|
}
|
||||||
|
// Upload-pending defer owns the next-cycle timing; don't let the default
|
||||||
|
// immediate schedule override it.
|
||||||
|
if (deferredUploadRescheduleMs === null) {
|
||||||
shouldScheduleNext = true;
|
shouldScheduleNext = true;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
recordConversationBatchFailure(conversationKey);
|
recordConversationBatchFailure(conversationKey);
|
||||||
|
|
||||||
@@ -344,7 +411,17 @@ export async function processBatch(
|
|||||||
if (conversationProcessing.get(conversationKey) === processingStartedAt) {
|
if (conversationProcessing.get(conversationKey) === processingStartedAt) {
|
||||||
conversationProcessing.delete(conversationKey);
|
conversationProcessing.delete(conversationKey);
|
||||||
}
|
}
|
||||||
if (shouldScheduleNext) {
|
if (deferredUploadRescheduleMs !== null) {
|
||||||
|
// Upload still in-flight: re-schedule after the backoff delay instead of
|
||||||
|
// immediately (the old path hot-looped at ~250-300ms per cycle).
|
||||||
|
const delayMs = deferredUploadRescheduleMs;
|
||||||
|
setTimeout(() => {
|
||||||
|
// Dynamic import to avoid circular dependency at module scope
|
||||||
|
import("./batchScheduler.js").then((m) =>
|
||||||
|
m.scheduleConversationAnalysis(conversationKey),
|
||||||
|
);
|
||||||
|
}, delayMs).unref();
|
||||||
|
} else if (shouldScheduleNext) {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
// Dynamic import to avoid circular dependency at module scope
|
// Dynamic import to avoid circular dependency at module scope
|
||||||
import("./batchScheduler.js").then((m) =>
|
import("./batchScheduler.js").then((m) =>
|
||||||
|
|||||||
@@ -254,6 +254,12 @@ export const configSchema = z
|
|||||||
.positive()
|
.positive()
|
||||||
.default(10000),
|
.default(10000),
|
||||||
AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
||||||
|
// Upload-pending batch poll (2026-08-25): when a batch is deferred because
|
||||||
|
// attachments are still uploading, the processor re-schedules with this
|
||||||
|
// base delay (linear ramp per consecutive poll, capped) instead of the
|
||||||
|
// 250ms debounce — the old path hot-looped ~300ms for the whole upload.
|
||||||
|
AI_ANALYSIS_UPLOAD_POLL_MS: z.coerce.number().positive().default(1500),
|
||||||
|
AI_ANALYSIS_MAX_UPLOAD_POLL_MS: z.coerce.number().positive().default(8000),
|
||||||
|
|
||||||
// ── AI Analysis Batch ───────────────────────────────────────────────
|
// ── AI Analysis Batch ───────────────────────────────────────────────
|
||||||
AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200),
|
AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200),
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// partitionBatchOutcome — upload-pending defer vs fanout (2026-08-25)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Bug history: the batch worker's race guard returned {ok:true, rows:[]} while
|
||||||
|
// attachments were still uploading; every target was classified "incomplete",
|
||||||
|
// fanned out to the individual queue, requeued there, rescheduled at 250ms —
|
||||||
|
// a hot ~300ms loop for the whole upload duration (~10 cycles in 3s in prod).
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
computeUploadPollDelayMs,
|
||||||
|
partitionBatchOutcome,
|
||||||
|
} from "../src/modules/ai-moderation/batchOutcomeClassifier.js";
|
||||||
|
|
||||||
|
const msgs = (...ids: string[]) => ids.map((id) => ({ id }));
|
||||||
|
|
||||||
|
describe("partitionBatchOutcome", () => {
|
||||||
|
it("marks ALL targets upload_pending when the full-batch guard fires", () => {
|
||||||
|
const out = partitionBatchOutcome(msgs("a", "b"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [],
|
||||||
|
uploadPendingIds: ["a", "b"],
|
||||||
|
});
|
||||||
|
expect(out.get("a")).toBe("upload_pending");
|
||||||
|
expect(out.get("b")).toBe("upload_pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never classifies an explicit upload_pending id as incomplete", () => {
|
||||||
|
// The regression this file exists for: uploadPendingIds must win over the
|
||||||
|
// missing-row heuristic.
|
||||||
|
const out = partitionBatchOutcome(msgs("a"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [],
|
||||||
|
uploadPendingIds: ["a"],
|
||||||
|
});
|
||||||
|
expect(out.get("a")).not.toBe("incomplete");
|
||||||
|
expect(out.get("a")).toBe("upload_pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("partitions a mixed batch: completed + upload-pending + missing", () => {
|
||||||
|
const out = partitionBatchOutcome(msgs("ok1", "up1", "gone1"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [
|
||||||
|
{ id: "ok1", ai_status: "clean" },
|
||||||
|
// up1 has NO row but IS in uploadPendingIds -> deferred, not failed
|
||||||
|
],
|
||||||
|
uploadPendingIds: ["up1"],
|
||||||
|
});
|
||||||
|
expect(out.get("ok1")).toBe("completed");
|
||||||
|
expect(out.get("up1")).toBe("upload_pending");
|
||||||
|
expect(out.get("gone1")).toBe("incomplete"); // unexplained drop stays retryable
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes flag-based failures to their buckets", () => {
|
||||||
|
const out = partitionBatchOutcome(msgs("i", "p", "f"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: "i",
|
||||||
|
ai_status: "error",
|
||||||
|
ai_moderation_flags: JSON.stringify(["analysis_incomplete"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
ai_status: "error",
|
||||||
|
ai_moderation_flags: JSON.stringify(["analysis_parse_failed"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f",
|
||||||
|
ai_status: "error",
|
||||||
|
ai_moderation_flags: JSON.stringify(["analysis_api_failed"]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out.get("i")).toBe("incomplete");
|
||||||
|
expect(out.get("p")).toBe("parse_failed");
|
||||||
|
expect(out.get("f")).toBe("api_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats error rows with no known flag as retryable incomplete", () => {
|
||||||
|
const out = partitionBatchOutcome(msgs("x"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [
|
||||||
|
{ id: "x", ai_status: "error", ai_moderation_flags: '["weird_flag"]' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out.get("x")).toBe("incomplete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts DB-round-trip MessageRecord shape (null ai_status)", () => {
|
||||||
|
const out = partitionBatchOutcome([{ id: "r", ai_status: null }] as never, {
|
||||||
|
ok: true,
|
||||||
|
rows: [{ id: "r", ai_status: null }],
|
||||||
|
});
|
||||||
|
expect(out.get("r")).toBe("completed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeUploadPollDelayMs", () => {
|
||||||
|
it("ramps linearly and respects the cap", () => {
|
||||||
|
expect(computeUploadPollDelayMs(1, 1500, 8000)).toBe(1500);
|
||||||
|
expect(computeUploadPollDelayMs(2, 1500, 8000)).toBe(3000);
|
||||||
|
expect(computeUploadPollDelayMs(3, 1500, 8000)).toBe(4500);
|
||||||
|
expect(computeUploadPollDelayMs(9, 1500, 8000)).toBe(8000); // capped
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is safe on degenerate input", () => {
|
||||||
|
expect(computeUploadPollDelayMs(0, 1500, 8000)).toBe(1500);
|
||||||
|
expect(computeUploadPollDelayMs(-5, 1000, 4000)).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user