Files
GMW/services/discord-gateway/tests/batchBudget.test.ts
T
asepharyana 80daa9f045 fix(gateway): un-claim budget-overflow messages stuck in processing
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
2026-09-24 16:49:27 +07:00

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([]);
});
});