fix(gasless): stop paging on post-handover ticks racing Redis teardown - #3722
fix(gasless): stop paging on post-handover ticks racing Redis teardown#3722droplet-rl wants to merge 2 commits into
Conversation
On every 5-minute serverless handover, the outgoing instance aborts its poll schedule and disconnects its Redis clients, but an in-flight evaluateApiSignatures tick is not cancelled: its provider-cache reads then fail with node-redis 'The client is closed' errors, surfacing as 'all providers failed' at error level and paging PagerDuty (~7 incidents since 2026-08-11, e.g. PD-327993). The successor instance already owns the batch by then, so the page is pure noise. Fixes, applied to GaslessRelayer and symmetrically to the DepositAddressHandler (same scheduleTask/handover/teardown pattern): - Make the per-deposit state machines abort-aware so an in-flight tick winds down at the next loop boundary instead of retrying against closed clients (or spinning forever in the fillLock wait branch). - Drain the in-flight tick (bounded, 5s) in waitForDisconnect() before the caller tears down the shared Redis clients. - Log a post-abort tick failure at debug instead of error; the error-level 'batch skipped this tick' log is unchanged for genuine in-service failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6096ce8e61
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| public pollAndExecute(): void { | ||
| scheduleTask( | ||
| () => this.evaluateApiSignatures(), | ||
| () => (this.inFlightTick = this.evaluateApiSignatures()), |
There was a problem hiding this comment.
Drain all overlapping gasless poll ticks
When evaluateApiSignatures() runs longer than apiPollingInterval, scheduleTask starts another invocation because it uses setInterval (src/utils/Tasks.ts:55), and this assignment replaces the promise for the older invocation. A later tick can finish quickly after filtering out the message whose state machine is still active, so waitForDisconnect() awaits that newer promise and disconnects Redis while the older tick is still submitting or confirming transactions. Track every active tick or serialize the polling loop rather than retaining only the most recent promise.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 4e3435e.
Worth noting this is not an edge case: API_POLLING_INTERVAL defaults to 1s, and a single message can loop for minutes on the delay(1) retry branches, so ticks overlap routinely. Retaining only the newest promise meant waitForDisconnect() could drain a tick that had already returned while an older one was still in DEPOSIT_CONFIRM.
I took your first option (track every active tick) rather than serializing. scheduleSequentialTask already exists in utils/Tasks.ts and would have been a one-line switch, but it changes behaviour in a way I do not think we want here: forEachAsync is Promise.all, yet a message that retries for minutes still holds up its own tick, and today it is precisely the overlapping successor ticks that pick up the rest of the batch. Serializing would stall the whole poll loop behind one slow message.
So pollAndExecute now goes through trackInFlight() (added in utils/Tasks.ts, next to the setInterval that causes the overlap), which tracks every invocation and drains them all under one bound. waitForDisconnect() also warns when that bound expires instead of silently proceeding to disconnect, so a tick that still cannot observe the abort is visible rather than mysterious. Regression test gates tick #1, lets tick #2 finish, and asserts the drain still waits.
| public pollAndExecute(): void { | ||
| scheduleTask( | ||
| () => this.evaluateDepositAddresses(), | ||
| () => (this.inFlightTick = this.evaluateDepositAddresses()), |
There was a problem hiding this comment.
Drain all overlapping deposit-address ticks
When deposit processing takes longer than indexerPollingInterval, the setInterval-based scheduleTask (src/utils/Tasks.ts:55) starts overlapping evaluations and each invocation overwrites inFlightTick. At handover, waitForDisconnect() therefore drains only the newest tick; an older tick may still be executing a deposit or withdrawal when the shared Redis clients are closed and the successor begins processing. Track all outstanding evaluations or use a non-overlapping scheduler so teardown cannot race an untracked tick.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Same fix applied here in 4e3435e: pollTicks = trackInFlight(() => this.evaluateDepositAddresses()), with waitForDisconnect() draining every tracked tick rather than the newest one. INDEXER_API_POLLING_INTERVAL also defaults to 1s, so the overlap is just as routine on this side.
See the reply on the gasless thread for why I tracked all ticks instead of switching to the existing non-overlapping scheduleSequentialTask.
| } | ||
| } | ||
| } while (!terminalStates.includes(getState())); | ||
| } while (!terminalStates.includes(getState()) && !this.abortController.signal.aborted); |
There was a problem hiding this comment.
Await a launched deposit before honoring abort
For an immediate-fill message, an abort can arrive while willSucceed() is awaiting; when it returns, the code still launches initiateDeposit() into depositReceiptPromise, but this new loop condition then exits before the DEPOSIT_CONFIRM state can await that promise. The enclosing poll tick consequently resolves and waitForDisconnect() permits Redis teardown while the deposit submission continues independently, and a later rejection is unhandled. Recheck the abort signal before launching the deposit or explicitly settle any launched depositReceiptPromise before returning.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and there is a second path you did not name: in the immediate-fill flow the deposit is launched in DEPOSIT_SUBMIT, FILL_PENDING then awaits initiateFill(), and only DEPOSIT_CONFIRM awaits the receipt. An abort landing during the fill leaks the same way. So re-checking the signal before launching is necessary but not sufficient on its own — I did both things you suggested, in 4e3435e:
if (this.abortController.signal.aborted) break;immediately beforeinitiateDeposit(), so no new submission starts once the successor owns the batch.await depositReceiptPromise?.catch(() => undefined)after the loop, still holding the fill lock, so the tick cannot resolve — and let the runner disconnect Redis — mid-submission, regardless of which state the loop exited from. That also closes the pre-existing expiry path, whereexpired()setsERRORafter a launch and nothing ever awaited the promise.void depositReceiptPromise.catch(() => undefined)at launch time for the unhandled rejection specifically: a throw out of the loop (e.g. frominitiateFill) skips the post-loop settle, and on current Node an unhandled rejection takes the process down rather than just warning.
Regression test aborts from inside initiateFill with a gated initiateDeposit, and asserts the tick does not resolve until the deposit settles.
|
|
||
| // Give an in-flight tick a bounded window to observe the abort and settle before the caller | ||
| // disconnects the shared Redis clients out from under it. | ||
| await Promise.race([this.inFlightTick?.catch(() => undefined), delay(this.shutdownDrainSeconds)]); |
There was a problem hiding this comment.
Make the deposit-address tick observe shutdown
Even with only one active poll, this five-second race does not drain it safely because evaluateDepositAddresses() and all of its message-processing paths never inspect abortController.signal. If handover occurs during the indexer request, a transaction confirmation, or a multi-message batch, the timeout wins after five seconds and the runner disconnects Redis while the same tick continues submitting or persisting work alongside the successor. Add abort checks between the query and message processing and before further on-chain work, or keep waiting for the tracked tick instead of assuming it can observe the abort.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
You are right, and the commit message overstated what the diff actually did — it claimed the state machines were made abort-aware "symmetrically" for the DepositAddressHandler, but this half only got the drain. With nothing inspecting the signal, that Promise.race was a 5s timeout, not a drain.
Fixed in 4e3435e. The tick now checks the abort signal after the indexer query and before each of the five sendAndConfirmTransaction sites (v1 deploy + execute, v3 execute, v1 + v3 withdraw), via a small handedOver() helper that also logs at debug. Each guard releases the in-flight lock on the way out (explicitly in v1 initiateDeposit, via the existing finally elsewhere), so the successor instance retries the key rather than finding it stranded.
One detail on placement: per-message checks would not have helped, because forEachAsync is mapAsync/Promise.all — every message in a batch launches in the same tick, so there is no window between them for an abort to land. The pre-submission guards are where a mid-tick handover actually lands, i.e. during the indexer query, the balance read, or the quote/execute/sign API call. And per your last point, waitForDisconnect() now warns when the drain bound expires rather than assuming the tick observed the abort.
Residual, called out rather than fixed here since it predates this PR: forEachAsync is fail-fast, so if one message rejects, evaluateDepositAddresses (and the gasless equivalent) settles while sibling messages are still running, outside the drain. Happy to follow up with allSettled semantics if you want that closed too — it changes when the per-tick error log fires, which is why I left it out of a shutdown-noise fix.
|
Ask again to re-run the unfinished work. |
|
@droplet-rl address codex comments. |
… abort-aware Follow-up to the codex review on #3722, which found three real gaps in the original fix. Both bots poll on a 1s default interval via scheduleTask, which is fixed-rate (setInterval), so a tick that outruns the interval overlaps with its successors -- routine here, since a single message can loop for minutes on delay(1) retries. Assigning each tick to a single inFlightTick field kept only the newest, so waitForDisconnect() could drain a tick that had already finished while an older one was still submitting or confirming transactions. - Add trackInFlight() in utils/Tasks.ts, beside the setInterval that causes the overlap: it tracks every invocation and drains them all under one bound. waitForDisconnect() now warns when that bound expires rather than silently proceeding to disconnect. - GaslessRelayer: DEPOSIT_SUBMIT launches the deposit but only DEPOSIT_CONFIRM settles it, and an abort during either the simulation or the immediate fill exits the loop in between. Re-check the abort before submitting, observe the rejection at launch (an abandoned promise could crash the process), and settle any launched promise after the loop so a tick cannot resolve mid-submission. - DepositAddressHandler: its tick never inspected the abort signal, so the drain was a 5s timeout rather than a drain. Check after the indexer query and before each of the five on-chain submissions, releasing the in-flight lock so the successor instance retries the key. Co-Authored-By: Claude <noreply@anthropic.com>
|
@codex feedback addressed in 4e3435e — all three findings were real, and one of them ("drain all overlapping ticks") was more serious than it looked, since both bots default to a 1s poll interval while a single message can loop for minutes on the 1 & 2 — overlapping ticks ( 3 — deposit launched but never awaited. Codex named the simulation path; there is a second one it missed, in the immediate-fill flow: the deposit is launched in 4 — deposit-address tick was not abort-aware. Correct, and the previous commit message overstated the diff: it claimed symmetry with the gasless state machines, but this half only got the drain, so the 5s race was a timeout rather than a drain. It now checks the signal after the indexer query and before each of the five Tests: new coverage for the overlapping-tick drain, the abort-during-simulation guard, the launched-deposit settle, and the two deposit-address guards; each fails without its fix. Locally 134 passing across One residual I did not fix, flagged on the thread: |
Summary
Testing
|
|
Heads up: this work was already on the branch before that run started, so The PR head is So nothing to port. For anyone diffing the two:
CI is green on The one residual worth a follow-up is still open and unaddressed by either version: |
Problem
The gasless relayer (and deposit-address handler) run as 5-minute serverless spoke invocations that hand over a Redis lock to their successor. On handover,
waitForDisconnect()aborts the poll schedule and the runner immediately callsdisconnectRedisClients()— but an in-flightevaluateApiSignaturestick is not cancelled. Its Redis-backed provider reads then fail with node-redisThe client is closed, which surfaces as "Not enough providers succeeded" (all providers "failed") at error level and pages PagerDuty.Prod evidence from 2026-08-14 (PD-327993,
bots-across-3839/across-spoke-2c-4g):32ddf7f0…takes over froma84cfce3…All 9 error ticks that day landed 4–10s after a 5-min handover boundary; at least 7 identical PD incidents since 2026-08-11, all auto-resolved noise (the successor owns the batch by the time the old tick fails). A busy per-deposit retry loop (
Could not locate deposit on Polygon) keeps ticks long-running, which is why the race window keeps being hit.Fix
Applied to
GaslessRelayerand symmetrically toDepositAddressHandler(identicalscheduleTask/ handover / Redis-teardown pattern):do/whileloop exits at the next loop boundary once the abort signal fires, instead of retrying against closed clients (or spinning forever in thefillLockwait branch, which never throws). A skipped batch is also short-circuited right after the API query.waitForDisconnect()now waits (max 5s) for the in-flight tick to observe the abort and settle before returning, so the runner'sfinally { disconnectRedisClients() }no longer yanks clients out from under it.debug("batch abandoned to successor instance") instead oferror. The error-level "batch skipped this tick" log is unchanged for genuine in-service failures, so real provider outages still page.Tests
New
shutdown / handoversuite intest/GaslessRelayer.ts:waitForDisconnectdrains the in-flight tick before resolvingwaitForDisconnectstill resolves at the drain bound if the tick hangsyarn build,eslint/prettier, and the fulltest/GaslessRelayer.tssuite (55 passing) verified locally.🤖 Generated with Claude Code