fix #86957: drain worker-spooled Telegram updates immediately

Wake the isolated polling drain immediately after a worker-spooled
update is written to channel_ingress_events, instead of waiting for
the next drain interval.

- Add requestImmediateDrain() calls after worker write and spooled message
- Track pending drain requests while drain is active (fix race condition)
- Add regression test for updates arriving during active drain

Fixes #86957.
This commit is contained in:
weiqinl
2026-06-21 23:10:45 +08:00
committed by Ayaan Zaidi
parent 62456d65eb
commit e29381a172
2 changed files with 183 additions and 1 deletions

View File

@@ -903,6 +903,172 @@ describe("TelegramPollingSession", () => {
}
});
it("drains worker-spooled updates without waiting for the next drain interval", async () => {
const abort = new AbortController();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
const handleUpdate = vi.fn(async () => {
abort.abort();
});
const bot = {
api: {
deleteWebhook: vi.fn(async () => true),
config: { use: vi.fn() },
},
init: vi.fn(async () => undefined),
handleUpdate,
stop: vi.fn(async () => undefined),
};
createTelegramBotMock.mockReturnValueOnce(bot);
let onMessage: WorkerMessageListener | undefined;
let stopWorker: (() => void) | undefined;
const workerDone = new Promise<void>((resolve) => {
stopWorker = resolve;
});
const ackSpooledUpdate = vi.fn();
const createWorker = vi.fn(() => ({
onMessage: vi.fn((listener: WorkerMessageListener) => {
onMessage = listener;
return () => undefined;
}),
ackSpooledUpdate,
stop: vi.fn(async () => {
stopWorker?.();
}),
task: vi.fn(async () => {
await workerDone;
}),
}));
try {
const session = createPollingSession({
abortSignal: abort.signal,
isolatedIngress: {
enabled: true,
spoolDir: tempDir,
createWorker,
drainIntervalMs: 60_000,
},
});
const runPromise = session.runUntilAbort();
await vi.waitFor(() => expect(onMessage).toBeDefined());
onMessage?.({
type: "update",
requestId: "write-1",
update: { update_id: 42, message: { text: "hello" } },
queued: 1,
});
await vi.waitFor(() =>
expect(ackSpooledUpdate).toHaveBeenCalledWith("write-1", { ok: true, updateId: 42 }),
);
onMessage?.({ type: "spooled", updateId: 42, queued: 1 });
await vi.waitFor(() =>
expect(handleUpdate).toHaveBeenCalledWith({ update_id: 42, message: { text: "hello" } }),
);
await vi.waitFor(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([]));
stopWorker?.();
await runPromise;
} finally {
abort.abort();
stopWorker?.();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("drains worker-spooled updates that arrive during an active drain", async () => {
const abort = new AbortController();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
await writeTelegramSpooledUpdate({
spoolDir: tempDir,
update: { update_id: 1, message: { text: "pre-seeded" } },
});
const handleUpdate = vi.fn(async (update) => {
if (update.update_id === 1) {
await new Promise<void>((resolve) => { setTimeout(resolve, 300); });
}
});
const bot = {
api: {
deleteWebhook: vi.fn(async () => true),
config: { use: vi.fn() },
},
init: vi.fn(async () => undefined),
handleUpdate,
stop: vi.fn(async () => undefined),
};
createTelegramBotMock.mockReturnValueOnce(bot);
let onMessage: WorkerMessageListener | undefined;
let stopWorker: (() => void) | undefined;
const workerDone = new Promise<void>((resolve) => {
stopWorker = resolve;
});
const ackSpooledUpdate = vi.fn();
const createWorker = vi.fn(() => ({
onMessage: vi.fn((listener: WorkerMessageListener) => {
onMessage = listener;
return () => undefined;
}),
ackSpooledUpdate,
stop: vi.fn(async () => {
stopWorker?.();
}),
task: vi.fn(async () => {
await workerDone;
}),
}));
try {
const session = createPollingSession({
abortSignal: abort.signal,
isolatedIngress: {
enabled: true,
spoolDir: tempDir,
createWorker,
drainIntervalMs: 60_000,
},
});
const runPromise = session.runUntilAbort();
await vi.waitFor(() =>
expect(handleUpdate).toHaveBeenCalledWith({
update_id: 1,
message: { text: "pre-seeded" },
}),
);
onMessage?.({
type: "update",
requestId: "write-2",
update: { update_id: 2, message: { text: "during-drain" } },
queued: 1,
});
await vi.waitFor(() =>
expect(ackSpooledUpdate).toHaveBeenCalledWith("write-2", { ok: true, updateId: 2 }),
);
onMessage?.({ type: "spooled", updateId: 2, queued: 1 });
await vi.waitFor(() =>
expect(handleUpdate).toHaveBeenCalledWith({
update_id: 2,
message: { text: "during-drain" },
}),
);
await vi.waitFor(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([]));
stopWorker?.();
await runPromise;
} finally {
abort.abort();
stopWorker?.();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("drains existing isolated ingress spool entries below the persisted offset", async () => {
const abort = new AbortController();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));

View File

@@ -1000,6 +1000,8 @@ export class TelegramPollingSession {
forceCycleResolve = resolve;
});
const stalledBacklogKeys = new Set<string>();
let requestImmediateDrain: () => void = () => undefined;
let drainRequested = false;
const unsubscribe = worker.onMessage((message) => {
const ackSpooledUpdate = (
requestId: string,
@@ -1053,6 +1055,7 @@ export class TelegramPollingSession {
}).then(
(updateId) => {
ackSpooledUpdate(message.requestId, { ok: true, updateId });
requestImmediateDrain();
},
(err: unknown) => {
ackSpooledUpdate(message.requestId, {
@@ -1065,6 +1068,7 @@ export class TelegramPollingSession {
}
if (message.type === "spooled") {
liveness.noteGetUpdatesActivity();
requestImmediateDrain();
}
});
const stopOnAbort = () => {
@@ -1105,10 +1109,15 @@ export class TelegramPollingSession {
}
};
const drainOnce = async () => {
if (restartRequested || drainActive || this.opts.abortSignal?.aborted) {
if (restartRequested || this.opts.abortSignal?.aborted) {
return;
}
if (drainActive) {
drainRequested = true;
return;
}
drainActive = true;
drainRequested = false;
try {
const drain = await this.#drainSpooledUpdates({ bot, spoolDir });
consecutiveDrainFailures = 0;
@@ -1151,8 +1160,15 @@ export class TelegramPollingSession {
);
} finally {
drainActive = false;
if (drainRequested && !restartRequested && !this.opts.abortSignal?.aborted) {
drainRequested = false;
void drainOnce();
}
}
};
requestImmediateDrain = () => {
void drainOnce();
};
await drainOnce();
const drainTimer = setInterval(() => {
void drainOnce();