Skip to content

Commit 55d31be

Browse files
committed
fix(telegram): age-gate retry-limit spool tombstones (#98776)
1 parent ce9ec4e commit 55d31be

2 files changed

Lines changed: 68 additions & 14 deletions

File tree

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

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -477,9 +477,10 @@ async function waitForTestReplyFenceAbort(params: { key: string; laneKey: string
477477
async function writeSpooledTestUpdates(
478478
spoolDir: string,
479479
updates: readonly TestTelegramUpdate[],
480+
options?: { now?: number },
480481
): Promise<void> {
481482
for (const update of updates) {
482-
await writeTelegramSpooledUpdate({ spoolDir, update });
483+
await writeTelegramSpooledUpdate({ spoolDir, update, now: options?.now });
483484
}
484485
}
485486

@@ -829,6 +830,47 @@ describe("TelegramPollingSession", () => {
829830
1_999,
830831
),
831832
).toBe(0);
833+
expect(
834+
pollingSessionTesting.resolveSpooledUpdateRetryDelayMs(
835+
{
836+
updateId: 44,
837+
path: "/tmp/44.json",
838+
update: { update_id: 44 },
839+
receivedAt: 0,
840+
attempts: pollingSessionTesting.spooledRetryMaxAttempts,
841+
lastAttemptAt: 1_000,
842+
lastError: "state store outage",
843+
},
844+
1_999,
845+
),
846+
).toBeGreaterThan(0);
847+
});
848+
849+
it("keeps generic retryable failures pending until they are old enough to dead-letter", () => {
850+
const update = {
851+
updateId: 42,
852+
path: "/tmp/42.json",
853+
update: { update_id: 42 },
854+
receivedAt: 1_000,
855+
attempts: pollingSessionTesting.spooledRetryMaxAttempts - 1,
856+
lastAttemptAt: 2_000,
857+
lastError: "state store outage",
858+
};
859+
860+
expect(
861+
pollingSessionTesting.shouldDeadLetterRetryableSpooledUpdate(
862+
update,
863+
pollingSessionTesting.spooledRetryMaxAttempts,
864+
1_000 + pollingSessionTesting.spooledRetryDeadLetterMinAgeMs - 1,
865+
),
866+
).toBe(false);
867+
expect(
868+
pollingSessionTesting.shouldDeadLetterRetryableSpooledUpdate(
869+
update,
870+
pollingSessionTesting.spooledRetryMaxAttempts,
871+
1_000 + pollingSessionTesting.spooledRetryDeadLetterMinAgeMs,
872+
),
873+
).toBe(true);
832874
});
833875

834876
it("does not call getUpdates for offset confirmation (avoiding 409 conflicts)", async () => {
@@ -2093,10 +2135,13 @@ describe("TelegramPollingSession", () => {
20932135
const log = vi.fn();
20942136
const events: string[] = [];
20952137
let poisonAttempts = 0;
2096-
await writeSpooledTestUpdates(tempDir, [
2097-
topicUpdate(42, 10, "poison"),
2098-
topicUpdate(44, 10, "after poison"),
2099-
]);
2138+
await writeSpooledTestUpdates(
2139+
tempDir,
2140+
[topicUpdate(42, 10, "poison"), topicUpdate(44, 10, "after poison")],
2141+
{
2142+
now: Date.now() - pollingSessionTesting.spooledRetryDeadLetterMinAgeMs,
2143+
},
2144+
);
21002145

21012146
const { runPromise, stopWorker } = startIsolatedIngressSession({
21022147
abort,

extensions/telegram/src/polling-session.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ const TELEGRAM_SPOOLED_CLAIM_HEALTH_GRACE_MS = 2 * TELEGRAM_SPOOLED_CLAIM_REFRES
138138
const TELEGRAM_SPOOLED_RETRY_MAX_ATTEMPTS = 8;
139139
const TELEGRAM_SPOOLED_RETRY_BASE_MS = 1_000;
140140
const TELEGRAM_SPOOLED_RETRY_MAX_MS = 3 * 60_000;
141+
const TELEGRAM_SPOOLED_RETRY_DEAD_LETTER_MIN_AGE_MS = 24 * 60 * 60 * 1000;
141142
const TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS = Math.ceil(
142143
TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS / 1000,
143144
);
@@ -178,12 +179,7 @@ function resolveNonRetryableSpooledUpdateFailure(
178179

179180
function resolveSpooledUpdateRetryDelayMs(update: TelegramSpooledUpdate, now = Date.now()): number {
180181
const attempts = update.attempts ?? 0;
181-
if (
182-
!update.lastError ||
183-
update.lastAttemptAt === undefined ||
184-
attempts <= 0 ||
185-
attempts >= TELEGRAM_SPOOLED_RETRY_MAX_ATTEMPTS
186-
) {
182+
if (!update.lastError || update.lastAttemptAt === undefined || attempts <= 0) {
187183
return 0;
188184
}
189185
const exponent = Math.min(attempts - 1, 8);
@@ -198,6 +194,17 @@ function resolveSpooledUpdateAttemptNumber(update: TelegramSpooledUpdate): numbe
198194
return (update.attempts ?? 0) + 1;
199195
}
200196

197+
function shouldDeadLetterRetryableSpooledUpdate(
198+
update: TelegramSpooledUpdate,
199+
attempt: number,
200+
now = Date.now(),
201+
): boolean {
202+
return (
203+
attempt >= TELEGRAM_SPOOLED_RETRY_MAX_ATTEMPTS &&
204+
now - update.receivedAt >= TELEGRAM_SPOOLED_RETRY_DEAD_LETTER_MIN_AGE_MS
205+
);
206+
}
207+
201208
type TelegramBot = ReturnType<typeof createTelegramBot>;
202209

203210
const waitForGracefulStop = async (stop: () => Promise<void>) => {
@@ -843,7 +850,7 @@ export class TelegramPollingSession {
843850
}
844851
}
845852
const attempt = resolveSpooledUpdateAttemptNumber(params.update);
846-
if (attempt >= TELEGRAM_SPOOLED_RETRY_MAX_ATTEMPTS) {
853+
if (shouldDeadLetterRetryableSpooledUpdate(params.update, attempt)) {
847854
const message = formatErrorMessage(params.err);
848855
try {
849856
const failed = await failTelegramSpooledUpdateClaim({
@@ -857,8 +864,8 @@ export class TelegramPollingSession {
857864
);
858865
return;
859866
}
860-
// Retryable poison updates must eventually become tombstones so the
861-
// lowest update id stops blocking every later turn in the same lane.
867+
// Retryable poison updates must eventually become tombstones, but not
868+
// during ordinary transient provider or state-store outages.
862869
this.opts.log(
863870
`[telegram][warn] spooled update ${params.update.updateId} on lane ${laneKey} reached retry limit after ${attempt} attempts; dead-lettered: ${message}`,
864871
);
@@ -1718,7 +1725,9 @@ export const testing = {
17181725
resetTelegramRestartBackoffState,
17191726
resolveTelegramRestartDelayMs,
17201727
resolveSpooledUpdateRetryDelayMs,
1728+
shouldDeadLetterRetryableSpooledUpdate,
17211729
spooledRetryMaxAttempts: TELEGRAM_SPOOLED_RETRY_MAX_ATTEMPTS,
1730+
spooledRetryDeadLetterMinAgeMs: TELEGRAM_SPOOLED_RETRY_DEAD_LETTER_MIN_AGE_MS,
17221731
isolatedIngressBacklogStallMs: ISOLATED_INGRESS_BACKLOG_STALL_MS,
17231732
spooledClaimRefreshIntervalMs: TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS,
17241733
resolveSpooledUpdateHandlerAbortGraceMs: (valueMs: unknown): number =>

0 commit comments

Comments
 (0)