Skip to content

Commit 99cd6c0

Browse files
committed
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.
1 parent 33eb6ab commit 99cd6c0

2 files changed

Lines changed: 183 additions & 1 deletion

File tree

extensions/telegram/src/polling-session.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,172 @@ describe("TelegramPollingSession", () => {
903903
}
904904
});
905905

906+
it("drains worker-spooled updates without waiting for the next drain interval", async () => {
907+
const abort = new AbortController();
908+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
909+
const handleUpdate = vi.fn(async () => {
910+
abort.abort();
911+
});
912+
const bot = {
913+
api: {
914+
deleteWebhook: vi.fn(async () => true),
915+
config: { use: vi.fn() },
916+
},
917+
init: vi.fn(async () => undefined),
918+
handleUpdate,
919+
stop: vi.fn(async () => undefined),
920+
};
921+
createTelegramBotMock.mockReturnValueOnce(bot);
922+
let onMessage: WorkerMessageListener | undefined;
923+
let stopWorker: (() => void) | undefined;
924+
const workerDone = new Promise<void>((resolve) => {
925+
stopWorker = resolve;
926+
});
927+
const ackSpooledUpdate = vi.fn();
928+
const createWorker = vi.fn(() => ({
929+
onMessage: vi.fn((listener: WorkerMessageListener) => {
930+
onMessage = listener;
931+
return () => undefined;
932+
}),
933+
ackSpooledUpdate,
934+
stop: vi.fn(async () => {
935+
stopWorker?.();
936+
}),
937+
task: vi.fn(async () => {
938+
await workerDone;
939+
}),
940+
}));
941+
942+
try {
943+
const session = createPollingSession({
944+
abortSignal: abort.signal,
945+
isolatedIngress: {
946+
enabled: true,
947+
spoolDir: tempDir,
948+
createWorker,
949+
drainIntervalMs: 60_000,
950+
},
951+
});
952+
953+
const runPromise = session.runUntilAbort();
954+
await vi.waitFor(() => expect(onMessage).toBeDefined());
955+
onMessage?.({
956+
type: "update",
957+
requestId: "write-1",
958+
update: { update_id: 42, message: { text: "hello" } },
959+
queued: 1,
960+
});
961+
962+
await vi.waitFor(() =>
963+
expect(ackSpooledUpdate).toHaveBeenCalledWith("write-1", { ok: true, updateId: 42 }),
964+
);
965+
onMessage?.({ type: "spooled", updateId: 42, queued: 1 });
966+
await vi.waitFor(() =>
967+
expect(handleUpdate).toHaveBeenCalledWith({ update_id: 42, message: { text: "hello" } }),
968+
);
969+
await vi.waitFor(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([]));
970+
stopWorker?.();
971+
await runPromise;
972+
} finally {
973+
abort.abort();
974+
stopWorker?.();
975+
await fs.rm(tempDir, { recursive: true, force: true });
976+
}
977+
});
978+
979+
it("drains worker-spooled updates that arrive during an active drain", async () => {
980+
const abort = new AbortController();
981+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
982+
983+
await writeTelegramSpooledUpdate({
984+
spoolDir: tempDir,
985+
update: { update_id: 1, message: { text: "pre-seeded" } },
986+
});
987+
988+
const handleUpdate = vi.fn(async (update) => {
989+
if (update.update_id === 1) {
990+
await new Promise<void>((resolve) => { setTimeout(resolve, 300); });
991+
}
992+
});
993+
994+
const bot = {
995+
api: {
996+
deleteWebhook: vi.fn(async () => true),
997+
config: { use: vi.fn() },
998+
},
999+
init: vi.fn(async () => undefined),
1000+
handleUpdate,
1001+
stop: vi.fn(async () => undefined),
1002+
};
1003+
createTelegramBotMock.mockReturnValueOnce(bot);
1004+
let onMessage: WorkerMessageListener | undefined;
1005+
let stopWorker: (() => void) | undefined;
1006+
const workerDone = new Promise<void>((resolve) => {
1007+
stopWorker = resolve;
1008+
});
1009+
const ackSpooledUpdate = vi.fn();
1010+
const createWorker = vi.fn(() => ({
1011+
onMessage: vi.fn((listener: WorkerMessageListener) => {
1012+
onMessage = listener;
1013+
return () => undefined;
1014+
}),
1015+
ackSpooledUpdate,
1016+
stop: vi.fn(async () => {
1017+
stopWorker?.();
1018+
}),
1019+
task: vi.fn(async () => {
1020+
await workerDone;
1021+
}),
1022+
}));
1023+
1024+
try {
1025+
const session = createPollingSession({
1026+
abortSignal: abort.signal,
1027+
isolatedIngress: {
1028+
enabled: true,
1029+
spoolDir: tempDir,
1030+
createWorker,
1031+
drainIntervalMs: 60_000,
1032+
},
1033+
});
1034+
1035+
const runPromise = session.runUntilAbort();
1036+
1037+
await vi.waitFor(() =>
1038+
expect(handleUpdate).toHaveBeenCalledWith({
1039+
update_id: 1,
1040+
message: { text: "pre-seeded" },
1041+
}),
1042+
);
1043+
1044+
onMessage?.({
1045+
type: "update",
1046+
requestId: "write-2",
1047+
update: { update_id: 2, message: { text: "during-drain" } },
1048+
queued: 1,
1049+
});
1050+
1051+
await vi.waitFor(() =>
1052+
expect(ackSpooledUpdate).toHaveBeenCalledWith("write-2", { ok: true, updateId: 2 }),
1053+
);
1054+
onMessage?.({ type: "spooled", updateId: 2, queued: 1 });
1055+
1056+
await vi.waitFor(() =>
1057+
expect(handleUpdate).toHaveBeenCalledWith({
1058+
update_id: 2,
1059+
message: { text: "during-drain" },
1060+
}),
1061+
);
1062+
await vi.waitFor(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([]));
1063+
stopWorker?.();
1064+
await runPromise;
1065+
} finally {
1066+
abort.abort();
1067+
stopWorker?.();
1068+
await fs.rm(tempDir, { recursive: true, force: true });
1069+
}
1070+
});
1071+
9061072
it("drains existing isolated ingress spool entries below the persisted offset", async () => {
9071073
const abort = new AbortController();
9081074
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));

