Skip to content

fix(mempool): mempool-owned branched context for admission + recheck - #2159

Draft
JayT106 wants to merge 13 commits into
mainfrom
mempool/branched-recheck-context
Draft

fix(mempool): mempool-owned branched context for admission + recheck#2159
JayT106 wants to merge 13 commits into
mainfrom
mempool/branched-recheck-context

Conversation

@JayT106

@JayT106 JayT106 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What

Gives the app-side mempool (mempool.type=app) its own working state instead of sharing baseapp's checkState between admission and recheck. Touches app/mempool/ (new mempoolState, txExec/admitter/recheckScheduler split) and app/app.go (refresh-at-Commit wiring, ante nonce-cache eviction hook).

Issue

Recheck used checkState and released the lock between candidates, so concurrent InsertTx/CheckTx interleaved with recheck's own writes into that state, making evictions timing-dependent (#2109). All three RunTx call sites (admit, CheckTxHandler, runRecheck) now run against a CacheMultiStore branched off the committed store and owned by the mempool, refreshed under the admission mutex right after each Commit.

Determinism during recheck is handled separately: candidates are grouped per signer and run in bounded chunks, so a signer's nonce chain stays contiguous and the mutex hold per chunk stays short regardless of queue depth.

Solution

  • mempoolState: mempool-owned CacheMultiStore, refreshed at Commit, nil-safe fallback to checkState.
  • Manager split into txExec (mutex + state + codecs), admitter (admission), recheckScheduler (staging/grouping/recheck).
  • Per-signer grouping with a group-boundary soft cap (maxRecheckBatch) — never splits a sender's nonce chain across a cycle.
  • Chunked execution (recheckChunkSize) so one oversized sender's queue can't stall Commit.
  • Cascade eviction on a proven nonce gap, guarded against multi-signer/unordered/duplicate-seq groups where the gap rule doesn't hold; a cross-chunk cascade re-verifies its chunk's head with one RunTx before blind-evicting, since the lock releases between chunks.
  • Eviction hook so a cascade/TTL eviction (which never spends a RunTx) also drops the matching entry in ethermint's per-(sender, nonce) ante cache — otherwise a stale entry lets a resubmit skip nonce verification. Fires once per signer named by the evicted tx, not just the pool's key signer.
  • app/proposal_diff_test.go differentially tests the fast PrepareProposal path against the default full-ante path.

Test

go test -tags objstore -mod=mod ./app/... -race -count=1 (both app and app/mempool), golangci-lint run clean, go build -tags objstore -mod=mod ./app/... clean. New/updated tests cover: nonce continuity across the branched store, generation-based cancellation on a superseded pass, group-boundary capping without splitting a sender, chunked cascade eviction and its cross-chunk re-verification, multi-signer/unordered cascade guards, and eviction-hook firing (including the multi-signer case).

Design notes: docs/architecture/mempool-branched-recheck-context.md.

JayT106 added 11 commits July 29, 2026 20:17
…context

Admission and recheck shared baseapp's checkState as the pending-nonce store.
Give the app mempool its own CacheMultiStore, branched off the committed store
and refreshed inside the existing admission-mutex span at Commit, and pass it as
RunTx's txMultiStore at all three call sites (admit, CheckTxHandler, runRecheck).
All three must move together: the branch is the sole nonce authority, so a split
would leave one path reading state reset at every Commit.

A generation counter lets an in-flight recheck pass abandon candidates validated
against a superseded branch; the unreached candidates' senders are re-merged into
staging so the next pass re-covers them.
…gap evictions

runRecheck took stateMu once per candidate, so an admission could land between
two txs of the same sender and make evictions timing-dependent. Bucket candidates
by the signer the mempool orders by and take stateMu once per group: a sender's
nonce chain now advances atomically against other senders' admissions, while the
hold time stays bounded by that sender's queue depth instead of the whole batch.
Encoding moves out of the lock, and the generation check now runs under stateMu
before each group, so a group is never split mid-flight.

On a nonce failure, evict the remaining higher-nonce siblings without a RunTx
each. Only when the gap is provable: an earlier tx in the group passed this pass,
so lastOK+1 is the expected nonce, and the failing nonce is strictly above it.
A wrong-sequence failure can also mean a stale (already committed) nonce, whose
successor may be valid — cascading there would evict good txs. Disabled for any
group that isn't the signer's contiguous ascending view.
Manager grew into one struct holding admission, recheck staging, selection,
and the shared execution state. Split it along the boundary the branched
context made explicit:

