fix(telegram): handle ENOENT race in spool drain recovery rename

Handle the Telegram isolated-polling spool recovery race where a stale `.processing` claim can disappear between discovery and the final rename back to pending. Recovery now treats `ENOENT` as benign and mirrors the existing duplicate-pending cleanup path for `EEXIST`, avoiding noisy drain-failure logs and spurious failure counters without changing claim ownership semantics.

Adds a regression test that removes the claim from inside `shouldRecover`, after recovery has discovered the entry and before the final rename path, so the old code would hit the reported `ENOENT` window.

Fixes #87847

Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
This commit is contained in:
Sebastien Tardif
2026-05-31 10:02:55 -07:00
committed by GitHub
parent 3ceaafb2b3
commit 66bbcfdade
2 changed files with 44 additions and 1 deletions

View File

@@ -242,6 +242,37 @@ describe("Telegram ingress spool", () => {
});
});
it("handles ENOENT race when processing file is removed before recovery rename", async () => {
await withTempSpool(async (spoolDir) => {
await writeTelegramSpooledUpdate({
spoolDir,
update: { update_id: 45, message: { text: "vanishes" } },
});
const update = (await listTelegramSpooledUpdates({ spoolDir }))[0];
if (!update) {
throw new Error("Expected a spooled update");
}
const claimed = await claimTelegramSpooledUpdate(update);
if (!claimed) {
throw new Error("Expected a claimed update");
}
let shouldRecoverCalls = 0;
const recovered = await recoverStaleTelegramSpooledUpdateClaims({
spoolDir,
staleMs: 0,
shouldRecover: async () => {
shouldRecoverCalls += 1;
await fs.unlink(claimed.path);
return true;
},
});
expect(recovered).toBe(0);
expect(shouldRecoverCalls).toBe(1);
expect(await fs.readdir(spoolDir)).toEqual([]);
});
});
it("does not treat stale claims with reused pids as live-owned", () => {
const now = Date.now();
expect(

View File

@@ -443,7 +443,19 @@ export async function recoverStaleTelegramSpooledUpdateClaims(params: {
if (await pathExists(pendingPath)) {
await unlinkIfPresent(claimedPath);
} else {
await fs.rename(claimedPath, pendingPath);
try {
await fs.rename(claimedPath, pendingPath);
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
continue;
}
if (code === "EEXIST") {
await unlinkIfPresent(claimedPath);
} else {
throw err;
}
}
}
recovered += 1;
}