extensions/telegram/src/polling-session.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,8 @@ export class TelegramPollingSession {
10001000
forceCycleResolve = resolve;
10011001
});
10021002
const stalledBacklogKeys = new Set<string>();
1003+
let requestImmediateDrain: () => void = () => undefined;
1004+
let drainRequested = false;
10031005
const unsubscribe = worker.onMessage((message) => {
10041006
const ackSpooledUpdate = (
10051007
requestId: string,
@@ -1053,6 +1055,7 @@ export class TelegramPollingSession {
10531055
}).then(
10541056
(updateId) => {
10551057
ackSpooledUpdate(message.requestId, { ok: true, updateId });
1058+
requestImmediateDrain();
10561059
},
10571060
(err: unknown) => {
10581061
ackSpooledUpdate(message.requestId, {
@@ -1065,6 +1068,7 @@ export class TelegramPollingSession {
10651068
}
10661069
if (message.type === "spooled") {
10671070
liveness.noteGetUpdatesActivity();
1071+
requestImmediateDrain();
10681072
}
10691073
});
10701074
const stopOnAbort = () => {
@@ -1105,10 +1109,15 @@ export class TelegramPollingSession {
11051109
}
11061110
};
11071111
const drainOnce = async () => {
1108-
if (restartRequested || drainActive || this.opts.abortSignal?.aborted) {
1112+
if (restartRequested || this.opts.abortSignal?.aborted) {
1113+
return;
1114+
}
1115+
if (drainActive) {
1116+
drainRequested = true;
11091117
return;
11101118
}
11111119
drainActive = true;
1120+
drainRequested = false;
11121121
try {
11131122
const drain = await this.#drainSpooledUpdates({ bot, spoolDir });
11141123
consecutiveDrainFailures = 0;
@@ -1151,8 +1160,15 @@ export class TelegramPollingSession {
11511160
);
11521161
} finally {
11531162
drainActive = false;
1163+
if (drainRequested && !restartRequested && !this.opts.abortSignal?.aborted) {
1164+
drainRequested = false;
1165+
void drainOnce();
1166+
}
11541167
}
11551168
};
1169+
requestImmediateDrain = () => {
1170+
void drainOnce();
1171+
};
11561172
await drainOnce();
11571173
const drainTimer = setInterval(() => {
11581174
void drainOnce();

0 commit comments

Comments
 (0)