fix(autoclaim): don't let a permanently disabled source pin the l2-to-lx block cursor - #1882
Conversation
…-lx cursor L2ToLx keeps one shared L1 block-window cursor across every source network. processSource returned sourceRetryLater for any fetcher.GetURL error, and PollOnce then held that shared cursor just before the earliest skipped source's verify row -- or, when that row sat at the very start of the window, returned without saving the cursor at all, re-scanning the identical window on the next poll. bridgeservicefinder.GetURL returns ErrNetworkDisabled for every network listed in Config.IgnoreNetworkIDs. That error is a deliberate, permanent exclusion: it can never clear. So one ignored network with a VerifyBatches row anywhere in the scanned range wedged the shared cursor forever and no L2-origin claim request could be created for any source. Observed in a deployment whose ignore list covers most registered rollups: the only "skip source" line ever emitted named an ignored network, and the request store held L1-origin requests exclusively. Treat a permanently excluded source as "nothing to do" (sourceUpToDate) instead of "retry later": it no longer contributes to retryBlock and no longer holds the window back. Its own (source, destination) LER cursors are still left untouched, so removing the network from the ignore list later loses nothing -- its next verify row is fetched from the old pair cursor. A genuinely retryable GetURL failure (service not yet announced or unhealthy) keeps its existing hold-the-window behaviour. ErrNetworkDisabled wraps ErrURLNotFound, so ServiceFetcher.GetURL re-wraps it through the not-found branch; the sentinel survives that (double %w) and stays detectable with errors.Is. A test pins that, so a refactor of the mapping cannot silently reintroduce the deadlock. Also report an excluded source once per source instead of on every poll: the exclusion never clears and the detector polls for the process lifetime, so the repetition carried no information. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJuYNKE9jRLRrCWTrrEJ2T
|
Claude finished @arnaubennassar's task in 2m 45s —— View job Review: PR #1882 — disabled source no longer pins the
|
…in the fetcher contract Review follow-up. The fix itself is unchanged: a permanently excluded source still counts as "nothing to do" and still lets the shared block-window cursor advance, and a genuinely retryable GetURL failure still holds the window just before its verify row. The detector was reaching into bridgeservicefinder for the sentinel that told the two apart, which only worked because ErrNetworkDisabled happens to wrap ErrURLNotFound. ClaimCandidatesFetcher exists so the detector talks in detector-local errors and so a non-finder-backed fetcher can express the same thing, so: - add a detector-local ErrSourceDisabled, the permanent counterpart of ErrURLNotFound; - map the finder's disabled error to it in ServiceFetcher.GetURL -- the designated translation point -- checked before the not-found branch it wraps, and still wrapping ErrURLNotFound since an excluded source is also a source with no URL; - branch processSource on the local sentinel and drop the bridgeservicefinder import; - document both sentinels, and what the detector does with each, on the ClaimCandidatesFetcher.GetURL contract. The whole bug was a permanence distinction the contract never expressed, so it belongs written down there. Also from the review: - SkippedSourceCount's doc still claimed every skipped source has its block-window position held back; a permanently excluded source is counted there and does not. - narrow the re-enabling note: leaving the pair LER cursors untouched covers everything bridged in between, but recovery needs the source to publish another verify row, since the window only rewinds by overlapBlocks and never returns to the row observed while the source was excluded. Records the operational consequence: to re-enable a source that has stopped producing verified batches, reset the block-window cursor. - tighten the once-per-source log expectation, which matched any single-argument Infof, to the format text plus the source id. - drop the unreachable lazy init in logDisabledSourceOnce; the constructor always builds the map, and the guard only obscured that it has one owner. NewLERSourceCount still counts an excluded source on every poll. Left as is: the field is defined as "at least one pair whose cursor differs from the source's newest LER", which is true for it, and equally true for the retryable-skip path, so moving the increment would make the two skip paths disagree for no consumer's benefit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJuYNKE9jRLRrCWTrrEJ2T
|
Automated review addressed in 66b8848. The fix's behaviour is unchanged — a permanently excluded source still lets the shared block-window cursor advance, and a genuinely retryable Applied:
Declined:
One correction: the review's framing of the fetcher test as "incidental" was right as a description of the old code but the double-
|
This PR collects a small set of operational fixes found while running `autoclaim` in a production deployment. It started as a single fix for `EthTxManager` defaults and has grown a second, unrelated fix (start-block defaulting) found during the same operating window — each small enough on its own not to warrant a separate PR, but included here explicitly so reviewers know why this is multi-part. ## 1. Apply EthTxManager defaults to claimers - Add `Config.ApplyDefaults()` in `autoclaim/config/config.go`, called once in `config/config.go` right after config unmarshalling and before `AutoClaim.Validate()`. It fills each `[[AutoClaim.Claimers]]` entry's `EthTxManager` fields that were left at their Go zero value with aggkit's own established `AggOracle.EVMSender.EthTxManager` opinion (`config/default.go`), and never touches a value the operator explicitly set. - Fix a key mismatch in `config/default.go`'s `[AggOracle.EVMSender.EthTxManager]` block: it rendered TOML keys `GetReceiptMaxTime` / `GetReceiptWaitInterval`, but `ethtxmanager.Config`'s mapstructure tags are `WaitReceiptMaxTime` / `WaitReceiptCheckInterval` ([`zkevm-ethtx-manager@v0.2.18/ethtxmanager/config.go:19,22`](https://github.com/0xPolygon/zkevm-ethtx-manager/blob/v0.2.18/ethtxmanager/config.go#L19-L22)), so those two AggOracle defaults were silently never binding. Verified against the vendored module source before including it. ### Why TOML array-of-tables elements (like `[[AutoClaim.Claimers]]`) get no per-element defaults from the config loader, and `config/default.go`'s `[AutoClaim]` section stops at `BridgeServiceFinder` — it defines no `[[AutoClaim.Claimers]]` at all. So any claimer field an operator does not fully spell out starts from its Go zero value. Two of those zero values are actively dangerous in `github.com/0xPolygon/zkevm-ethtx-manager@v0.2.18/ethtxmanager`: - `FrequencyToMonitorTxs=0` → `time.After(0)` fires immediately, spinning the tx-monitor loop with no delay ([`ethtxmanager.go:485`](https://github.com/0xPolygon/zkevm-ethtx-manager/blob/v0.2.18/ethtxmanager/ethtxmanager.go#L485)). - `GasPriceMarginFactor=0` → the suggested gas price is multiplied by zero, so every transaction is rejected as `transaction underpriced` ([`ethtxmanager.go:989-991`](https://github.com/0xPolygon/zkevm-ethtx-manager/blob/v0.2.18/ethtxmanager/ethtxmanager.go#L989-L991)). Both were hit back-to-back in a production deployment: a claimer without an explicit `EthTxManager` block first submitted underpriced transactions, then — once that surfaced and `GasPriceMarginFactor` alone was patched at the deploy-config layer — the *same* already-wedged monitored transaction kept retrying in the busy-loop, because re-pricing an existing transaction is only ever applied to a transaction that reached `Sent` status, and an underpriced-at-creation transaction never gets there. The result was **~1,500-1,600 log lines/second sustained for roughly 4 days, on the order of 100 million log lines**, from a pod Kubernetes reported as perfectly healthy the entire time (no crash, no restart, probes green) — nothing in standard pod-health monitoring would have caught it. ### What is defaulted, and why those values `Config.ApplyDefaults()` mirrors aggkit's own `AggOracle.EVMSender.EthTxManager` values (`config/default.go`), so every `EthTxManager` instance in the binary behaves the same way absent explicit operator config: | Field | Default | Rationale | |---|---|---| | `FrequencyToMonitorTxs` | `1s` | matches AggOracle; prevents the zero-delay busy-loop | | `WaitTxToBeMined` | `2s` | matches AggOracle | | `GetReceiptMaxTime` (`WaitReceiptMaxTime`) | `250ms` | matches AggOracle (now that the key is fixed) | | `GetReceiptWaitInterval` (`WaitReceiptCheckInterval`) | `1s` | matches AggOracle | | `GasPriceMarginFactor` | `1` (any value `<= 0` is coerced to `1`) | a zero-or-negative factor is never a legitimate request — it means "never send"; `1` is a neutral no-margin multiplier | | `SafeStatusL1NumberOfBlocks` | `5` | matches AggOracle | | `FinalizedStatusL1NumberOfBlocks` | `10` | matches AggOracle | | `EstimateGasMaxRetries` | `1` | matches AggOracle | A note on the last three: the vendored module documents `0` as a *meaningful* sentinel for `SafeStatusL1NumberOfBlocks`/`FinalizedStatusL1NumberOfBlocks` ("use the network's own safe/finalized tag") and for `EstimateGasMaxRetries` ("retry forever"). These aren't zero-values that are unconditionally broken the way the durations and gas margin are. But aggkit's own `AggOracle` path already overrides all three of those module sentinels with a fixed opinion, so this PR mirrors that same override for claimers, for consistency across every `EthTxManager` instance in the binary — rather than leaving claimers as the only place that silently falls through to the vendored zero-value behavior. I considered a validation error instead of silent defaulting for some of these fields, but rejected it: unlike `StoragePath` (already required by `ClaimerConfig.Validate`), none of these fields have an "obviously correct" value an operator must supply — they're tuning knobs with the project's own established defaults, and requiring every deployment to spell out all eight of them just to avoid a footgun would only reproduce the same gap the next time a field is added to the vendored config. Defaulting to the project's own already-established opinion is safer than either silently zero-valuing or hard-failing. ### Testing (fix 1) - `go build ./...` — passes. - `go test ./autoclaim/... ./config/...` — passes (existing suite unaffected; added `TestApplyDefaultsFillsUnsetEthTxManagerFields`, `TestApplyDefaultsPreservesExplicitlySetEthTxManagerFields`, `TestApplyDefaultsCoercesNonPositiveGasPriceMarginFactor` in `autoclaim/config/config_test.go`). ## 2. Default bridge-detector start blocks to a recent lookback, not genesis ### Evidence Both bridge detectors' start-block config fields — `L1ToL2BridgeDetector.StartBlock` and `L2ToLxBridgeDetector.StartL1Block` (`autoclaim/config/config.go`, ~:67 and ~:81) — were plain `uint64`, so `0` was indistinguishable from "unset", and `0` means scan from L1 genesis. This is the same zero-value-means-something-dangerous class as the `GasPriceMarginFactor` fix in section 1. In the same production deployment: a rollout that omitted these scanned **~6.9M Sepolia blocks**, taking ~19h, **twice** (once after a datadir wipe), and would have mass-enqueued years of stale deposits the moment `DryRun` was lifted. The workaround was hand-pinning a wall-clock-derived magic number (head minus 7 days) with a "re-pin if the deploy slips past date X" comment, which goes stale by construction. On the L2-to-Lx side, if the resolved LER was zero or the configured block predated the first L1 info tree leaf, `initialFromLER` (`autoclaim/bridgedetector/l2_to_lx.go`, ~:592) **silently fell back to full history** — invisible, and with no in-product recovery for bridges that fall below a detector's start block (they are permanently unclaimable; the admin API only acts on already-detected requests). ### Fix Both fields become `*uint64`: `nil` (the field absent from config) means "resolve automatically", an explicit value — including `0` — is used verbatim and never adjusted, which is what makes this change backwards compatible for every deployment that already pins a start block. A new `StartLookback cfgtypes.Duration` field (default `24h`, applied in `ApplyDefaults()`) controls how far back an unset value resolves from. `mapstructure`/viper in this codebase decode `*uint64` correctly out of the box — verified directly against this repo's own `viper`/`mapstructure` versions before committing to the pointer approach (absent key → `nil`; `= 0` → pointer to `0`; `= 42` → pointer to `42`) — so no sentinel value or companion `isSet` field was needed. **Resolution** (`autoclaim/bridgedetector/startblock.go`, `ResolveStartBlock`): when unset, it binary-searches L1 block headers (`EthClienter.CustomHeaderByNumber`) for the highest block at or before `now - StartLookback` — about `log2(head-minBlock)` header lookups, not a linear scan — then clamps the result to `[minBlock, head]`. `minBlock` is the L1 bridge sync's own configured genesis block for `L1ToL2BridgeDetector` (`BridgeL1Sync.InitialBlockNum`) and the RollupManager contract's creation block for `L2ToLxBridgeDetector` (`L1NetworkConfig.RollupManagerCreationBlock`, which config validation already guarantees is `> 0`) — a detector can never usefully start earlier than the data source it reads from or the contract whose events it needs. It deliberately does **not** estimate the block number from an average block-time constant: an orchestration tool used elsewhere in this operating window assumed 12.0s/block on Sepolia when the real figure was 12.512s, which skewed a multi-hour ETA by 14 hours — reading real timestamps back from the chain avoids that class of error entirely. `StartLookback` defaults to `24h`: a bridge only becomes claimable after certificate settlement *and* destination GER injection, which has been observed in production to take **over an hour**, so a short window risks leaving valid bridges permanently unclaimable, while 24h of Sepolia is only ~7,000 blocks versus the ~6.9M actually backfilled by the genesis default. The resolution runs once at startup per enabled detector and logs an `INFO` naming the lookback, the resolved block and its timestamp, plus a `WARN` that bridges originating before that block will never be autoclaimed and need manual claiming. **`config/default.go`** previously baked `StartBlock = 0` / `StartL1Block = 0` into the rendered config template for every deployment. Those lines are removed (not set to a placeholder — simply absent), since leaving them in would have made every existing deployment "explicit genesis" forever and defeated this fix entirely; the Go-side `ApplyDefaults()` is now the only place `StartLookback` gets a default, matching how the EthTxManager defaults in section 1 are applied. ### Silent-fallback decision: WARN + per-source retry, not a hard error `initialFromLER`'s two silent-fallback branches (leaf-not-found, zero LER) now return a new `ErrCannotResolveInitialLER` sentinel instead of `nil, nil` — but **only** when the configured start block is non-zero; `StartL1Block = 0` remains an explicit, honored request for full history, and since automatic resolution is always clamped to a strictly-positive `minBlock`, a literal `0` can only ever be deliberate operator intent, never a resolution artifact. I initially made this a hard error, but `initialFromLER`'s caller (`resolveFromLER` → `processSource`) propagates any error all the way up through `PollOnce`, which aborts the **entire** poll — meaning one source with a bad `StartL1Block` relative to its history would silently stall *every other source's* discovery too, until an operator intervened. That is too large a blast radius for a per-source config problem, and risks the same "wedged and silently retrying" pattern the `EthTxManager` fix in section 1 exists to prevent. Instead, `processSource` now catches `ErrCannotResolveInitialLER` specifically and treats it like the existing finder-miss/not-synced-yet cases: it logs a prominent `WARN` naming the source and remediation ("lower `StartL1Block`, or set it to `0`"), and returns `sourceRetryLater` — that source alone is retried every poll until fixed; every other source in the same poll is unaffected. ### Testing (fix 2) - `go build ./...` — passes. - `go test ./autoclaim/... ./config/... ./cmd/...` — passes. Added `autoclaim/bridgedetector/startblock_test.go` (`TestResolveStartBlock*`: explicit value/explicit-zero used verbatim with zero RPC calls, unset resolves from lookback, clamping at both the head and the configured floor, L1-client error propagation); updated the two existing `l2_to_lx_test.go` silent-fallback tests to cover the explicit-genesis path, and added their non-explicit-genesis counterparts asserting the new retry-later behavior (no candidates fetched, poll still succeeds, `SkippedSourceCount` increments); updated `config/config_test.go` and `autoclaim/runtime/runtime_test.go` fixtures for the pointer field. - `gofmt -l` — clean. - `go vet ./...` (whole repo, including test files) — clean. - `golangci-lint run ./autoclaim/... ./config/... ./cmd/...` (v2.4.0) — 0 issues (one `mnd` finding on the binary-search midpoint calculation addressed with `//nolint:mnd`, matching the existing convention in `types/block_finality.go`). ### Also updates `docs/autoclaim.md` The default-value callouts and config-reference table rows for `StartBlock`/`StartL1Block` described the old "default `0`" behavior directly; left as-is they would have actively misled operators about the very fields this fix changes, so they are updated in place alongside two new `StartLookback` rows. No other content in that file changed. ### Out of scope (follow-ups, not implemented here) - A Prometheus gauge for the resolved start block. - Refusing to start when the DB is empty but the chain has history beyond the configured/resolved window (the datadir-wipe gap hazard from section 1's incident — a real problem, but too invasive for this PR). ## 🐞 Issues Cross-references [#1874](#1874) and [0xPolygon/zkevm-ethtx-manager#139](0xPolygon/zkevm-ethtx-manager#139) (does not close either). This PR removes the *cause* (an unconfigured `EthTxManager` reaching zero values), but the deadlock filed as zkevm-ethtx-manager#139 remains a separate, module-level defect: an underpriced-at-creation transaction can never be re-priced, because re-pricing is gated on `MonitoredTxStatusSent`, a status that transaction never reaches. A wedged claimer created before this fix ships still needs the operational recovery described there; this PR only prevents new claimers from getting into that state. ## 📝 Notes - Kept the diff to the config and logging layers only — no vendored `zkevm-ethtx-manager` changes. - **Changelog-worthy behavior change:** a deployment that previously *omitted* `L1ToL2BridgeDetector.StartBlock` / `L2ToLxBridgeDetector.StartL1Block` will now resolve a recent block via `StartLookback` (default 24h) instead of scanning from genesis. A deployment that already sets either field explicitly, to any value including `0`, is unaffected. - Section 2 (start-block defaulting) is meaningfully larger than section 1 — it adds a new resolution algorithm and a config-template default, not just a defaulting pass. The owner chose to include it here as a second small operational fix from the same deployment window, but it is the one most defensible as its own PR if reviewers would rather split it out. - The `IgnoreNetworkIDs` skip-source logging change that was previously part of this PR has been dropped from here. It is now handled in [#1882](#1882), together with the underlying `l2-to-lx` cursor fix that log line is the visible symptom of — changing the log level alone would have hidden the evidence while leaving the bug in place. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DJuYNKE9jRLRrCWTrrEJ2T --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
🔄 Changes Summary
L2ToLxdetector's shared L1 block-window cursor, which previously stopped all L2-origin claim discovery.autoclaim/bridgedetector/l2_to_lx.gokeeps one block-window cursor (l2-to-lx) shared by every source network.processSourcereturnedsourceRetryLaterfor anyfetcher.GetURLerror, andPollOncethen held that shared cursor just before the earliest skipped source's verify row — or, when that row sat at the very start of the window,return result, nilwithout saving the cursor at all, re-scanning the identical window on the next poll.bridgeservicefinder.GetURLreturnsErrNetworkDisabledfor every network listed inConfig.IgnoreNetworkIDs. That error is a deliberate, permanent exclusion — it can never clear. So a single ignored network with aVerifyBatchesrow anywhere in the scanned range wedged the shared cursor forever, and no L2-origin claim request could ever be created for any source.Observed in a deployment whose ignore list covers most registered rollups: over 8 hours the only
skip sourceline emitted named an ignored network, and the request store contained L1-origin requests exclusively — not one L2-origin request.The fix: a permanently excluded source is
sourceUpToDate("nothing to do") instead ofsourceRetryLater. It no longer contributes toretryBlockand no longer holds the window back. Its own(source, destination)LER cursors are still left untouched, so when the network is later un-ignored, its next verify row is fetched from the old pair cursor and covers everything bridged in between. That recovery does depend on the source publishing another verify row: the shared window has already moved past the row seen while the source was excluded and only rewinds byoverlapBlocks(default 1), so it never returns to it. To un-ignore a source that has stopped producing verified batches, reset thel2-to-lxblock-window cursor so its old verify row falls inside the scanned range again. The trade is deliberate — the old behaviour bought that re-observability at the cost of deadlocking every source. A genuinely retryableGetURLfailure (service not yet announced, or unhealthy) keeps its existing hold-the-window behaviour, unchanged.Two smaller points:
bridgeservicefinderby the detector.ClaimCandidatesFetchergains a detector-localErrSourceDisabled, the permanent counterpart ofErrURLNotFound;ServiceFetcher.GetURL— the designated translation point — maps the finder's disabled error to it, checked before the not-found branch that error wraps, and still wrappingErrURLNotFoundsince an excluded source is also a source with no URL.processSourcebranches on the local sentinel and no longer importsbridgeservicefinder, and both sentinels are documented on theGetURLcontract: the whole bug was a permanence distinction the contract never expressed. A test pins the mapping, so a future refactor cannot silently reintroduce the deadlock, and any non-finder-backed fetcher can now express the same thing.The doc comments on
sourceOutcome,PollOnceandprocessSourcesaid "transient" where the code accepted any error; they are updated to match the new semantics.📋 Config Updates
🔌 API Updates
✅ Testing
autoclaim/bridgedetector:TestL2ToLxDisabledSourceDoesNotHoldBlockCursor— an excluded source does not hold the cursor back; the window advances past its verify row.TestL2ToLxTransientURLErrorStillHoldsBlockCursor— a retryable URL miss still holds the cursor (guards against regressing the existing behaviour).TestL2ToLxDisabledSourceDoesNotStarveHealthySource— mixed window: one excluded source plus one healthy source at a later block; the healthy source processes and the cursor advances.TestL2ToLxDisabledSourceAtWindowStartDoesNotPinCursor— the production regression: an excluded source's verify row at the very start of the window must not take the "return without saving the cursor" path.TestL2ToLxDisabledSourceIsReportedOncePerSource— the report is emitted once, not per poll.TestServiceFetcherGetURL/network_disabled_maps_to_the_permanent_sentinel— pins that the finder's disabled error arrives at the detector asErrSourceDisabled, never as a plain retryableErrURLNotFound, including the branch ordering that makes it so.l2_to_lx.goreverted to its pre-fix state, 4 of the 5 behavioural tests fail:TestL2ToLxNotSyncedSkipsSourceWithoutAdvancingCursor,TestL2ToLxRetriesSkippedSourceOnLaterPollWithoutNewLER,TestL2ToLxRetrySkipAtWindowStartKeepsStoredCursor).go test ./autoclaim/... ./bridgeservicefinder/...— all packagesok.golangci-lint run --timeout 5m—0 issues.🐞 Issues
🔗 Related PRs
autoclaim/bridgedetector/l2_to_lx.go(and its test file) in the sameprocessSourceGetURLerror branch, so the two will conflict textually on merge. The changes are logically independent: fix(autoclaim): EthTxManager defaults and start-block defaulting #1875 only lowers the log level of the skip line, this PR changes the control flow so the shared cursor is not held. This PR is based directly ondevelopand contains nothing from fix(autoclaim): EthTxManager defaults and start-block defaulting #1875.📝 Notes
(source, destination)LER cursor is what bounds the actual candidate fetch. Leaving it untouched means that if the operator later un-ignores the network, its next verify row is fetched from the old pair cursor and still covers every bridge exit made while it was excluded — even though the block window has long since moved past. The converse is the operational caveat above: a re-enabled source that never publishes another verify row is never re-examined, because the window does not rewind to the row it already passed. Resetting the block-window cursor is the recovery for that case.map[uint32]struct{}on the detector, touched only from the single poll goroutine — deliberately not a general log-dedup mechanism.🤖 Generated with Claude Code
https://claude.ai/code/session_01DJuYNKE9jRLRrCWTrrEJ2T