Skip to content

Commit a7316b0

Browse files
committed
fix(telegram): release stuck ingress claims on timeout instead of dead-lettering
- Reduce ISOLATED_INGRESS_BACKLOG_STALL_MS from 25 min to 5 min so stuck handlers are detected sooner - Replace failTelegramSpooledUpdateClaim with releaseTelegramSpooledUpdateClaim in recoverTimedOutSpooledHandler so the update is requeued (attempts++) and retried rather than permanently dead-lettered - Add options.lastError to releaseTelegramSpooledUpdateClaim so the timeout reason is recorded in the queue row for diagnostics Previously a hanging handler held its claim indefinitely (>25 min before timeout detection), blocked all subsequent same-lane updates, and when finally detected dead-lettered the update permanently — requiring manual SQLite intervention to restore Telegram inbound processing. Fixes #95350
1 parent e34204a commit a7316b0

3 files changed

Lines changed: 36 additions & 34 deletions

File tree

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

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,12 +1881,10 @@ describe("TelegramPollingSession", () => {
18811881
const secondRunPromise = secondSession.runUntilAbort();
18821882

18831883
await vi.advanceTimersByTimeAsync(1_000);
1884-
await vi.waitFor(async () => expect(await failedUpdateIds(tempDir)).toEqual([42]));
1884+
await vi.waitFor(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([42]));
18851885
await vi.waitFor(() =>
18861886
expect(
1887-
log.mock.calls.some(([line]) =>
1888-
String(line).includes("timed out spooled update 42 did not stop"),
1889-
),
1887+
log.mock.calls.some(([line]) => String(line).includes("timed out spooled update 42")),
18901888
).toBe(true),
18911889
);
18921890
expect(handleUpdate).toHaveBeenCalledTimes(1);
@@ -2820,7 +2818,7 @@ describe("TelegramPollingSession", () => {
28202818
}
28212819
});
28222820

2823-
it("fails a timed-out spooled handler and restarts before draining later same-lane updates", async () => {
2821+
it("releases a timed-out spooled handler for retry and restarts before draining later same-lane updates", async () => {
28242822
vi.useFakeTimers({ shouldAdvanceTime: true });
28252823
const abort = new AbortController();
28262824
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
@@ -2881,14 +2879,16 @@ describe("TelegramPollingSession", () => {
28812879

28822880
await vi.advanceTimersByTimeAsync(1_000);
28832881
await vi.waitFor(() => expect(worker.createWorker).toHaveBeenCalledTimes(2));
2884-
await vi.waitFor(() => expect(events).toEqual(["first:42", "second:43"]));
2882+
// After timeout the claim is released back to pending, so the second bot
2883+
// retries update 42 instead of skipping to update 43.
2884+
await vi.waitFor(() => expect(events).toEqual(["first:42", "second:42"]));
28852885
await runPromise;
28862886

28872887
expect(createTelegramBotMock).toHaveBeenCalledTimes(2);
28882888
expect(firstBot.stop).toHaveBeenCalledTimes(1);
28892889
expect(secondBot.stop).toHaveBeenCalledTimes(1);
2890-
expect(await pendingUpdateIds(tempDir, "all")).toEqual([]);
2891-
expect(await failedUpdateIds(tempDir)).toEqual([42]);
2890+
expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]);
2891+
expect(await failedUpdateIds(tempDir)).toEqual([]);
28922892
expectLogIncludes(log, "spool handler timed out behind update 42");
28932893
} finally {
28942894
abort.abort();
@@ -2949,8 +2949,8 @@ describe("TelegramPollingSession", () => {
29492949

29502950
expect(worker.createWorker).toHaveBeenCalledTimes(1);
29512951
expect(events).toEqual(["first:42"]);
2952-
expect(await failedUpdateIds(tempDir)).toEqual([42]);
2953-
expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]);
2952+
expect(await failedUpdateIds(tempDir)).toEqual([]);
2953+
expect(await pendingUpdateIds(tempDir, "all")).toEqual([42, 43]);
29542954

29552955
releaseFirstTurn?.();
29562956
abort.abort();
@@ -3103,11 +3103,13 @@ describe("TelegramPollingSession", () => {
31033103

31043104
await vi.advanceTimersByTimeAsync(500);
31053105
expect(events).toEqual(["first:42"]);
3106-
expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]);
3106+
// The timed-out update 42 is released back to pending, so it remains
3107+
// in the queue alongside update 43.
3108+
expect(await pendingUpdateIds(tempDir, "all")).toEqual([42, 43]);
31073109

31083110
releaseFirstWorker?.();
31093111
await vi.waitFor(() => expect(createWorker).toHaveBeenCalledTimes(2));
3110-
await vi.waitFor(() => expect(events).toEqual(["first:42", "second:43"]));
3112+
await vi.waitFor(() => expect(events).toEqual(["first:42", "second:42"]));
31113113
await runPromise;
31123114
} finally {
31133115
abort.abort();
@@ -3118,7 +3120,7 @@ describe("TelegramPollingSession", () => {
31183120
}
31193121
});
31203122

3121-
it("keeps a timed-out lane guarded when its failed state cannot be written", async () => {
3123+
it("keeps a timed-out lane guarded when its release for retry cannot be written", async () => {
31223124
vi.useFakeTimers({ shouldAdvanceTime: true });
31233125
const abort = new AbortController();
31243126
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
@@ -3130,8 +3132,8 @@ describe("TelegramPollingSession", () => {
31303132
releaseRegularTurn = resolve;
31313133
});
31323134
const spoolModule = await import("./telegram-ingress-spool.js");
3133-
const failSpy = vi
3134-
.spyOn(spoolModule, "failTelegramSpooledUpdateClaim")
3135+
const releaseSpy = vi
3136+
.spyOn(spoolModule, "releaseTelegramSpooledUpdateClaim")
31353137
.mockRejectedValueOnce(new Error("disk full"));
31363138
createTelegramBotMock.mockReturnValueOnce({
31373139
api: {
@@ -3184,12 +3186,13 @@ describe("TelegramPollingSession", () => {
31843186
const runPromise = session.runUntilAbort();
31853187
await vi.waitFor(() => expect(events).toEqual(["handled:42"]));
31863188
await vi.advanceTimersByTimeAsync(150);
3187-
await vi.waitFor(() => expectLogIncludes(log, "could not be marked failed: disk full"));
3189+
await vi.waitFor(() => expectLogIncludes(log, "could not be released for retry: disk full"));
31883190

31893191
await vi.advanceTimersByTimeAsync(500);
31903192
expect(createWorker).toHaveBeenCalledTimes(1);
31913193
expect(events).toEqual(["handled:42"]);
31923194
expect(await failedUpdateIds(tempDir)).toEqual([]);
3195+
// Release threw, so the claim stays claimed (not released back to pending).
31933196
expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]);
31943197
expect(
31953198
(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).map(
@@ -3213,7 +3216,7 @@ describe("TelegramPollingSession", () => {
32133216
await vi.advanceTimersByTimeAsync(20_000);
32143217
await runPromise;
32153218
} finally {
3216-
failSpy.mockRestore();
3219+
releaseSpy.mockRestore();
32173220
releaseRegularTurn?.();
32183221
abort.abort();
32193222
stopWorker?.();
@@ -3323,9 +3326,11 @@ describe("TelegramPollingSession", () => {
33233326
// lone active handler on the same lane, hangs the same way, and is now also
33243327
// recovered on timeout rather than stranded with no backlog behind it (#84158).
33253328
// Each recovery restarts ingress, so the worker is created once more per
3326-
// recovered handler (initial + two restarts).
3327-
await vi.waitFor(async () => expect(await failedUpdateIds(tempDir)).toEqual([42, 43]));
3328-
expect(createWorker).toHaveBeenCalledTimes(3);
3329+
// recovered handler (initial + two restarts). The timed-out updates are
3330+
// released back to pending (not failed) so they can be retried. After
3331+
// restart the drain re-claims update 42, leaving only 43 pending.
3332+
await vi.waitFor(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]));
3333+
expect(createWorker.mock.calls.length).toBeGreaterThanOrEqual(3);
33293334

33303335
abort.abort();
33313336
await vi.advanceTimersByTimeAsync(20_000);

extensions/telegram/src/polling-session.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ const MIN_POLL_STALL_THRESHOLD_MS = 30_000;
126126
const MAX_POLL_STALL_THRESHOLD_MS = 600_000;
127127
const POLL_WATCHDOG_INTERVAL_MS = 30_000;
128128
const POLL_STOP_GRACE_MS = 15_000;
129-
const ISOLATED_INGRESS_BACKLOG_STALL_MS = 25 * 60_000;
129+
const ISOLATED_INGRESS_BACKLOG_STALL_MS = 5 * 60_000;
130130
const TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS = 5_000;
131131
const TELEGRAM_SPOOLED_HANDLER_TIMEOUT_ENV = "OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS";
132132
const TELEGRAM_SPOOLED_DRAIN_START_LIMIT = 100;
@@ -886,24 +886,19 @@ export class TelegramPollingSession {
886886
}
887887
const age = formatDurationPrecise(timedOutHandler.ageMs);
888888
activeHandler.timedOutAt = Date.now();
889-
const message = `Telegram isolated polling spool handler timed out behind update ${handler.updateId} on lane ${handler.laneKey} after ${age}; marking the update failed, aborting active reply work, and restarting isolated ingress so later updates can drain.`;
889+
const message = `Telegram isolated polling spool handler timed out behind update ${handler.updateId} on lane ${handler.laneKey} after ${age}; releasing the claim for retry, aborting active reply work, and restarting isolated ingress so later updates can drain.`;
890890
activeHandler.timeoutMessage = message;
891891
try {
892-
const failed = await failTelegramSpooledUpdateClaim({
893-
update: handler.update,
894-
reason: "handler-timeout",
895-
message,
892+
// Release the claim back to pending so the drain loop can retry it,
893+
// instead of dead-lettering with a permanent failure. The attempts
894+
// counter on the queue record is incremented on each release, so
895+
// repeated timeouts are observable via SQLite diagnostics.
896+
await releaseTelegramSpooledUpdateClaim(handler.update, {
897+
lastError: message,
896898
});
897-
if (!failed) {
898-
this.opts.log(
899-
`[telegram][diag] timed out spooled update ${handler.updateId} no longer had a processing marker to fail.`,
900-
);
901-
this.#status.notePollingError(message);
902-
return { handlerKey: handler.handlerKey, restart: false };
903-
}
904899
} catch (err) {
905900
this.opts.log(
906-
`[telegram][diag] timed out spooled update ${handler.updateId} could not be marked failed: ${formatErrorMessage(err)}`,
901+
`[telegram][diag] timed out spooled update ${handler.updateId} could not be released for retry: ${formatErrorMessage(err)}`,
907902
);
908903
this.#status.notePollingError(message);
909904
return { handlerKey: handler.handlerKey, restart: false };

extensions/telegram/src/telegram-ingress-spool.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,9 +267,11 @@ export async function claimTelegramSpooledUpdate(
267267

268268
export async function releaseTelegramSpooledUpdateClaim(
269269
update: ClaimedTelegramSpooledUpdate,
270+
options?: { lastError?: string },
270271
): Promise<void> {
271272
await createTelegramIngressQueue(path.dirname(update.pendingPath)).release(
272273
queueMutationTarget(update),
274+
{ lastError: options?.lastError },
273275
);
274276
}
275277

0 commit comments

Comments
 (0)