forked from EduCraft/curriculum-project-hub
feat: add message burst debouncing (MessageBatcher)
- 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
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user