getPendingMessagesByConversation() flips every fetched pending row to 'processing', then pickBatchWithinBudget() may stop early on the token budget. The tail rows that did NOT make the batch were never un-claimed, so they stayed 'processing' forever — the recovery worker reverted them (120s) only for the next wave to re-claim them, an infinite loop of stuck messages that never get analyzed (saw 22 rows, some recycled for 40+ minutes). - add computeBudgetOverflowMessages() pure helper (batchBudget.ts) - batchScheduler un-claims overflow rows back to 'pending' before dispatching the trimmed batch - recovery-worker now reverts stuck processing unconditionally (the old 'conversationProcessing.size > 0' guard skipped the revert when the in-memory lock map was empty, e.g. fresh boot — exactly when stranded rows from a previous process need rescuing) - 3 regression tests for the overflow helper
149 lines
4.1 KiB
TypeScript
149 lines
4.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
computeBudgetOverflowMessages,
|
|
pickBatchWithinBudget,
|
|
type TokenEstimator,
|
|
} from "../src/modules/ai-moderation/batchBudget.js";
|
|
import type { MessageRecord } from "../src/modules/message-capture/types.js";
|
|
|
|
// Deterministic estimator: 1 token per character. Keeps the budget math
|
|
// exact regardless of tiktoken behavior (the real estimator is injected at
|
|
// the call site — see batchProcessor.ts).
|
|
const estimate: TokenEstimator = (text: string) => text.length;
|
|
|
|
const TOKENS_PER_MESSAGE = 50;
|
|
|
|
function msg(id: string, content: string, createdAt: number): MessageRecord {
|
|
return {
|
|
id,
|
|
guild_id: "g",
|
|
channel_id: "c",
|
|
thread_id: null,
|
|
user_id: "u",
|
|
username: "user",
|
|
avatar_url: null,
|
|
content,
|
|
edited_content: null,
|
|
created_at: createdAt,
|
|
edited_at: null,
|
|
deleted_at: null,
|
|
type: "text",
|
|
is_reply: false,
|
|
is_forward: false,
|
|
is_crosspost: false,
|
|
reference_message_id: null,
|
|
reference_channel_id: null,
|
|
reference_guild_id: null,
|
|
metadata: null,
|
|
};
|
|
}
|
|
|
|
describe("pickBatchWithinBudget", () => {
|
|
it("returns a contiguous chronological prefix — no gaps mid-timeline", () => {
|
|
// sizes: 100, 100, 400 (overflow), 10
|
|
const messages = [
|
|
msg("m1", "a".repeat(100), 1),
|
|
msg("m2", "b".repeat(100), 2),
|
|
msg("m3", "c".repeat(400), 3),
|
|
msg("m4", "d".repeat(10), 4),
|
|
];
|
|
const batch = pickBatchWithinBudget(
|
|
messages,
|
|
500,
|
|
TOKENS_PER_MESSAGE,
|
|
estimate,
|
|
);
|
|
|
|
// m1(150)+m2(150)=300 fits; m3 would be 550 > 500 → stop.
|
|
// m4 must NOT be picked even though it alone fits (no timeline gap).
|
|
expect(batch.map((m) => m.id)).toEqual(["m1", "m2"]);
|
|
});
|
|
|
|
it("includes a message that exactly hits the budget", () => {
|
|
const messages = [msg("m1", "a".repeat(450), 1)];
|
|
const batch = pickBatchWithinBudget(
|
|
messages,
|
|
500,
|
|
TOKENS_PER_MESSAGE,
|
|
estimate,
|
|
);
|
|
expect(batch.map((m) => m.id)).toEqual(["m1"]);
|
|
});
|
|
|
|
it("returns empty when the first message alone exceeds the budget", () => {
|
|
const messages = [
|
|
msg("big", "x".repeat(1000), 1),
|
|
msg("m2", "y".repeat(10), 2),
|
|
];
|
|
const batch = pickBatchWithinBudget(
|
|
messages,
|
|
500,
|
|
TOKENS_PER_MESSAGE,
|
|
estimate,
|
|
);
|
|
expect(batch).toHaveLength(0);
|
|
});
|
|
|
|
it("handles empty input", () => {
|
|
expect(
|
|
pickBatchWithinBudget([], 500, TOKENS_PER_MESSAGE, estimate),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("computeBudgetOverflowMessages", () => {
|
|
it("returns the tail that did NOT fit the token budget", () => {
|
|
const messages = [
|
|
msg("m1", "a".repeat(100), 1),
|
|
msg("m2", "b".repeat(100), 2),
|
|
msg("m3", "c".repeat(900), 3),
|
|
msg("m4", "d".repeat(10), 4),
|
|
];
|
|
// 500 budget: m1(150)+m2(150)=300 fits, m3(950) overflows → batch = [m1,m2]
|
|
const batch = pickBatchWithinBudget(
|
|
messages,
|
|
500,
|
|
TOKENS_PER_MESSAGE,
|
|
estimate,
|
|
);
|
|
const overflow = computeBudgetOverflowMessages(messages, batch);
|
|
// m3 + m4 were claimed `processing` by the DB fetch but never processed —
|
|
// they must be identified so the scheduler can return them to `pending`.
|
|
expect(overflow.map((m) => m.id)).toEqual(["m3", "m4"]);
|
|
});
|
|
|
|
it("returns everything when the batch is empty (all overflow)", () => {
|
|
const messages = [
|
|
msg("m1", "x".repeat(1000), 1),
|
|
msg("m2", "y".repeat(1000), 2),
|
|
];
|
|
const batch = pickBatchWithinBudget(
|
|
messages,
|
|
500,
|
|
TOKENS_PER_MESSAGE,
|
|
estimate,
|
|
);
|
|
expect(batch).toHaveLength(0);
|
|
expect(
|
|
computeBudgetOverflowMessages(messages, batch).map((m) => m.id),
|
|
).toEqual(["m1", "m2"]);
|
|
});
|
|
|
|
it("returns nothing when every claimed message was processed", () => {
|
|
const messages = [
|
|
msg("m1", "a".repeat(100), 1),
|
|
msg("m2", "b".repeat(100), 2),
|
|
];
|
|
const batch = pickBatchWithinBudget(
|
|
messages,
|
|
500,
|
|
TOKENS_PER_MESSAGE,
|
|
estimate,
|
|
);
|
|
expect(batch).toHaveLength(2);
|
|
expect(
|
|
computeBudgetOverflowMessages(messages, batch).map((m) => m.id),
|
|
).toEqual([]);
|
|
});
|
|
});
|