forked from EduCraft/curriculum-project-hub
ad65d93b2e
- Create MessageBatcher: debounce 600ms, adaptive delay for long chunks, max 8 messages/4000 chars before immediate flush, per (chatId, sender) key - Integrate into trigger: text messages enqueued, slash commands and file/image bypass batching, locked projects respond immediately - Add unit tests (8) for batcher lifecycle and edge cases
126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
export interface MessageBatcherOptions {
|
|
/** Default: 600. */
|
|
readonly debounceMs?: number;
|
|
/** Default: 8. */
|
|
readonly maxMessages?: number;
|
|
/** Default: 4000. */
|
|
readonly maxChars?: number;
|
|
/** Default: 3500. */
|
|
readonly splitThreshold?: number;
|
|
/** Default: 1200. */
|
|
readonly extendedDebounceMs?: number;
|
|
}
|
|
|
|
export type MessageBatcherCallback = (mergedText: string, key?: string) => Promise<void>;
|
|
|
|
interface PendingBatch {
|
|
readonly texts: string[];
|
|
charCount: number;
|
|
timer: ReturnType<typeof setTimeout> | null;
|
|
}
|
|
|
|
const DEFAULT_OPTIONS = {
|
|
debounceMs: 600,
|
|
maxMessages: 8,
|
|
maxChars: 4000,
|
|
splitThreshold: 3500,
|
|
extendedDebounceMs: 1200,
|
|
} as const;
|
|
|
|
export class MessageBatcher {
|
|
private readonly debounceMs: number;
|
|
private readonly maxMessages: number;
|
|
private readonly maxChars: number;
|
|
private readonly splitThreshold: number;
|
|
private readonly extendedDebounceMs: number;
|
|
private readonly pending = new Map<string, PendingBatch>();
|
|
private readonly chains = new Map<string, Promise<void>>();
|
|
|
|
constructor(
|
|
private readonly onFlush: MessageBatcherCallback,
|
|
options: MessageBatcherOptions = {},
|
|
) {
|
|
this.debounceMs = options.debounceMs ?? DEFAULT_OPTIONS.debounceMs;
|
|
this.maxMessages = options.maxMessages ?? DEFAULT_OPTIONS.maxMessages;
|
|
this.maxChars = options.maxChars ?? DEFAULT_OPTIONS.maxChars;
|
|
this.splitThreshold = options.splitThreshold ?? DEFAULT_OPTIONS.splitThreshold;
|
|
this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs;
|
|
}
|
|
|
|
async enqueue(chatId: string, senderOpenId: string, text: string): Promise<void> {
|
|
const key = messageBatchKey(chatId, senderOpenId);
|
|
await this.runSerial(key, async () => {
|
|
const existing = this.pending.get(key);
|
|
const batch =
|
|
existing ??
|
|
{
|
|
texts: [],
|
|
charCount: 0,
|
|
timer: null,
|
|
};
|
|
|
|
if (existing === undefined) {
|
|
this.pending.set(key, batch);
|
|
}
|
|
|
|
batch.texts.push(text);
|
|
batch.charCount += text.length + (batch.texts.length === 1 ? 0 : 1);
|
|
|
|
if (batch.texts.length >= this.maxMessages || batch.charCount >= this.maxChars) {
|
|
await this.flushPendingBatch(key, batch);
|
|
return;
|
|
}
|
|
|
|
this.scheduleFlush(key, batch, text.length >= this.splitThreshold ? this.extendedDebounceMs : this.debounceMs);
|
|
});
|
|
}
|
|
|
|
async flushNow(key: string): Promise<void> {
|
|
await this.runSerial(key, async () => {
|
|
const batch = this.pending.get(key);
|
|
if (batch === undefined) return;
|
|
await this.flushPendingBatch(key, batch);
|
|
});
|
|
}
|
|
|
|
async flushAll(): Promise<void> {
|
|
const keys = [...this.pending.keys()];
|
|
await Promise.all(keys.map((key) => this.flushNow(key)));
|
|
}
|
|
|
|
private scheduleFlush(key: string, batch: PendingBatch, delayMs: number): void {
|
|
if (batch.timer !== null) {
|
|
clearTimeout(batch.timer);
|
|
}
|
|
batch.timer = setTimeout(() => {
|
|
void this.flushNow(key).catch(() => undefined);
|
|
}, delayMs);
|
|
(batch.timer as { unref?: () => void }).unref?.();
|
|
}
|
|
|
|
private async flushPendingBatch(key: string, batch: PendingBatch): Promise<void> {
|
|
if (batch.timer !== null) {
|
|
clearTimeout(batch.timer);
|
|
batch.timer = null;
|
|
}
|
|
if (this.pending.get(key) !== batch) return;
|
|
this.pending.delete(key);
|
|
await this.onFlush(batch.texts.join("\n"), key);
|
|
}
|
|
|
|
private runSerial(key: string, operation: () => Promise<void>): Promise<void> {
|
|
const previous = this.chains.get(key) ?? Promise.resolve();
|
|
const next = previous.catch(() => undefined).then(operation);
|
|
this.chains.set(key, next);
|
|
return next.finally(() => {
|
|
if (this.chains.get(key) === next) {
|
|
this.chains.delete(key);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export function messageBatchKey(chatId: string, senderOpenId: string): string {
|
|
return `${chatId}:${senderOpenId}`;
|
|
}
|