Files
curriculum-project-hub/hub/src/feishu/triggerQueue.ts
T
hongjr03 36c0801e6f feat: add per-project message trigger wait queue
- TriggerQueue: FIFO per-project queue, max 5 items, 5min TTL expiry
- Replace reject-when-locked with queue: users get position feedback
- Run completion auto-starts next queued trigger (skips expired items)
- /reset clears project queue; 60s periodic purge interval
- Add unit tests (10) for queue lifecycle and edge cases
2026-07-08 17:31:43 +08:00

101 lines
2.7 KiB
TypeScript

import type { MessageReceiveEvent } from "./client.js";
export interface QueuedTrigger {
readonly projectId: string;
readonly chatId: string;
readonly prompt: string;
readonly msg: MessageReceiveEvent["message"];
readonly senderOpenId: string;
readonly actor: { readonly feishuOpenId: string; readonly chatId: string };
readonly enqueuedAt: number;
}
export interface TriggerQueueOptions {
readonly maxQueueSize?: number;
readonly maxWaitMs?: number;
}
const DEFAULT_MAX_QUEUE_SIZE = 5;
const DEFAULT_MAX_WAIT_MS = 300_000;
export class TriggerQueue {
readonly maxQueueSize: number;
readonly maxWaitMs: number;
private readonly queues = new Map<string, QueuedTrigger[]>();
constructor(options: TriggerQueueOptions = {}) {
this.maxQueueSize = Math.max(0, Math.trunc(options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
this.maxWaitMs = Math.max(0, Math.trunc(options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS));
}
enqueue(projectId: string, trigger: Omit<QueuedTrigger, "projectId" | "enqueuedAt">): number {
const queue = this.queues.get(projectId) ?? [];
if (queue.length >= this.maxQueueSize) return 0;
queue.push({
...trigger,
projectId,
enqueuedAt: Date.now(),
});
if (!this.queues.has(projectId)) {
this.queues.set(projectId, queue);
}
return queue.length;
}
dequeue(projectId: string): QueuedTrigger | null {
const queue = this.queues.get(projectId);
if (queue === undefined || queue.length === 0) return null;
const next = queue.shift() ?? null;
if (queue.length === 0) {
this.queues.delete(projectId);
}
return next;
}
peek(projectId: string): QueuedTrigger | null {
return this.queues.get(projectId)?.[0] ?? null;
}
length(projectId: string): number {
return this.queues.get(projectId)?.length ?? 0;
}
hasPending(projectId: string): boolean {
return this.length(projectId) > 0;
}
purgeExpired(): number {
const now = Date.now();
let removed = 0;
for (const [projectId, queue] of this.queues) {
const fresh = queue.filter((trigger) => !this.isExpired(trigger, now));
removed += queue.length - fresh.length;
if (fresh.length === 0) {
this.queues.delete(projectId);
} else if (fresh.length !== queue.length) {
this.queues.set(projectId, fresh);
}
}
return removed;
}
clear(projectId: string): number {
const count = this.length(projectId);
this.queues.delete(projectId);
return count;
}
clearAll(): void {
this.queues.clear();
}
isExpired(trigger: QueuedTrigger, now = Date.now()): boolean {
return trigger.enqueuedAt + this.maxWaitMs < now;
}
}
export const triggerQueue = new TriggerQueue();