- exec.go: txExec owns the admission mutex, mempoolState, the generation
  counter, and the codecs. Both halves run txs through it, so this state
  belongs to neither alone.
- admitter.go: admit, InsertTx/CheckTx handlers, cacheTx.
- scheduler.go: sender staging, candidate selection, TTL/timeout eviction,
  recheck grouping, and the async worker.

Manager is now a facade over the three, so app.go and the proposal handler
call sites are unchanged. Lock order is unchanged: recheckMu > txExec.mu >
stagingMu, with mempoolState.mu innermost.
The fast path (mempool.type=app with the encoder cache) trusts admission and
recheck, so it only encodes each pooled tx instead of re-running the ante like
the default handler does. Nothing captured what that buys or costs.

Run both handlers over identically seeded pools and assert the boundary:

- all-valid pool and a same-sender nonce gap: identical selections and pools,
  since the gap guard lives in the shared DefaultProposalHandler sequence
  tracking.
- stale nonce, recheck backlog, timeout height: the fast path proposes txs the
  ante rejects and leaves them pooled for recheck instead of evicting them
  mid-proposal.
- baseFee drift: selections match because the proposal gate replaces the ante's
  fee check; only the pool differs, as a gated tx stays pooled.

Each divergent case also runs the real ProcessProposal over the fast path's
proposal with a non-empty blocklist and asserts ACCEPT: cronos ProcessProposal
is blocklist-only, so an ante-invalid tx cannot make peers reject the block.

Pooled txs are a local diffTx carrying its own signer, nonce, fee, gas, and
timeout, so no account keeper or real codec is needed.
Sort recheck groups ascending by seq and disable cascade for co-signers of a
multi-signer tx, whose nonces the keyed group cannot see.
The flat batch cap could hand a sender's higher-nonce txs to a later cycle
without their prefix, so they failed wrong-sequence against a freshly
rebranched base and were evicted while valid. Cap whole groups instead, run
each group in bounded chunks so a deep queue can't stall Commit, and read the
generation counter after the pool scan rather than before it.
The deferred carry is keyed on tx identity, so a fee bump replacing a carried
tx at the same nonce dropped it from the next cycle's group and took the live
tail down as a false wrong-sequence failure; carry the senders too. Also keep
cascade eviction inside the chunked mutex hold, and tighten the recheck test
runner to reject stale nonces like the real ante does.
cascadeChunkLocked now spends one RunTx on a chunk's head before blind-
evicting the rest, since the lock releases between chunks and a same-
sender admission can fill a gap proven in an earlier chunk. Also guard
unordered txs out of cascadable grouping, and carry unreached senders
into deferred on a gen-abort so a low-priority tail can't be starved by
sustained aborts.
Both eviction paths remove a tx from the pool without spending a RunTx,
so the EVM ante's per-(sender, nonce) admission cache never learns the
slot is free and skips nonce verification on a resubmit at that nonce.
Add an eviction hook the scheduler fires with (sender, nonce) on every
eviction; wire it to the ante cache's Delete in app.go.

Also: cascadeChunkLocked no longer assumes any head failure proves the
gap survived — only a nonce error does; other failures (e.g. funds)
fall through to per-candidate rechecking for the rest of the chunk.
…ames

evict fired the ante nonce-cache hook only for the group's key signer.
A multi-MsgEthereumTx tx stages one ante-cache entry per msg, so a
second-and-later signer's entry leaked on cascade/TTL eviction the same
way round 5 fixed for the key signer. evict now enumerates all signers
via GetSigners when the evicted tx is multi-signer.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6f58cb95-3639-41b5-af65-952ce45ad586

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the adr label Jul 30, 2026
Comment thread app/mempool/scheduler.go
Comment on lines +98 to +100
for sg := range senders {
s.recheckSenders[sg] = struct{}{}
}
Comment thread app/mempool/scheduler.go
// Pass 1: evictions. Collect senders of evicted txs so their remaining pool txs
// (e.g. higher-nonce siblings) are rechecked — they become invalid after the gap.
var evictedSet map[sdk.Tx]struct{} // nil until first eviction; nil-map read is safe
now := time.Now()
Comment thread app/mempool/scheduler.go
// nonce state, so run the rest of the chunk one RunTx at a time instead
// of assuming the gap held.
evicted, cascaded, next, gapFound = s.runCandidatesLocked(g, start+1, end, nonceCursor{})
return evicted + 1, cascaded, next, gapFound, true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants