chore: KEEP-1291 remove dead fee-escalation code and alert on stuck pending transactions - #2272
chore: KEEP-1291 remove dead fee-escalation code and alert on stuck pending transactions#2272suisuss wants to merge 4 commits into
Conversation
…ending transactions lib/web3/gas-strategy.ts carried a complete same-nonce fee-escalation implementation - RetryConfig, DEFAULT_RETRY_CONFIG, TransactionStuckError, waitForConfirmation, executeWithRetry - that nothing imported. Its only consumer was a test block asserting a config equals itself, a class is a class and a function is a function, so there was no behaviour to preserve. Two comments on pending_transactions advertised the capability as if it existed. They now say what the table and column are actually for, and point at the gauge that replaces the claim. The replacement is keeperhub_web3_pending_transactions_stuck: rows still `pending` more than 15 minutes after submitted_at, by chain_id. Nothing acts on it automatically - a stuck transaction stays a human decision - but the backlog is now visible instead of silent. Counted in SQL alone. Confirming a row is genuinely stuck rather than merely old means comparing its nonce against the chain's, one RPC call per wallet, which is too expensive for a scrape; that version is KEEP-1315. This over-counts rows the reconciler has not yet reaped, the safe direction for an alert.
The stuck-backlog query filters pending_transactions on status and submitted_at with no wallet_address, so idx_pending_tx_status - which leads on wallet_address - cannot serve it. Nothing prunes pending_transactions; there is no DELETE against it anywhere in the codebase, so the query would degrade into a sequential scan growing with lifetime transaction volume, run on every DB-metrics refresh. That is the class of regression metrics-db-review-gate.yml exists to catch. idx_pending_tx_stuck is partial on status = 'pending', so it stays the size of the in-flight set rather than the table, and leads on submitted_at so the age predicate is a bounded range scan. chain_id is included so the GROUP BY is satisfied from the index. Measured on 200k rows, 200 of them pending: Index Only Scan, 0.39 ms, 201 buffers, against a parallel sequential scan at 32.7 ms and 3709 buffers without it. The gap widens linearly as the table grows. The migration carries -- @requires-db-prep and uses CREATE INDEX IF NOT EXISTS: an operator must build it CONCURRENTLY on the target database before merge, since the bare statement drizzle-kit emits takes an ACCESS EXCLUSIVE lock. The exact CONCURRENTLY statement and its validity check are in the migration header.
The comments added with the gauge said there is no same-nonce fee escalation in the codebase. That is too strong: app/api/execute/_lib/retry.ts bumps gas at the same nonce on retry. It is confined to a single direct-execution request and never writes to pending_transactions, so it cannot clear a row that is already stuck - which is the point the comments were making - but the claim as written was wrong and would mislead the next reader.
… bump The previous commit corrected an overclaim in the wrong direction. It said app/api/execute/_lib/retry.ts bumps gas at the same nonce. It does not. gasBumpMultiplier appears only inside retry.ts - the optional field on GasBumpOverrides and the line constructing it - plus a unit test asserting the override reaches the callback. Both call sites in app/api/execute/node/route.ts pass `async () => stepFn(stepInput)` and `async () => (await stepFn(stepInput)) as TransactionResult`; neither declares the overrides parameter, so the multiplier is discarded on every retry. Nothing applies it to maxFeePerGas or maxPriorityFeePerGas. The doc comment at retry.ts:89 describes an intended contract - "the caller is responsible for applying this multiplier" - that no caller honours. So the original claim was right: nothing in the codebase performs same-nonce fee escalation. KEEP-1293 removes the vestigial plumbing entirely, at which point the text corrected here would have described a mechanism that exists in no form.
joelorzet
left a comment
There was a problem hiding this comment.
Four items to correct before merge. One is a defect, three are stale or wrong references.
1. The stuck-transaction gauge cannot recover on its own
lib/metrics/db-metrics.ts:432
The query is bounded only on the young side (submitted_at < now() - 15 minutes). Nothing erases rows from pending_transactions. validateAndReconcile leaves a row in pending when a different RPC endpoint answered. Reconciliation runs only at workflow start, and only for that exact wallet and chain.
As a result, one orphan row from an abandoned wallet holds keeperhub_web3_pending_transactions_stuck{chain_id} at 1 or more forever. The infra rule > 0 for 5m then fires permanently, and no automatic path clears it. The test "drops a chain's series once its backlog clears" passes only because the DB is mocked.
The same accumulation weakens the premise that the index stays the size of the in-flight set.
Add an upper bound to the query, or add a path that resolves rows that the reconciler will never revisit.
2. The schema comment describes code that no longer exists
lib/db/schema-extensions.ts:542
The parenthetical describes GasBumpOverrides, gasBumpMultiplier, and an overrides argument in app/api/execute/_lib/retry.ts. Staging removed all three after the merge base of this branch. The comment is false on merge.
3. The metrics reference repeats the same stale claim
lib/metrics/METRICS_REFERENCE.md:125
4. The migration header cites a file that is not in the repo
drizzle/0150_keep_1291_pending_tx_stuck_index.sql:14
The header cites docs/incidents/2026-05-29-db-cpu-spike.md. This path does not exist. It comes from metrics-db-review-gate.yml.
Checked and correct
- The removed
gas-strategy.tssymbols (RetryConfig,DEFAULT_RETRY_CONFIG,TransactionStuckError,waitForConfirmation,executeWithRetry) have no importers left.app/api/execute/_lib/retry.tsand the tempowaitForConfirmationare unrelated. The removal of thesleepandlogUserErrorimports is correct. - The
Promise.allorder inrefreshDbMetricsNowmatches the destructuring.lt,count, andpendingTransactionsare imported. ThemetricsDbpool is used. - Migration, journal, and snapshot agree. The journal
whenvalue is monotonic.0150_snapshot.prevIdmatches0149_snapshot.id. The snapshot index entry matches the schema declaration. Staging has no schema drift since the merge base. - I ran the DDL against Postgres 16. The qualified partial predicate
WHERE "pending_transactions"."status" = 'pending'deparses to the same index as the unqualifiedCONCURRENTLYform in the runbook, so theIF NOT EXISTSno-op holds. - Partial-index plan risk with
postgres.jsprepared statements: underplan_cache_mode=force_generic_planthe index is unusable and the planner falls back to a parallel sequential scan. With the defaultautosetting Postgres keeps the custom plan and does an index-only scan. This is not a defect, but it is worth knowing. - The test mock map, the
DB_METRICS_CACHE_TTL_MS=0path, and the gaugereset()semantics are all correct.
Process
- The PR title and several new code comments carry Linear ticket IDs. The project CLAUDE.md forbids them in PR titles and in code comments. The existing metrics code already does this, so this is a pattern to stop, not a new mistake.
- Merge staging into the branch before it lands. That merge is also what surfaces items 2 and 3.
Linear: KEEP-1291
What this does
lib/web3/gas-strategy.tscarried a complete same-nonce fee-escalation implementation that nothing called. This removes it, corrects the two places that advertised the capability as if it existed, and adds the visibility that was the actual reason to delete it rather than wire it up.Deleted
From
lib/web3/gas-strategy.ts:RetryConfig,DEFAULT_RETRY_CONFIG,TransactionStuckError,waitForConfirmation,executeWithRetry, plus thesleepandlogUserErrorimports they were the only users of.Verification that nothing imported them: a repo-wide grep for each of the five symbols (excluding
node_modules,.next, and other worktrees) returned matches only insidelib/web3/gas-strategy.tsitself and insidetests/unit/gas-strategy.test.ts.Two names need disambiguating, and neither is touched here:
executeWithRetryinapp/api/execute/_lib/retry.tsis a different, live function with its ownRetryConfigfromapp/api/execute/_lib/types.ts. It is used byapp/api/execute/node/route.tsand covered bytests/unit/retry.test.ts.waitForConfirmationinplugins/tempo/steps/tempo-tx-core.tsis an unrelated boolean parameter.The deleted tests were the
describe("executeWithRetry types and config")block. Its four cases asserted that a config object equals itself, that a class is a class, that an error subclassesError, and that a function has type"function". There was no behaviour to preserve. The remaining 38 tests in that file pass.Corrected
Two comments on
pending_transactionsinlib/db/schema-extensions.tsdescribed automated gas bumping that does not exist: "Detect stuck transactions that may need gas bumping" on the table and "for stuck tx analysis" ongas_price. Both now describe what the table and column are actually for, and point at the gauge below as the only thing that notices a stuck transaction.Added
keeperhub_web3_pending_transactions_stuck, a gauge counting rows inpending_transactionsstillstatus = 'pending'more than 15 minutes aftersubmitted_at, labelledchain_id.Nothing acts on it automatically. Deleting escalation code is only defensible if the condition it claimed to handle stops being invisible, so this gauge is the replacement: a stuck transaction remains a human decision, but the backlog now pages instead of failing silently. The Grafana alert rule lands separately in the infra repo: techops-services/infrastructure#344 (draft), which alerts on
max by (chain_id) (keeperhub_web3_pending_transactions_stuck{cluster="techops-prod", namespace="keeperhub"}) > 0for 5m at warning severity. That rule must not be applied until this PR is merged and deployed and the series is confirmed present in thegrafanacloud-techopsservices-promdatasource, so the two should land in that order.Counted in SQL alone. Establishing that a row is genuinely stuck rather than merely old means comparing its nonce against the chain's current nonce, which is one RPC call per wallet and too expensive for a scrape path; that version is KEEP-1315. This query therefore over-counts rows the reconciler has not yet reaped, which is the safe direction for an alert.
Where it is emitted, and why not the cron route
It is a DB-sourced gauge declared on
dbRegistryinlib/metrics/collectors/prometheus.tsand set insiderefreshDbMetricsNow(), the scrape-time refresh, from a newgetStuckPendingTransactionCountsFromDb()inlib/metrics/db-metrics.ts. That is the same registry and the same function askeeperhub_workflow_queue_depth, which has live series in prod.The alternative considered was emitting it from the
execution-reconcilercron route viasetGauge. That does not work here. Prometheus gauges are process-local; the app runsreplicaCount: 4in prod, and the CronJob hits one arbitrary pod per run. Three of four pods would report nothing while the fourth held a value that never refreshed, somax by (chain_id)would latch high forever andsumwould multiply.METRICS_REFERENCE.mdalready codifies the rule this would break: DB-sourced gauges must report identical values from every pod. The DB-sourced path satisfies it because every scrape recomputes from the same database.Consequently there is no
MetricNamesentry.MetricNamesfeedssetGauge, which this gauge does not use, and DB-sourced gauges such askeeperhub_executions_unconfirmedhave no entry either. Adding one would create exactly the kind of unreferenced export this PR removes.Verification
tsc --noEmit: clean.biome checkon all changed files: clean.vitest runover the 9 affected unit files: 153 passed.tests/unit/db-metrics-cache.test.tscover the gauge end to end through the real prom-client registry, mocking only the DB query: one series per chain, series dropped once a chain's backlog clears (so the alert can recover), and last value held when the query returns null (so a query error does not report a misleading 0).getStuckPendingTransactionCountsFromDbwas also added to that file's default mock map, which everyupdateDbMetricstest depends on.Rendering the registry confirms the exposition is what the infra alert expects:
Note for reviewers
keeperhub_executions_unconfirmedcurrently has zero series in prod. That is not a broken emitter:executionsUnconfirmed.set()is called inrefreshDbMetricsNow()alongside the gauge added here. It was added yesterday in d4552a3 (KEEP-1282) and has not reached prod yet. The two gauges share a code path, so both should appear on the same deploy.Index for the new query (second commit)
metrics-db-review-gatecorrectly flagged the first commit. The gauge query filterspending_transactionsonstatusandsubmitted_atwith nowallet_address, soidx_pending_tx_status- which leads onwallet_address- cannot serve it, and nothing prunes the table: there is noDELETEagainstpending_transactionsanywhere in the codebase. Left alone the query would be a sequential scan growing with lifetime transaction volume, executed on every DB-metrics refresh. That is the regression class the gate exists to catch.drizzle/0150_keep_1291_pending_tx_stuck_index.sqladdsidx_pending_tx_stuck, partial onstatus = 'pending'so it stays the size of the in-flight set rather than the table, leading onsubmitted_atso the age predicate is a bounded range scan, withchain_idincluded so theGROUP BYis satisfied from the index.Measured on Postgres 16 with 200k rows, 200 of them pending:
idx_pending_tx_stuckThe gap widens as the table grows, which it does monotonically.
drizzle-kit generateproduced the migration and snapshot cleanly, and a follow-upgeneratereports no drift. The migration was applied against a real Postgres 16 to confirm it runs.Two gates need a human
db-prep-checkis red by design. The migration carries-- @requires-db-prepon line 1 and usesCREATE INDEX IF NOT EXISTS, because the bare statement drizzle-kit emits takes anACCESS EXCLUSIVElock. Before merge an operator must build the index without holding a write lock and then applydb-prepped-staging. The exact statement and its validity check are in the migration header.metrics-db-review-gateneeds themetrics-db-reviewedlabel. The evidence for that attestation is the measurement above; I was not able to apply the label myself.