feat(gateway): complete openclaw phase1 queue parity v2
This commit is contained in:
+140
-14
@@ -14,20 +14,56 @@ interface QueueEntry<T = unknown> {
|
||||
work: () => Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
policy: LaneQueueConfig;
|
||||
metadata?: LaneQueueEnqueueMetadata;
|
||||
}
|
||||
|
||||
interface Lane {
|
||||
active: boolean;
|
||||
queue: QueueEntry[];
|
||||
debounceTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export type LaneQueueMode = 'collect' | 'steer' | 'interrupt';
|
||||
export type LaneQueueMode = 'collect' | 'followup' | 'steer' | 'steer_backlog' | 'interrupt';
|
||||
export type LaneQueueOverflow = 'drop_old' | 'drop_new';
|
||||
|
||||
export interface LaneQueueConfig {
|
||||
mode: LaneQueueMode;
|
||||
cap: number;
|
||||
overflow: LaneQueueOverflow;
|
||||
debounceMs: number;
|
||||
summarizeOverflow: boolean;
|
||||
}
|
||||
|
||||
export interface LaneQueueEnqueueMetadata {
|
||||
requestId?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface LaneQueueEnqueueOptions {
|
||||
policy?: Partial<LaneQueueConfig>;
|
||||
metadata?: LaneQueueEnqueueMetadata;
|
||||
}
|
||||
|
||||
export type LaneQueueRejectCode = 'superseded' | 'overflow' | 'cancelled';
|
||||
|
||||
export interface LaneQueueRejectDetails {
|
||||
code: LaneQueueRejectCode;
|
||||
laneId: string;
|
||||
mode: LaneQueueMode;
|
||||
overflow?: LaneQueueOverflow;
|
||||
droppedCount?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class LaneQueueRejectedError extends Error {
|
||||
readonly details: LaneQueueRejectDetails;
|
||||
|
||||
constructor(details: LaneQueueRejectDetails) {
|
||||
super(details.message);
|
||||
this.name = 'LaneQueueRejectedError';
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class LaneQueue {
|
||||
@@ -39,6 +75,8 @@ export class LaneQueue {
|
||||
mode: config?.mode ?? 'collect',
|
||||
cap: Math.max(1, config?.cap ?? 50),
|
||||
overflow: config?.overflow ?? 'drop_old',
|
||||
debounceMs: Math.max(0, config?.debounceMs ?? 0),
|
||||
summarizeOverflow: config?.summarizeOverflow ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,8 +85,13 @@ export class LaneQueue {
|
||||
* Returns a promise that resolves with the work's return value
|
||||
* once it has been executed (which may be immediately if the lane is idle).
|
||||
*/
|
||||
async enqueue<T>(laneId: string, work: () => Promise<T>, policy?: Partial<LaneQueueConfig>): Promise<T> {
|
||||
const effective = this.resolvePolicy(policy);
|
||||
async enqueue<T>(
|
||||
laneId: string,
|
||||
work: () => Promise<T>,
|
||||
policyOrOptions?: Partial<LaneQueueConfig> | LaneQueueEnqueueOptions,
|
||||
): Promise<T> {
|
||||
const options = this.normalizeEnqueueOptions(policyOrOptions);
|
||||
const effective = this.resolvePolicy(options.policy);
|
||||
let lane = this.lanes.get(laneId);
|
||||
if (!lane) {
|
||||
lane = { active: false, queue: [] };
|
||||
@@ -56,7 +99,7 @@ export class LaneQueue {
|
||||
}
|
||||
|
||||
// If nothing is running on this lane, execute immediately
|
||||
if (!lane.active) {
|
||||
if (!lane.active && !lane.debounceTimer) {
|
||||
lane.active = true;
|
||||
try {
|
||||
return await work();
|
||||
@@ -66,17 +109,51 @@ export class LaneQueue {
|
||||
}
|
||||
}
|
||||
|
||||
if (effective.mode === 'steer' || effective.mode === 'interrupt') {
|
||||
this.rejectPending(lane, 'Superseded by newer request');
|
||||
if (effective.mode === 'steer' || effective.mode === 'steer_backlog' || effective.mode === 'interrupt') {
|
||||
this.rejectPending(laneId, lane, {
|
||||
code: 'superseded',
|
||||
laneId,
|
||||
mode: effective.mode,
|
||||
message: 'Superseded by newer request',
|
||||
});
|
||||
} else if (effective.mode === 'followup' && lane.queue.length > 0) {
|
||||
this.rejectPending(laneId, lane, {
|
||||
code: 'superseded',
|
||||
laneId,
|
||||
mode: effective.mode,
|
||||
message: 'Superseded by newer follow-up request',
|
||||
});
|
||||
}
|
||||
|
||||
if (lane.queue.length >= effective.cap) {
|
||||
if (effective.overflow === 'drop_new') {
|
||||
return Promise.reject(new Error('Lane queue full (drop_new)'));
|
||||
return Promise.reject(
|
||||
new LaneQueueRejectedError({
|
||||
code: 'overflow',
|
||||
laneId,
|
||||
mode: effective.mode,
|
||||
overflow: 'drop_new',
|
||||
droppedCount: 1,
|
||||
message: effective.summarizeOverflow
|
||||
? `Lane queue full (drop_new): request rejected with ${lane.queue.length} pending`
|
||||
: 'Lane queue full (drop_new)',
|
||||
}),
|
||||
);
|
||||
}
|
||||
// drop_old
|
||||
const dropped = lane.queue.shift();
|
||||
dropped?.reject(new Error('Lane queue overflow (drop_old)'));
|
||||
dropped?.reject(
|
||||
new LaneQueueRejectedError({
|
||||
code: 'overflow',
|
||||
laneId,
|
||||
mode: effective.mode,
|
||||
overflow: 'drop_old',
|
||||
droppedCount: 1,
|
||||
message: effective.summarizeOverflow
|
||||
? 'Lane queue overflow (drop_old): oldest pending request dropped'
|
||||
: 'Lane queue overflow (drop_old)',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, queue the work and return a deferred promise
|
||||
@@ -85,13 +162,16 @@ export class LaneQueue {
|
||||
work: work as () => Promise<unknown>,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
policy: effective,
|
||||
metadata: options.metadata,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if a lane currently has active work executing. */
|
||||
isProcessing(laneId: string): boolean {
|
||||
return this.lanes.get(laneId)?.active ?? false;
|
||||
const lane = this.lanes.get(laneId);
|
||||
return (lane?.active ?? false) || Boolean(lane?.debounceTimer);
|
||||
}
|
||||
|
||||
/** Get the number of pending (not yet started) items in a lane. */
|
||||
@@ -117,18 +197,28 @@ export class LaneQueue {
|
||||
const lane = this.lanes.get(laneId);
|
||||
if (!lane) {return;}
|
||||
|
||||
this.rejectPending(lane, 'Lane cancelled');
|
||||
if (lane.debounceTimer) {
|
||||
clearTimeout(lane.debounceTimer);
|
||||
lane.debounceTimer = undefined;
|
||||
}
|
||||
|
||||
this.rejectPending(laneId, lane, {
|
||||
code: 'cancelled',
|
||||
laneId,
|
||||
mode: this.config.mode,
|
||||
message: 'Lane cancelled',
|
||||
});
|
||||
|
||||
// Clean up empty idle lanes
|
||||
if (!lane.active && lane.queue.length === 0) {
|
||||
if (!lane.active && lane.queue.length === 0 && !lane.debounceTimer) {
|
||||
this.lanes.delete(laneId);
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(lane: Lane, reason: string): void {
|
||||
private rejectPending(laneId: string, lane: Lane, details: LaneQueueRejectDetails): void {
|
||||
const pending = lane.queue.splice(0);
|
||||
for (const entry of pending) {
|
||||
entry.reject(new Error(reason));
|
||||
entry.reject(new LaneQueueRejectedError({ ...details, laneId, mode: entry.policy.mode }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +227,8 @@ export class LaneQueue {
|
||||
mode: policy?.mode ?? this.config.mode,
|
||||
cap: Math.max(1, policy?.cap ?? this.config.cap),
|
||||
overflow: policy?.overflow ?? this.config.overflow,
|
||||
debounceMs: Math.max(0, policy?.debounceMs ?? this.config.debounceMs),
|
||||
summarizeOverflow: policy?.summarizeOverflow ?? this.config.summarizeOverflow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,10 +236,28 @@ export class LaneQueue {
|
||||
* Process the next queued entry for a lane (called after current work finishes).
|
||||
* Runs asynchronously so the caller's finally block completes first.
|
||||
*/
|
||||
private processNext(laneId: string): void {
|
||||
private processNext(laneId: string, skipDebounce = false): void {
|
||||
const lane = this.lanes.get(laneId);
|
||||
if (!lane) {return;}
|
||||
|
||||
if (lane.active || lane.debounceTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = lane.queue[0];
|
||||
if (!next) {
|
||||
this.lanes.delete(laneId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skipDebounce && next.policy.debounceMs > 0) {
|
||||
lane.debounceTimer = setTimeout(() => {
|
||||
lane.debounceTimer = undefined;
|
||||
this.processNext(laneId, true);
|
||||
}, next.policy.debounceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = lane.queue.shift();
|
||||
if (!entry) {
|
||||
// Lane is empty — clean up
|
||||
@@ -164,4 +274,20 @@ export class LaneQueue {
|
||||
this.processNext(laneId);
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeEnqueueOptions(
|
||||
policyOrOptions?: Partial<LaneQueueConfig> | LaneQueueEnqueueOptions,
|
||||
): LaneQueueEnqueueOptions {
|
||||
if (!policyOrOptions) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if ('policy' in policyOrOptions || 'metadata' in policyOrOptions) {
|
||||
return policyOrOptions as LaneQueueEnqueueOptions;
|
||||
}
|
||||
|
||||
return {
|
||||
policy: policyOrOptions as Partial<LaneQueueConfig>,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user