forked from EduCraft/curriculum-project-hub
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
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user