From 63ead1e89428c269269efb98af3e0e7a802b3dec Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Wed, 29 Jul 2026 20:17:35 -0400 Subject: [PATCH 01/12] feat(mempool): run recheck and admission on a mempool-owned branched 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. --- app/app.go | 19 ++- app/mempool/manager.go | 125 +++++++++++--- app/mempool/manager_test.go | 264 ++++++++++++++++++++++++++---- app/mempool/recheck_async_test.go | 10 ++ app/mempool/recheck_test.go | 93 +++++++++++ app/mempool/state.go | 44 +++++ app/mempool/state_test.go | 67 ++++++++ 7 files changed, 567 insertions(+), 55 deletions(-) create mode 100644 app/mempool/state.go create mode 100644 app/mempool/state_test.go diff --git a/app/app.go b/app/app.go index a531c8face..f6bcf7efa0 100644 --- a/app/app.go +++ b/app/app.go @@ -1202,6 +1202,16 @@ func New( tmos.Exit(err.Error()) } + if app.mempoolManager != nil { + // Earliest correct point for the first mempoolState refresh: stores are + // now loaded, so the branch it takes sees committed state instead of an + // empty pre-load tree. + mu := app.mempoolManager.AdmissionMutex() + mu.Lock() + app.mempoolManager.RefreshMempoolStateLocked() + mu.Unlock() + } + if qmsVersion > 0 { // it should not happens since we constraint the loaded iavl version to not exceed the versiondb version, // still keep the check for safety. @@ -1680,11 +1690,16 @@ func (app *App) Commit() (*abci.ResponseCommit, error) { } resp, err := func() (*abci.ResponseCommit, error) { - // AppMempool.Lock() is a no-op; mu serializes checkState reset against concurrent admission. + // AppMempool.Lock() is a no-op; mu serializes BaseApp.Commit() and the + // mempoolState refresh against concurrent RunTx-based admission/recheck. mu := app.mempoolManager.AdmissionMutex() mu.Lock() defer mu.Unlock() - return app.BaseApp.Commit() + resp, err := app.BaseApp.Commit() + if err == nil { + app.mempoolManager.RefreshMempoolStateLocked() + } + return resp, err }() if err == nil { diff --git a/app/mempool/manager.go b/app/mempool/manager.go index aeae07bb9b..af8685450e 100644 --- a/app/mempool/manager.go +++ b/app/mempool/manager.go @@ -2,7 +2,9 @@ package mempool import ( "context" + "fmt" "sync" + "sync/atomic" "time" abci "github.com/cometbft/cometbft/abci/types" @@ -25,16 +27,28 @@ var _ txRunner = (*baseapp.BaseApp)(nil) // Manager owns the app-side mempool for mempool.type=app type Manager struct { - // mu guards BaseApp.checkState - // AppMempool.Lock() is a no-op, so mu replaces the mempool lock BaseApp - // normally relies on. Held only around RunTx, never the lock-free pool scan. - mu sync.Mutex + // stateMu guards state.base, the shared nonce authority for admission and + // recheck: RunTx is serialized through it, and App.Commit holds it across + // BaseApp.Commit() plus the post-Commit refresh so the swap never races a + // RunTx reader or the live memiavl tree mid-Commit. AppMempool.Lock() is a + // no-op, so stateMu also replaces the mempool lock BaseApp normally relies + // on. Held only around RunTx, never the lock-free pool scan. + stateMu sync.Mutex runner txRunner encCache *EncoderCache txEncoder sdk.TxEncoder trace bool // preVerify runs cheap verification lock-free before the tx admission mutex; set to nil for skip. preVerify func([]byte) error + // state holds the CacheMultiStore branch RunTx uses in place of checkState. + // nil until the first RefreshMempoolStateLocked call (after LoadLatestVersion + // in production, or never in the newManager() test constructor); store() + // returns nil in the meantime so RunTx's 5th arg falls back to checkState. + state *mempoolState + // gen counts mempoolState refreshes; runRecheck aborts a pass once gen + // advances mid-flight, since its candidates were validated against a + // now-superseded base. + gen atomic.Uint64 mpool sdkmempool.Mempool signer sdkmempool.SignerExtractionAdapter @@ -75,6 +89,12 @@ func NewManager(app *baseapp.BaseApp, encCache *EncoderCache, txEncoder sdk.TxEn a.maxRecheckBatch = recheckBatchSize a.ttlNumBlocks = ttlNumBlocks a.recheckDisabled = recheckDisabled + a.state = &mempoolState{provider: app.CommitMultiStore} + // Left unrefreshed here: NewManager runs inside baseAppOptions, before + // LoadLatestVersion, so branching now would read an unloaded store. state.base + // stays nil until App wires the first RefreshMempoolStateLocked call after + // LoadLatestVersion succeeds; store() falling back to nil (checkState) until + // then is the correct degradation. recheckEnabledGauge := float32(0) if !recheckDisabled { recheckEnabledGauge = 1 @@ -143,10 +163,23 @@ func (a *Manager) mergeRecheckSenders(senders map[string]struct{}) { } } -// AdmissionMutex exposes mu so App.Commit can serialize its checkState reset -// against lock-free admission. +// AdmissionMutex exposes stateMu so App.Commit can serialize BaseApp.Commit() +// and the mempoolState refresh against RunTx-based admission and recheck. func (a *Manager) AdmissionMutex() *sync.Mutex { - return &a.mu + return &a.stateMu +} + +// RefreshMempoolStateLocked branches mempoolState off the freshly committed +// store and bumps gen, canceling any recheck pass still validating against +// the superseded base. Precondition: the caller holds stateMu (AdmissionMutex), +// which App.Commit does across BaseApp.Commit() and this call. No-op when +// state is nil (newManager() test constructor). +func (a *Manager) RefreshMempoolStateLocked() { + if a.state == nil { + return + } + a.state.refreshLocked() + a.gen.Add(1) } // SetPreVerify sets the pre-verification hook. @@ -209,10 +242,10 @@ func (a *Manager) admit(txBytes []byte) (code uint32, codespace, log string) { } } - a.mu.Lock() - defer a.mu.Unlock() + a.stateMu.Lock() + defer a.stateMu.Unlock() - _, _, _, err := a.runner.RunTx(sdk.ExecModeCheck, txBytes, tx, -1, nil, nil) + _, _, _, err := a.runner.RunTx(sdk.ExecModeCheck, txBytes, tx, -1, a.state.store(), nil) if err != nil { if errorsmod.IsOf(err, sdkmempool.ErrMempoolTxMaxCapacity) { return abci.CodeTypeRetry, "", "mempool is full" @@ -238,11 +271,24 @@ func (a *Manager) cacheTx(tx sdk.Tx, raw []byte) { a.encCache.Set(tx, bz) } -// CheckTxHandler runs RPC CheckTx. +// CheckTxHandler runs RPC CheckTx. It calls the runner directly instead of the +// runTx closure baseapp passes in (abci.go CheckTx), which hardcodes +// txMultiStore = nil; the exec-mode mapping below mirrors BaseApp.CheckTx so +// req.Type stays authoritative. func (a *Manager) CheckTxHandler() sdk.CheckTxHandler { - return func(runTx sdk.RunTx, req *abci.RequestCheckTx) (*abci.ResponseCheckTx, error) { + return func(_ sdk.RunTx, req *abci.RequestCheckTx) (*abci.ResponseCheckTx, error) { + var mode sdk.ExecMode + switch req.Type { + case abci.CheckTxType_New: + mode = sdk.ExecModeCheck + case abci.CheckTxType_Recheck: + mode = sdk.ExecModeReCheck + default: + return nil, fmt.Errorf("unknown RequestCheckTx type: %s", req.Type) + } + // Decode before locking: proto unmarshal is CPU-intensive; decoder and - // DecodeCache have their own locks. Bad txs return without acquiring mu. + // DecodeCache have their own locks. Bad txs return without acquiring stateMu. var tx sdk.Tx if a.encCache != nil { var err error @@ -251,10 +297,10 @@ func (a *Manager) CheckTxHandler() sdk.CheckTxHandler { } } - a.mu.Lock() - defer a.mu.Unlock() + a.stateMu.Lock() + defer a.stateMu.Unlock() - gasInfo, result, anteEvents, err := runTx(req.Tx, tx) + gasInfo, result, anteEvents, err := a.runner.RunTx(mode, req.Tx, tx, -1, a.state.store(), nil) if err != nil { return sdkerrors.ResponseCheckTxWithEvents(err, gasInfo.GasWanted, gasInfo.GasUsed, anteEvents, a.trace), nil } @@ -341,6 +387,7 @@ func (a *Manager) RecheckTxs() { a.recheckMu.Lock() // lock order: see the recheckMu field comment defer a.recheckMu.Unlock() recheckSenders, height, deferred := a.drainStaging() + gen := a.gen.Load() // Before the first block (height 0) with no senders/carry there's nothing to scan. if len(recheckSenders) == 0 && len(deferred) == 0 && height == 0 { return @@ -348,7 +395,7 @@ func (a *Manager) RecheckTxs() { snapshot := PoolSnapshot(context.Background(), a.mpool) candidates := a.capRecheckTxs(a.selectTxs(snapshot, recheckSenders, height, deferred)) - a.runRecheck(candidates) + a.runRecheck(candidates, gen) telemetry.SetGauge(float32(a.mpool.CountTx()), "cronos", "mempool", "pool", "size") } @@ -486,17 +533,27 @@ func (a *Manager) capRecheckTxs(candidates []sdk.Tx) []sdk.Tx { return candidates[:a.maxRecheckBatch] } -// runRecheck re-validates candidates via RunTx(ReCheck) -func (a *Manager) runRecheck(candidates []sdk.Tx) { - var evicted float32 - for _, tx := range candidates { +// runRecheck re-validates candidates via RunTx(ReCheck), abandoning the rest +// of the pass once gen advances mid-flight: those candidates were validated +// against a base a concurrent Commit has already superseded. drainStaging +// already cleared recheckSenders for this cycle, so the unreached candidates' +// senders are re-merged into staging here — otherwise a sender that isn't +// touched again by a later block would never be rechecked until TTL. +func (a *Manager) runRecheck(candidates []sdk.Tx, gen uint64) { + var evicted, superseded float32 + for i, tx := range candidates { + if a.gen.Load() != gen { + superseded = float32(len(candidates) - i) + a.recoverSenders(candidates[i:]) + break + } bz, _, err := EncodeTx(a.encCache, a.txEncoder, tx) if err != nil { continue } - a.mu.Lock() - _, _, _, err = a.runner.RunTx(sdk.ExecModeReCheck, bz, tx, -1, nil, nil) - a.mu.Unlock() + a.stateMu.Lock() + _, _, _, err = a.runner.RunTx(sdk.ExecModeReCheck, bz, tx, -1, a.state.store(), nil) + a.stateMu.Unlock() if err != nil { a.evict(tx) evicted++ @@ -505,6 +562,26 @@ func (a *Manager) runRecheck(candidates []sdk.Tx) { if evicted > 0 { telemetry.IncrCounter(evicted, "cronos", "mempool", "recheck", "evicted") } + if superseded > 0 { + telemetry.IncrCounter(superseded, "cronos", "mempool", "recheck", "superseded") + } +} + +// recoverSenders folds txs' senders back into staged recheckSenders without +// touching deferred, which capRecheckTxs may have already set this cycle. +func (a *Manager) recoverSenders(txs []sdk.Tx) { + senders := make(map[string]struct{}) + for _, tx := range txs { + for _, s := range a.signers(tx) { + senders[s] = struct{}{} + } + } + if len(senders) == 0 { + return + } + a.stagingMu.Lock() + a.mergeRecheckSenders(senders) + a.stagingMu.Unlock() } // txTimedout reports whether tx should be evicted by its own declared timeout: diff --git a/app/mempool/manager_test.go b/app/mempool/manager_test.go index c8a1f86db1..bbb04a6e35 100644 --- a/app/mempool/manager_test.go +++ b/app/mempool/manager_test.go @@ -35,14 +35,26 @@ func (t *ptrTx) GetTimeoutHeight() uint64 { return t.timeout } // noopEncoder is a non-nil txEncoder for tests that don't assert on bytes. var noopEncoder sdk.TxEncoder = func(sdk.Tx) ([]byte, error) { return nil, nil } -// stubRunner is a test double for txRunner. +// stubRunner is a test double for txRunner. resp, if set, takes precedence +// over runTx and gives full control over the returned GasInfo/Result/events +// (needed by CheckTxHandler tests, which no longer drive a caller-supplied +// runTx closure). type stubRunner struct { runTx func([]byte) error + resp func(mode sdk.ExecMode, txBytes []byte) (sdk.GasInfo, *sdk.Result, []abci.Event, error) calls atomic.Int64 + // ms, if non-nil, records the txMultiStore arg of the most recent RunTx call. + ms *storetypes.MultiStore } func (s *stubRunner) RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, txIndex int, ms storetypes.MultiStore, cache map[string]any) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { s.calls.Add(1) + if s.ms != nil { + *s.ms = ms + } + if s.resp != nil { + return s.resp(mode, txBytes) + } if s.runTx != nil { return sdk.GasInfo{}, nil, nil, s.runTx(txBytes) } @@ -268,13 +280,13 @@ func TestInsertTxHandler_ConcurrentAdmissionIsSerialized(t *testing.T) { } func TestCheckTxHandler_MapsSuccess(t *testing.T) { - a := newManager(&stubRunner{}, nil, noopEncoder, nil) + runner := &stubRunner{resp: func(sdk.ExecMode, []byte) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { + return sdk.GasInfo{GasWanted: 100, GasUsed: 42}, &sdk.Result{Log: "ok", Data: []byte("d")}, nil, nil + }} + a := newManager(runner, nil, noopEncoder, nil) check := a.CheckTxHandler() - runTx := func([]byte, sdk.Tx) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { - return sdk.GasInfo{GasWanted: 100, GasUsed: 42}, &sdk.Result{Log: "ok", Data: []byte("d")}, nil, nil - } - resp, err := check(runTx, &abci.RequestCheckTx{Tx: []byte("tx")}) + resp, err := check(nil, &abci.RequestCheckTx{Tx: []byte("tx")}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -290,14 +302,12 @@ func TestCheckTxHandler_MapsSuccess(t *testing.T) { } func TestCheckTxHandler_MapsError(t *testing.T) { - a := newManager(&stubRunner{}, nil, noopEncoder, nil) + anteErr := errorsmod.Register("test-check", 1, "bad sig") + runner := &stubRunner{runTx: func([]byte) error { return anteErr }} + a := newManager(runner, nil, noopEncoder, nil) check := a.CheckTxHandler() - anteErr := errorsmod.Register("test-check", 1, "bad sig") - runTx := func([]byte, sdk.Tx) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { - return sdk.GasInfo{}, nil, nil, anteErr - } - resp, err := check(runTx, &abci.RequestCheckTx{Tx: []byte("bad")}) + resp, err := check(nil, &abci.RequestCheckTx{Tx: []byte("bad")}) if err != nil { t.Fatalf("handler must not surface a transport error, got %v", err) } @@ -306,6 +316,41 @@ func TestCheckTxHandler_MapsError(t *testing.T) { } } +func TestCheckTxHandler_RecheckTypeMapsToExecModeReCheck(t *testing.T) { + var capturedMode sdk.ExecMode + a := newManager(&captureExecModeRunner{mode: &capturedMode}, nil, noopEncoder, nil) + check := a.CheckTxHandler() + + if _, err := check(nil, &abci.RequestCheckTx{Tx: []byte("tx"), Type: abci.CheckTxType_Recheck}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedMode != sdk.ExecModeReCheck { + t.Fatalf("expected ExecModeReCheck, got %v", capturedMode) + } +} + +func TestCheckTxHandler_NewTypeMapsToExecModeCheck(t *testing.T) { + var capturedMode sdk.ExecMode + a := newManager(&captureExecModeRunner{mode: &capturedMode}, nil, noopEncoder, nil) + check := a.CheckTxHandler() + + if _, err := check(nil, &abci.RequestCheckTx{Tx: []byte("tx"), Type: abci.CheckTxType_New}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedMode != sdk.ExecModeCheck { + t.Fatalf("expected ExecModeCheck, got %v", capturedMode) + } +} + +func TestCheckTxHandler_UnknownTypeReturnsError(t *testing.T) { + a := newManager(&stubRunner{}, nil, noopEncoder, nil) + check := a.CheckTxHandler() + + if _, err := check(nil, &abci.RequestCheckTx{Tx: []byte("tx"), Type: abci.CheckTxType(99)}); err == nil { + t.Fatal("expected error for unknown CheckTxType") + } +} + func TestCheckTxHandler_RegistersCanonicalBytes(t *testing.T) { tx := &ptrTx{} raw := []byte("rpc-gossip-bytes") @@ -322,10 +367,7 @@ func TestCheckTxHandler_RegistersCanonicalBytes(t *testing.T) { a := newManager(&stubRunner{}, enc, txEncoder, decoder) check := a.CheckTxHandler() - runTx := func([]byte, sdk.Tx) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { - return sdk.GasInfo{}, &sdk.Result{}, nil, nil - } - if _, err := check(runTx, &abci.RequestCheckTx{Tx: raw}); err != nil { + if _, err := check(nil, &abci.RequestCheckTx{Tx: raw}); err != nil { t.Fatalf("unexpected error: %v", err) } got, ok := enc.Get(tx) @@ -341,14 +383,12 @@ func TestCheckTxHandler_NoRegisterOnReject(t *testing.T) { tx := &ptrTx{} decoder := func([]byte) (sdk.Tx, error) { return tx, nil } enc := NewEncoderCache(0, 0) - a := newManager(&stubRunner{}, enc, noopEncoder, decoder) + anteErr := errorsmod.Register("test-check-rej", 1, "bad") + runner := &stubRunner{runTx: func([]byte) error { return anteErr }} + a := newManager(runner, enc, noopEncoder, decoder) check := a.CheckTxHandler() - anteErr := errorsmod.Register("test-check-rej", 1, "bad") - runTx := func([]byte, sdk.Tx) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { - return sdk.GasInfo{}, nil, nil, anteErr - } - if _, err := check(runTx, &abci.RequestCheckTx{Tx: []byte("bad")}); err != nil { + if _, err := check(nil, &abci.RequestCheckTx{Tx: []byte("bad")}); err != nil { t.Fatalf("unexpected error: %v", err) } if _, ok := enc.Get(tx); ok { @@ -362,12 +402,8 @@ func TestManager_InsertAndCheckShareMutex(t *testing.T) { insert := a.InsertTxHandler() check := a.CheckTxHandler() - // CheckTx's runTx closure mirrors BaseApp: it drives the same lock-free - // runner/state that InsertTx writes through a.runner. - runTx := func(txBytes []byte, _ sdk.Tx) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { - return runner.RunTx(sdk.ExecModeCheck, txBytes, nil, -1, nil, nil) - } - + // CheckTxHandler drives a.runner directly (the same lock-free raceRunner + // InsertTx writes through), so -race flags either path if it skips stateMu. const goroutines = 16 const perG = 64 var wg sync.WaitGroup @@ -381,7 +417,7 @@ func TestManager_InsertAndCheckShareMutex(t *testing.T) { if g%2 == 0 { _, err = insert(&abci.RequestInsertTx{Tx: tx}) } else { - _, err = check(runTx, &abci.RequestCheckTx{Tx: tx}) + _, err = check(nil, &abci.RequestCheckTx{Tx: tx}) } if err != nil { t.Errorf("g%d i%d: unexpected error: %v", g, i, err) @@ -611,3 +647,173 @@ func TestManagerCountTx(t *testing.T) { t.Fatalf("want 3, got %d", got) } } + +// msCaptureRunner is a txRunner double that records each call's txMultiStore +// arg, so a test can assert the three RunTx call sites (admit, CheckTxHandler, +// runRecheck) all receive the same mempoolState.base instance. +type msCaptureRunner struct { + mu sync.Mutex + ms []storetypes.MultiStore +} + +func (r *msCaptureRunner) RunTx(_ sdk.ExecMode, _ []byte, _ sdk.Tx, _ int, ms storetypes.MultiStore, _ map[string]any) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { + r.mu.Lock() + r.ms = append(r.ms, ms) + r.mu.Unlock() + return sdk.GasInfo{}, &sdk.Result{}, nil, nil +} + +func TestManager_AllThreeRunTxSitesShareBaseInstance(t *testing.T) { + runner := &msCaptureRunner{} + a := newManager(runner, nil, noopEncoder, nil) + base := newFakeCacheStore() + a.state = &mempoolState{base: base} + a.mpool = &fakePool{} + a.signer = fakeSigner{m: map[sdk.Tx][]sdkmempool.SignerData{}} + + a.admit([]byte("tx1")) + check := a.CheckTxHandler() + check(nil, &abci.RequestCheckTx{Tx: []byte("tx2")}) //nolint:errcheck + a.runRecheck([]sdk.Tx{&ptrTx{id: 1}}, a.gen.Load()) + + if len(runner.ms) != 3 { + t.Fatalf("expected 3 RunTx calls (admit, CheckTxHandler, runRecheck), got %d", len(runner.ms)) + } + for i, ms := range runner.ms { + if ms == nil { + t.Fatalf("call %d: got a nil store, want the wired base", i) + } + if ms != storetypes.MultiStore(base) { + t.Fatalf("call %d: got a different store instance than the wired base", i) + } + } +} + +// fakeNonceStore stands in for the real branched CacheMultiStore's role as +// nonce authority: setNonce mirrors baseapp's ante write-back into the +// txMultiStore arg, getNonce mirrors a later RunTx reading it back out. +type fakeNonceStore struct { + cacheMultiStoreIface // defined type, not the interface itself: see state_test.go + mu sync.Mutex + nonces map[string]uint64 +} + +func newFakeNonceStore() *fakeNonceStore { return &fakeNonceStore{nonces: map[string]uint64{}} } + +func (f *fakeNonceStore) setNonce(sender string, n uint64) { + f.mu.Lock() + defer f.mu.Unlock() + f.nonces[sender] = n +} + +func (f *fakeNonceStore) getNonce(sender string) uint64 { + f.mu.Lock() + defer f.mu.Unlock() + return f.nonces[sender] +} + +// nonceBranchRunner models baseapp's RunTx(txMultiStore) contract from the +// design doc: ReCheck writes a nonce bump back into the passed store; Check +// only succeeds once that write is visible through the same store. +type nonceBranchRunner struct{} + +func (r *nonceBranchRunner) RunTx(mode sdk.ExecMode, _ []byte, _ sdk.Tx, _ int, ms storetypes.MultiStore, _ map[string]any) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { + store, ok := ms.(*fakeNonceStore) + if !ok { + return sdk.GasInfo{}, nil, nil, errors.New("no branched store") + } + switch mode { + case sdk.ExecModeReCheck: + store.setNonce("alice", 8) + return sdk.GasInfo{}, &sdk.Result{}, nil, nil + case sdk.ExecModeCheck: + if store.getNonce("alice") < 8 { + return sdk.GasInfo{}, nil, nil, errors.New("nonce not yet visible") + } + return sdk.GasInfo{}, &sdk.Result{}, nil, nil + default: + return sdk.GasInfo{}, &sdk.Result{}, nil, nil + } +} + +// TestManager_RecheckWriteVisibleToLaterAdmit proves nonce continuity across +// the branch: a RunTx(ExecModeReCheck) write into base must be visible to a +// later admit() reading through the same shared base. +func TestManager_RecheckWriteVisibleToLaterAdmit(t *testing.T) { + store := newFakeNonceStore() + a := newManager(&nonceBranchRunner{}, nil, noopEncoder, nil) + a.state = &mempoolState{base: store} + + a.runRecheck([]sdk.Tx{&ptrTx{id: 1}}, a.gen.Load()) + + code, _, log := a.admit([]byte("alice-nonce-8-sibling")) + if code != abci.CodeTypeOK { + t.Fatalf("admission must see recheck's nonce write-back through the shared base, got code=%d log=%q", code, log) + } +} + +func TestManager_RefreshMempoolStateLockedSwapsBaseAndBumpsGen(t *testing.T) { + first, second := newFakeCacheStore(), newFakeCacheStore() + calls := 0 + a := newManager(&stubRunner{}, nil, noopEncoder, nil) + a.state = &mempoolState{provider: func() storetypes.CommitMultiStore { + calls++ + if calls == 1 { + return &fakeCommitStore{cache: first} + } + return &fakeCommitStore{cache: second} + }} + a.state.refreshLocked() // mirrors NewManager's initial refresh + if got := a.state.store(); got != storetypes.MultiStore(first) { + t.Fatalf("expected initial base, got %v", got) + } + + beforeGen := a.gen.Load() + a.RefreshMempoolStateLocked() + + if got := a.state.store(); got != storetypes.MultiStore(second) { + t.Fatal("RefreshMempoolStateLocked must swap base identity") + } + if got := a.gen.Load(); got != beforeGen+1 { + t.Fatalf("RefreshMempoolStateLocked must bump gen, got %d want %d", got, beforeGen+1) + } +} + +func TestManager_RefreshMempoolStateLockedNoopWithoutState(t *testing.T) { + a := newManager(&stubRunner{}, nil, noopEncoder, nil) // state nil (test ctor) + a.RefreshMempoolStateLocked() // must not panic + if a.gen.Load() != 0 { + t.Fatal("nil state must leave gen untouched") + } +} + +// TestManager_NilBaseBeforeFirstRefresh mirrors the production wiring order: +// NewManager sets state.provider but must NOT refresh (the store isn't loaded +// yet at that point in baseAppOptions), so admit falls back to checkState +// (nil txMultiStore) until App calls RefreshMempoolStateLocked after +// LoadLatestVersion. +func TestManager_NilBaseBeforeFirstRefresh(t *testing.T) { + runner := &msCaptureRunner{} + a := newManager(runner, nil, noopEncoder, nil) + base := newFakeCacheStore() + calls := 0 + a.state = &mempoolState{provider: func() storetypes.CommitMultiStore { + calls++ + return &fakeCommitStore{cache: base} + }} // provider wired, no refreshLocked call yet: mirrors NewManager exactly + + a.admit([]byte("pre-refresh")) + if len(runner.ms) != 1 || runner.ms[0] != nil { + t.Fatalf("admit before the first refresh must pass a nil store (checkState fallback), got %v", runner.ms) + } + if calls != 0 { + t.Fatal("provider must not be invoked before RefreshMempoolStateLocked") + } + + a.RefreshMempoolStateLocked() // mirrors App's post-LoadLatestVersion call + + a.admit([]byte("post-refresh")) + if len(runner.ms) != 2 || runner.ms[1] != storetypes.MultiStore(base) { + t.Fatalf("admit after the first refresh must pass the wired base, got %v", runner.ms) + } +} diff --git a/app/mempool/recheck_async_test.go b/app/mempool/recheck_async_test.go index ef87242d47..6eb4a73afd 100644 --- a/app/mempool/recheck_async_test.go +++ b/app/mempool/recheck_async_test.go @@ -2,6 +2,7 @@ package mempool import ( "context" + "strconv" "sync" "sync/atomic" "testing" @@ -80,6 +81,15 @@ func TestTriggerRecheck_ConcurrentCommits(t *testing.T) { f.a.TriggerRecheck() }(int64(i + 1)) } + // admit races commit + recheck through the same stateMu-guarded path + // (RunTx's shared base), exercised together under -race. + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + f.a.admit([]byte("concurrent-" + strconv.Itoa(i))) + }(i) + } wg.Wait() f.a.Close() } diff --git a/app/mempool/recheck_test.go b/app/mempool/recheck_test.go index 5d47a394e9..513890ec7f 100644 --- a/app/mempool/recheck_test.go +++ b/app/mempool/recheck_test.go @@ -36,6 +36,9 @@ type recheckRunner struct { failNoRemoveBytes map[string]bool modes []sdk.ExecMode seen map[string]bool + // onCall, if set, runs after recording the call but before returning, letting + // a test bump gen mid-pass to exercise runRecheck's cancellation check. + onCall func(txBytes []byte) } func (r *recheckRunner) RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, _ int, _ storetypes.MultiStore, _ map[string]any) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { @@ -43,6 +46,9 @@ func (r *recheckRunner) RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, _ in defer r.mu.Unlock() r.modes = append(r.modes, mode) r.seen[string(txBytes)] = true + if r.onCall != nil { + r.onCall(txBytes) + } if r.failBytes[string(txBytes)] { _ = r.pool.Remove(tx) // baseapp removes on ante failure during recheck return sdk.GasInfo{}, nil, nil, errors.New("ante failed on recheck") @@ -966,3 +972,90 @@ func TestRecheckTxs_NilEncCacheEvictionNoPanic(t *testing.T) { t.Fatal("aged tx must be evicted even with nil encCache") } } + +const aliceSeq0Bytes = "alice-0" + +// A generation bump mid-pass (a concurrent Commit's RefreshMempoolStateLocked) +// must cancel runRecheck's remaining candidates; the next RecheckTxs cycle +// re-covers the skipped one from the staging runRecheck restored. +func TestRecheckTxs_GenerationBumpMidPassSkipsRemainingCandidates(t *testing.T) { + f := newRecheckFixture() + first := f.add(1, "alice", 0, aliceSeq0Bytes) + second := f.add(2, "alice", 1, "alice-1") + + f.runner.onCall = func(txBytes []byte) { + if string(txBytes) == aliceSeq0Bytes { + f.a.gen.Add(1) // simulate a Commit's refresh landing mid-pass + } + } + f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.RecheckTxs() + + if !f.runner.seen[aliceSeq0Bytes] { + t.Fatal("the candidate validated before the bump must still run") + } + if f.runner.seen["alice-1"] { + t.Fatal("candidates after the generation bump must be skipped, not rechecked against a superseded base") + } + if !poolHas(f.pool, second) { + t.Fatal("a skipped (not rechecked) candidate must stay in the pool") + } + if !poolHas(f.pool, first) { + t.Fatal("the pre-bump candidate must still be evicted/kept per its own RunTx result") + } + + // No manual re-staging: runRecheck must have re-merged alice itself. + f.runner.onCall = nil + f.a.RecheckTxs() + + if !f.runner.seen["alice-1"] { + t.Fatal("the skipped candidate must be re-covered by the next RecheckTxs pass") + } +} + +// TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred covers +// the two-sender abort case explicitly: the unreached sender must land in +// staging (not just its raw tx, which runRecheck never touches), and an +// already-set deferred carry from this same cycle's capRecheckTxs must survive +// untouched. +func TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred(t *testing.T) { + f := newRecheckFixture() + aliceTx := f.add(1, "alice", 0, aliceSeq0Bytes) + bobTx := f.add(2, "bob", 0, "bob-0") + carryTx := f.add(3, "carol", 0, "carol-carry") // stands in for capRecheckTxs' overflow carry + + f.a.deferred = []sdk.Tx{carryTx} + + f.runner.onCall = func(txBytes []byte) { + if string(txBytes) == aliceSeq0Bytes { + f.a.gen.Add(1) // simulate a Commit's refresh landing after the first candidate + } + } + gen := f.a.gen.Load() + f.a.runRecheck([]sdk.Tx{aliceTx, bobTx}, gen) + + if !f.runner.seen[aliceSeq0Bytes] { + t.Fatal("the candidate validated before the bump must still run") + } + if f.runner.seen["bob-0"] { + t.Fatal("the candidate after the bump must be skipped, not rechecked against a superseded base") + } + if _, ok := f.a.recheckSenders[sdk.AccAddress("bob").String()]; !ok { + t.Fatal("bob must be re-covered in staging after its candidate was skipped") + } + if len(f.a.deferred) != 1 || f.a.deferred[0] != carryTx { + t.Fatal("an already-set deferred carry from this cycle must not be clobbered") + } + + // Next RecheckTxs cycle: bob (re-covered) and the carried carol tx must both + // get rechecked. + f.runner.onCall = nil + f.a.RecheckTxs() + + if !f.runner.seen["bob-0"] { + t.Fatal("the re-covered sender's tx must be rechecked by the next RecheckTxs cycle") + } + if !f.runner.seen["carol-carry"] { + t.Fatal("the deferred carry must still be rechecked by the next RecheckTxs cycle") + } +} diff --git a/app/mempool/state.go b/app/mempool/state.go new file mode 100644 index 0000000000..a60a82fd88 --- /dev/null +++ b/app/mempool/state.go @@ -0,0 +1,44 @@ +package mempool + +import ( + "sync" + + storetypes "github.com/cosmos/cosmos-sdk/store/v2/types" +) + +// mempoolState holds the CacheMultiStore branch that admission and recheck +// share as the sole nonce authority (docs/architecture/mempool-branched-recheck-context.md). +// mu guards base independently of Manager.stateMu: it is always the innermost +// lock (mempoolState never calls back into Manager while holding it), so +// nesting it under stateMu adds no ordering hazard, and base stays safe to +// read even if a future caller forgets to hold stateMu. +type mempoolState struct { + mu sync.RWMutex + base storetypes.CacheMultiStore + provider func() storetypes.CommitMultiStore +} + +// refreshLocked branches a fresh base off the committed store. Precondition: +// the caller holds Manager.stateMu, which is what actually keeps this swap +// from racing a concurrent RunTx or the live memiavl tree mid-Commit. +func (s *mempoolState) refreshLocked() { + base := s.provider().CacheMultiStore() + s.mu.Lock() + s.base = base + s.mu.Unlock() +} + +// store returns the current base, or nil so RunTx falls back to checkState. +// Nil-safe on a nil receiver (and nil base) so the newManager() test +// constructor, which leaves Manager.state nil, keeps working without a store. +func (s *mempoolState) store() storetypes.MultiStore { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.base == nil { + return nil + } + return s.base +} diff --git a/app/mempool/state_test.go b/app/mempool/state_test.go new file mode 100644 index 0000000000..ea619972a5 --- /dev/null +++ b/app/mempool/state_test.go @@ -0,0 +1,67 @@ +package mempool + +import ( + "testing" + + storetypes "github.com/cosmos/cosmos-sdk/store/v2/types" +) + +// cacheMultiStoreIface is a defined (non-alias) copy of the interface, so +// embedding it below doesn't name-collide with the interface's own +// CacheMultiStore() method (an anonymous storetypes.CacheMultiStore field +// would be named "CacheMultiStore", shadowing that promoted method). +type cacheMultiStoreIface storetypes.CacheMultiStore + +// fakeCacheStore is a minimal storetypes.CacheMultiStore double used purely as +// an identity marker: it embeds a nil CacheMultiStore to satisfy the +// interface, so a test using it must never invoke an unoverridden method. +type fakeCacheStore struct { + cacheMultiStoreIface +} + +func newFakeCacheStore() *fakeCacheStore { return &fakeCacheStore{} } + +// fakeCommitStore is a minimal storetypes.CommitMultiStore double whose only +// live method is CacheMultiStore, standing in for BaseApp.CommitMultiStore(). +type fakeCommitStore struct { + storetypes.CommitMultiStore + cache storetypes.CacheMultiStore +} + +func (f *fakeCommitStore) CacheMultiStore() storetypes.CacheMultiStore { return f.cache } + +func TestMempoolState_StoreNilOnNilReceiver(t *testing.T) { + var s *mempoolState + if got := s.store(); got != nil { + t.Fatalf("nil *mempoolState must report nil store, got %v", got) + } +} + +func TestMempoolState_StoreNilOnNilBase(t *testing.T) { + s := &mempoolState{} + if got := s.store(); got != nil { + t.Fatalf("unrefreshed mempoolState (nil base) must report nil store, got %v", got) + } +} + +func TestMempoolState_RefreshLockedSwapsBaseIdentity(t *testing.T) { + first, second := newFakeCacheStore(), newFakeCacheStore() + calls := 0 + s := &mempoolState{provider: func() storetypes.CommitMultiStore { + calls++ + if calls == 1 { + return &fakeCommitStore{cache: first} + } + return &fakeCommitStore{cache: second} + }} + + s.refreshLocked() + if got := s.store(); got != storetypes.MultiStore(first) { + t.Fatalf("expected first base after initial refresh, got %v", got) + } + + s.refreshLocked() + if got := s.store(); got != storetypes.MultiStore(second) { + t.Fatal("refreshLocked must swap base identity on the next call") + } +} From 9de1750a4d0d6a34db7df5dce618aa3664b06f85 Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Wed, 29 Jul 2026 20:25:34 -0400 Subject: [PATCH 02/12] perf(mempool): group recheck candidates per sender and cascade nonce-gap evictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/mempool/manager.go | 156 +++++++++++++++++++++++++++++++----- app/mempool/recheck_test.go | 144 ++++++++++++++++++++++++++------- 2 files changed, 251 insertions(+), 49 deletions(-) diff --git a/app/mempool/manager.go b/app/mempool/manager.go index af8685450e..cc9468786e 100644 --- a/app/mempool/manager.go +++ b/app/mempool/manager.go @@ -533,40 +533,141 @@ func (a *Manager) capRecheckTxs(candidates []sdk.Tx) []sdk.Tx { return candidates[:a.maxRecheckBatch] } -// runRecheck re-validates candidates via RunTx(ReCheck), abandoning the rest -// of the pass once gen advances mid-flight: those candidates were validated -// against a base a concurrent Commit has already superseded. drainStaging -// already cleared recheckSenders for this cycle, so the unreached candidates' -// senders are re-merged into staging here — otherwise a sender that isn't -// touched again by a later block would never be rechecked until TTL. +// recheckCandidate carries the signer nonce alongside the tx: telling a nonce +// gap from a merely stale nonce is what makes cascade eviction safe. +type recheckCandidate struct { + tx sdk.Tx + bz []byte + seq uint64 +} + +// recheckGroup holds one signer's candidates in pool order. cascadable is false +// when the group is not that signer's contiguous ascending-nonce view — an +// unknown signer, a repeated or out-of-order nonce, or a tx dropped on encode +// error — because the cascade rule reasons about the next expected nonce. +type recheckGroup struct { + txs []recheckCandidate + cascadable bool +} + +// runRecheck re-validates candidates via RunTx(ReCheck), one signer group at a +// time so a sender's nonce chain advances atomically with respect to other +// senders' admissions. The pass is abandoned once gen advances mid-flight: the +// remaining candidates would be validated against a base a concurrent Commit +// has already superseded. drainStaging already cleared recheckSenders for this +// cycle, so the unreached candidates' senders are re-merged into staging here — +// otherwise a sender that isn't touched again by a later block would never be +// rechecked until TTL. func (a *Manager) runRecheck(candidates []sdk.Tx, gen uint64) { - var evicted, superseded float32 - for i, tx := range candidates { - if a.gen.Load() != gen { - superseded = float32(len(candidates) - i) - a.recoverSenders(candidates[i:]) - break - } - bz, _, err := EncodeTx(a.encCache, a.txEncoder, tx) - if err != nil { + var evicted, cascaded, superseded float32 + groups := a.groupCandidates(candidates) + for i, g := range groups { + if len(g.txs) == 0 { continue } - a.stateMu.Lock() - _, _, _, err = a.runner.RunTx(sdk.ExecModeReCheck, bz, tx, -1, a.state.store(), nil) - a.stateMu.Unlock() - if err != nil { - a.evict(tx) - evicted++ + e, c, aborted := a.recheckGroup(g, gen) + evicted += e + cascaded += c + if aborted { + unreached := unreachedTxs(groups[i:]) + superseded = float32(len(unreached)) + a.recoverSenders(unreached) + break } } if evicted > 0 { telemetry.IncrCounter(evicted, "cronos", "mempool", "recheck", "evicted") } + if cascaded > 0 { + telemetry.IncrCounter(cascaded, "cronos", "mempool", "recheck", "cascade_evicted") + } if superseded > 0 { telemetry.IncrCounter(superseded, "cronos", "mempool", "recheck", "superseded") } } +// groupCandidates buckets candidates by first signer — the one the mempool +// orders by — keeping first-appearance order across groups and pool order +// within one, so the front-loaded deferred prefix still runs first. Encoding +// happens here, outside stateMu, to keep the per-group lock hold to RunTx. +func (a *Manager) groupCandidates(candidates []sdk.Tx) []recheckGroup { + groups := make([]recheckGroup, 0, len(candidates)) + index := make(map[string]int, len(candidates)) + for _, tx := range candidates { + key, seq, known := a.firstSigner(tx) + gi, seen := index[key] + if !seen { + groups = append(groups, recheckGroup{cascadable: known}) + gi = len(groups) - 1 + index[key] = gi + } + g := &groups[gi] + bz, _, err := EncodeTx(a.encCache, a.txEncoder, tx) + if err != nil { + g.cascadable = false + continue + } + if n := len(g.txs); n > 0 && seq <= g.txs[n-1].seq { + g.cascadable = false + } + g.txs = append(g.txs, recheckCandidate{tx: tx, bz: bz, seq: seq}) + } + return groups +} + +// recheckGroup re-validates one signer's candidates under a single stateMu hold. +// Reports aborted when gen advanced before the group started, leaving the group +// untouched. On a nonce gap the remaining higher-nonce siblings are evicted +// without spending a RunTx on each: nothing can fill the gap while they sit in +// the pool. Any other failure evicts only the failing tx, since a later sibling +// may still be the account's next expected nonce. +func (a *Manager) recheckGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, aborted bool) { + a.stateMu.Lock() + defer a.stateMu.Unlock() + // gen only advances under stateMu, so it cannot change once this group starts. + if a.gen.Load() != gen { + return 0, 0, true + } + + var lastOK uint64 + haveOK := false + for i, c := range g.txs { + _, _, _, err := a.runner.RunTx(sdk.ExecModeReCheck, c.bz, c.tx, -1, a.state.store(), nil) + if err == nil { + lastOK, haveOK = c.seq, true + continue + } + a.evict(c.tx) + evicted++ + // A gap is only provable relative to a nonce this pass just accepted; + // without one the failure may be a stale nonce, whose successor is valid. + if g.cascadable && haveOK && c.seq > lastOK+1 && isNonceErr(err) { + for _, rest := range g.txs[i+1:] { + a.evict(rest.tx) + cascaded++ + } + return evicted, cascaded, false + } + } + return evicted, cascaded, false +} + +// isNonceErr matches both ante paths: cosmos sig verification reports +// ErrWrongSequence, the EVM nonce check reports ErrInvalidSequence. +func isNonceErr(err error) bool { + return errorsmod.IsOf(err, sdkerrors.ErrWrongSequence, sdkerrors.ErrInvalidSequence) +} + +func unreachedTxs(groups []recheckGroup) []sdk.Tx { + var txs []sdk.Tx + for _, g := range groups { + for _, c := range g.txs { + txs = append(txs, c.tx) + } + } + return txs +} + // recoverSenders folds txs' senders back into staged recheckSenders without // touching deferred, which capRecheckTxs may have already set this cycle. func (a *Manager) recoverSenders(txs []sdk.Tx) { @@ -617,6 +718,19 @@ func (a *Manager) evict(tx sdk.Tx) { a.encCache.Evict(tx) } +// firstSigner returns the signer the mempool orders by, with its nonce. An +// unknown signer only costs the cascade optimization, not the recheck itself. +func (a *Manager) firstSigner(tx sdk.Tx) (key string, seq uint64, known bool) { + if a.signer == nil { + return "", 0, false + } + sigs, err := a.signer.GetSigners(tx) + if err != nil || len(sigs) == 0 { + return "", 0, false + } + return sigs[0].Signer.String(), sigs[0].Sequence, true +} + func (a *Manager) signers(tx sdk.Tx) []string { sigs, err := a.signer.GetSigners(tx) if err != nil { diff --git a/app/mempool/recheck_test.go b/app/mempool/recheck_test.go index 513890ec7f..ec7b7cb12a 100644 --- a/app/mempool/recheck_test.go +++ b/app/mempool/recheck_test.go @@ -3,14 +3,18 @@ package mempool import ( "context" "errors" + "slices" "strconv" "sync" "testing" abci "github.com/cometbft/cometbft/abci/types" + errorsmod "cosmossdk.io/errors" + storetypes "github.com/cosmos/cosmos-sdk/store/v2/types" sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" ) @@ -34,8 +38,13 @@ type recheckRunner struct { pool sdkmempool.Mempool failBytes map[string]bool failNoRemoveBytes map[string]bool - modes []sdk.ExecMode - seen map[string]bool + // failErrs returns a specific error per tx bytes, without removing from the + // pool, so tests can drive runRecheck's nonce-gap classification. + failErrs map[string]error + modes []sdk.ExecMode + seen map[string]bool + // calls records tx bytes in call order, for grouping assertions. + calls []string // onCall, if set, runs after recording the call but before returning, letting // a test bump gen mid-pass to exercise runRecheck's cancellation check. onCall func(txBytes []byte) @@ -46,9 +55,13 @@ func (r *recheckRunner) RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, _ in defer r.mu.Unlock() r.modes = append(r.modes, mode) r.seen[string(txBytes)] = true + r.calls = append(r.calls, string(txBytes)) if r.onCall != nil { r.onCall(txBytes) } + if err, ok := r.failErrs[string(txBytes)]; ok { + return sdk.GasInfo{}, nil, nil, err + } if r.failBytes[string(txBytes)] { _ = r.pool.Remove(tx) // baseapp removes on ante failure during recheck return sdk.GasInfo{}, nil, nil, errors.New("ante failed on recheck") @@ -975,41 +988,25 @@ func TestRecheckTxs_NilEncCacheEvictionNoPanic(t *testing.T) { const aliceSeq0Bytes = "alice-0" -// A generation bump mid-pass (a concurrent Commit's RefreshMempoolStateLocked) -// must cancel runRecheck's remaining candidates; the next RecheckTxs cycle -// re-covers the skipped one from the staging runRecheck restored. -func TestRecheckTxs_GenerationBumpMidPassSkipsRemainingCandidates(t *testing.T) { +// A generation bump cannot split one signer's group: gen only advances under +// stateMu, which recheckGroup holds for the whole group. The bump here is raised +// from inside RunTx (i.e. without stateMu) to show the group still completes, +// and that cancellation is a between-groups decision. +func TestRecheckTxs_GenerationBumpDoesNotSplitASignersGroup(t *testing.T) { f := newRecheckFixture() - first := f.add(1, "alice", 0, aliceSeq0Bytes) - second := f.add(2, "alice", 1, "alice-1") + f.add(1, "alice", 0, aliceSeq0Bytes) + f.add(2, "alice", 1, "alice-1") f.runner.onCall = func(txBytes []byte) { if string(txBytes) == aliceSeq0Bytes { - f.a.gen.Add(1) // simulate a Commit's refresh landing mid-pass + f.a.gen.Add(1) } } f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} f.a.RecheckTxs() - if !f.runner.seen[aliceSeq0Bytes] { - t.Fatal("the candidate validated before the bump must still run") - } - if f.runner.seen["alice-1"] { - t.Fatal("candidates after the generation bump must be skipped, not rechecked against a superseded base") - } - if !poolHas(f.pool, second) { - t.Fatal("a skipped (not rechecked) candidate must stay in the pool") - } - if !poolHas(f.pool, first) { - t.Fatal("the pre-bump candidate must still be evicted/kept per its own RunTx result") - } - - // No manual re-staging: runRecheck must have re-merged alice itself. - f.runner.onCall = nil - f.a.RecheckTxs() - - if !f.runner.seen["alice-1"] { - t.Fatal("the skipped candidate must be re-covered by the next RecheckTxs pass") + if !f.runner.seen[aliceSeq0Bytes] || !f.runner.seen["alice-1"] { + t.Fatal("both candidates of one signer must run under the same stateMu hold") } } @@ -1059,3 +1056,94 @@ func TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred(t *te t.Fatal("the deferred carry must still be rechecked by the next RecheckTxs cycle") } } + +const ( + carlSeq5Bytes = "carl-5" + carlSeq7Bytes = "carl-7" + carlSeq8Bytes = "carl-8" +) + +func TestRunRecheck_GroupsCandidatesBySigner(t *testing.T) { + f := newRecheckFixture() + aliceLow := f.add(1, "alice", 0, aliceSeq0Bytes) + bob := f.add(2, "bob", 0, "bob-0") + aliceHigh := f.add(3, "alice", 1, "alice-1") + + f.a.runRecheck([]sdk.Tx{aliceLow, bob, aliceHigh}, f.a.gen.Load()) + + want := []string{aliceSeq0Bytes, "alice-1", "bob-0"} + if !slices.Equal(f.runner.calls, want) { + t.Fatalf("candidates must run grouped by signer in first-appearance order: got %v, want %v", f.runner.calls, want) + } +} + +func TestRunRecheck_NonceGapCascadesToHigherSiblings(t *testing.T) { + f := newRecheckFixture() + valid := f.add(1, "carl", 5, carlSeq5Bytes) + gapped := f.add(2, "carl", 7, carlSeq7Bytes) + higher := f.add(3, "carl", 8, carlSeq8Bytes) + f.runner.failErrs = map[string]error{carlSeq7Bytes: errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} + + f.a.runRecheck([]sdk.Tx{valid, gapped, higher}, f.a.gen.Load()) + + if f.runner.seen[carlSeq8Bytes] { + t.Fatal("a sibling behind a proven nonce gap must be evicted without spending a RunTx") + } + if poolHas(f.pool, gapped) || poolHas(f.pool, higher) { + t.Fatal("the gapped tx and its higher-nonce siblings must be evicted") + } + if !poolHas(f.pool, valid) { + t.Fatal("the tx that passed recheck must stay in the pool") + } +} + +// A wrong-sequence failure with no accepted nonce before it may be a stale nonce +// (already committed), in which case the successor is the account's expected one. +func TestRunRecheck_StaleNonceDoesNotCascade(t *testing.T) { + f := newRecheckFixture() + stale := f.add(1, "carl", 5, carlSeq5Bytes) + next := f.add(2, "carl", 6, "carl-6") + f.runner.failErrs = map[string]error{carlSeq5Bytes: errorsmod.Wrap(sdkerrors.ErrInvalidSequence, "stale")} + + f.a.runRecheck([]sdk.Tx{stale, next}, f.a.gen.Load()) + + if !f.runner.seen["carl-6"] { + t.Fatal("the successor of a stale nonce must still be rechecked") + } + if poolHas(f.pool, stale) { + t.Fatal("the stale tx must be evicted") + } + if !poolHas(f.pool, next) { + t.Fatal("the successor must stay in the pool after passing recheck") + } +} + +func TestRunRecheck_NonNonceFailureDoesNotCascade(t *testing.T) { + f := newRecheckFixture() + valid := f.add(1, "carl", 5, carlSeq5Bytes) + failing := f.add(2, "carl", 7, carlSeq7Bytes) + higher := f.add(3, "carl", 8, carlSeq8Bytes) + f.runner.failErrs = map[string]error{carlSeq7Bytes: errorsmod.Wrap(sdkerrors.ErrInsufficientFunds, "no funds")} + + f.a.runRecheck([]sdk.Tx{valid, failing, higher}, f.a.gen.Load()) + + if !f.runner.seen[carlSeq8Bytes] { + t.Fatal("only a nonce gap justifies skipping a sibling's RunTx") + } +} + +// Cascading assumes the group is the signer's ascending-nonce view; a descending +// pair means the assumption doesn't hold, so every candidate keeps its RunTx. +func TestRunRecheck_OutOfOrderNoncesDisableCascade(t *testing.T) { + f := newRecheckFixture() + valid := f.add(1, "carl", 5, carlSeq5Bytes) + gapped := f.add(2, "carl", 9, "carl-9") + lower := f.add(3, "carl", 7, carlSeq7Bytes) + f.runner.failErrs = map[string]error{"carl-9": errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} + + f.a.runRecheck([]sdk.Tx{valid, gapped, lower}, f.a.gen.Load()) + + if !f.runner.seen[carlSeq7Bytes] { + t.Fatal("a non-ascending group must not cascade") + } +} From fb119ac21ba5b28c98a9a4a9ac69efc61e478f2b Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Wed, 29 Jul 2026 20:37:10 -0400 Subject: [PATCH 03/12] refactor(mempool): split Manager into admission and recheck halves 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. --- app/mempool/admitter.go | 129 ++++++ app/mempool/exec.go | 59 +++ app/mempool/manager.go | 688 +++--------------------------- app/mempool/manager_test.go | 46 +- app/mempool/recheck_async_test.go | 32 +- app/mempool/recheck_test.go | 306 ++++++------- app/mempool/scheduler.go | 489 +++++++++++++++++++++ app/mempool/state.go | 12 +- 8 files changed, 927 insertions(+), 834 deletions(-) create mode 100644 app/mempool/admitter.go create mode 100644 app/mempool/exec.go create mode 100644 app/mempool/scheduler.go diff --git a/app/mempool/admitter.go b/app/mempool/admitter.go new file mode 100644 index 0000000000..648f6a8d5b --- /dev/null +++ b/app/mempool/admitter.go @@ -0,0 +1,129 @@ +package mempool + +import ( + "fmt" + + abci "github.com/cometbft/cometbft/abci/types" + + errorsmod "cosmossdk.io/errors" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" +) + +// admitter is the admission half of the app mempool: peer-relayed InsertTx and +// RPC CheckTx, both validated by RunTx against the shared branch. +type admitter struct { + exec *txExec + trace bool + // preVerify runs cheap verification lock-free before the tx admission mutex; set to nil for skip. + preVerify func([]byte) error +} + +// admit is the shared admission path: preVerify + decode unlocked (bad txs skip +// the mutex), then RunTx(ExecModeCheck) + cacheTx under it. Over-capacity maps to +// CodeTypeRetry. tx stays nil when encCache is nil; BaseApp.RunTx accepts nil +// sdk.Tx (uses txBytes). +func (a *admitter) admit(txBytes []byte) (code uint32, codespace, log string) { + if a.preVerify != nil { + if err := a.preVerify(txBytes); err != nil { + cs, c, l := errorsmod.ABCIInfo(err, false) + return c, cs, l + } + } + + var tx sdk.Tx + if a.exec.encCache != nil { + var err error + if tx, err = a.exec.decoder(txBytes); err != nil { + cs, c, l := errorsmod.ABCIInfo(sdkerrors.ErrTxDecode.Wrap(err.Error()), false) + return c, cs, l + } + } + + a.exec.mu.Lock() + defer a.exec.mu.Unlock() + + _, _, _, err := a.exec.runTxLocked(sdk.ExecModeCheck, txBytes, tx) + if err != nil { + if errorsmod.IsOf(err, sdkmempool.ErrMempoolTxMaxCapacity) { + return abci.CodeTypeRetry, "", "mempool is full" + } + cs, c, l := errorsmod.ABCIInfo(err, false) + return c, cs, l + } + + a.cacheTx(tx, txBytes) + return abci.CodeTypeOK, "", "" +} + +// cacheTx registers the already-decoded tx under its canonical bytes (raw +// req.Tx bytes on encode error). No-op without a cache. +func (a *admitter) cacheTx(tx sdk.Tx, raw []byte) { + if a.exec.encCache == nil { + return + } + bz := raw + if canonical, err := a.exec.txEncoder(tx); err == nil { + bz = canonical + } + a.exec.encCache.Set(tx, bz) +} + +// insertTxHandler validates peer-relayed txs via RunTx(ExecModeCheck) before +// admitting them. +func (a *admitter) insertTxHandler() sdk.InsertTxHandler { + return func(req *abci.RequestInsertTx) (*abci.ResponseInsertTx, error) { + code, _, _ := a.admit(req.Tx) + return &abci.ResponseInsertTx{Code: code}, nil + } +} + +// checkTxHandler runs RPC CheckTx. It calls the runner directly instead of the +// runTx closure baseapp passes in (abci.go CheckTx), which hardcodes +// txMultiStore = nil; the exec-mode mapping below mirrors BaseApp.CheckTx so +// req.Type stays authoritative. +func (a *admitter) checkTxHandler() sdk.CheckTxHandler { + return func(_ sdk.RunTx, req *abci.RequestCheckTx) (*abci.ResponseCheckTx, error) { + var mode sdk.ExecMode + switch req.Type { + case abci.CheckTxType_New: + mode = sdk.ExecModeCheck + case abci.CheckTxType_Recheck: + mode = sdk.ExecModeReCheck + default: + return nil, fmt.Errorf("unknown RequestCheckTx type: %s", req.Type) + } + + // Decode before locking: proto unmarshal is CPU-intensive; decoder and + // DecodeCache have their own locks. Bad txs return without acquiring the mutex. + var tx sdk.Tx + if a.exec.encCache != nil { + var err error + if tx, err = a.exec.decoder(req.Tx); err != nil { + return sdkerrors.ResponseCheckTxWithEvents(sdkerrors.ErrTxDecode.Wrap(err.Error()), 0, 0, nil, a.trace), nil + } + } + + a.exec.mu.Lock() + defer a.exec.mu.Unlock() + + gasInfo, result, anteEvents, err := a.exec.runTxLocked(mode, req.Tx, tx) + if err != nil { + return sdkerrors.ResponseCheckTxWithEvents(err, gasInfo.GasWanted, gasInfo.GasUsed, anteEvents, a.trace), nil + } + + a.cacheTx(tx, req.Tx) + + // No MarkEventsToIndex (unlike default CheckTx): that flag only feeds + // the tx indexer on FinalizeBlock results, not CheckTx. + return &abci.ResponseCheckTx{ + GasWanted: int64(gasInfo.GasWanted), + GasUsed: int64(gasInfo.GasUsed), + Log: result.Log, + Data: result.Data, + Events: result.Events, + }, nil + } +} diff --git a/app/mempool/exec.go b/app/mempool/exec.go new file mode 100644 index 0000000000..ffb2c90249 --- /dev/null +++ b/app/mempool/exec.go @@ -0,0 +1,59 @@ +package mempool + +import ( + "sync" + "sync/atomic" + + abci "github.com/cometbft/cometbft/abci/types" + + "github.com/cosmos/cosmos-sdk/baseapp" + storetypes "github.com/cosmos/cosmos-sdk/store/v2/types" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +type txRunner interface { + RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, txIndex int, txMultiStore storetypes.MultiStore, incarnationCache map[string]any) (sdk.GasInfo, *sdk.Result, []abci.Event, error) +} + +var _ txRunner = (*baseapp.BaseApp)(nil) + +// txExec is what admission and recheck share: the RunTx entry point, the +// branched state both run against, and the codecs both need. +type txExec struct { + // mu guards state.base, the shared nonce authority for admission and recheck: + // RunTx is serialized through it, and App.Commit holds it across + // BaseApp.Commit() plus the post-Commit refresh so the swap never races a + // RunTx reader or the live memiavl tree mid-Commit. AppMempool.Lock() is a + // no-op, so mu also replaces the mempool lock BaseApp normally relies on. + // Held only around RunTx, never the lock-free pool scan. + mu sync.Mutex + runner txRunner + // state holds the CacheMultiStore branch RunTx uses in place of checkState. + // nil until the first refreshLocked call (after LoadLatestVersion in + // production, or never in the newManager() test constructor); store() returns + // nil in the meantime so RunTx's 5th arg falls back to checkState. + state *mempoolState + // gen counts state refreshes; a recheck pass abandons its remaining groups + // once gen advances, since they were selected against a superseded base. + gen atomic.Uint64 + encCache *EncoderCache + txEncoder sdk.TxEncoder + decoder sdk.TxDecoder +} + +// runTxLocked runs tx against the mempool's own branch instead of checkState. +// Precondition: the caller holds mu. +func (e *txExec) runTxLocked(mode sdk.ExecMode, bz []byte, tx sdk.Tx) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { + return e.runner.RunTx(mode, bz, tx, -1, e.state.store(), nil) +} + +// refreshLocked rebranches off the freshly committed store and bumps gen, +// canceling any recheck pass still validating against the superseded base. +// Precondition: the caller holds mu. +func (e *txExec) refreshLocked() { + if e.state == nil { + return + } + e.state.refreshLocked() + e.gen.Add(1) +} diff --git a/app/mempool/manager.go b/app/mempool/manager.go index cc9468786e..d44d0bbeb4 100644 --- a/app/mempool/manager.go +++ b/app/mempool/manager.go @@ -2,106 +2,45 @@ package mempool import ( "context" - "fmt" "sync" - "sync/atomic" "time" - abci "github.com/cometbft/cometbft/abci/types" - - errorsmod "cosmossdk.io/errors" - "github.com/cosmos/cosmos-sdk/baseapp" - storetypes "github.com/cosmos/cosmos-sdk/store/v2/types" "github.com/cosmos/cosmos-sdk/telemetry" sdk "github.com/cosmos/cosmos-sdk/types" - sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" ) -type txRunner interface { - RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, txIndex int, txMultiStore storetypes.MultiStore, incarnationCache map[string]any) (sdk.GasInfo, *sdk.Result, []abci.Event, error) -} - -var _ txRunner = (*baseapp.BaseApp)(nil) - -// Manager owns the app-side mempool for mempool.type=app +// Manager owns the app-side mempool for mempool.type=app. It is a facade over +// the two halves — admission and recheck — which share only txExec. type Manager struct { - // stateMu guards state.base, the shared nonce authority for admission and - // recheck: RunTx is serialized through it, and App.Commit holds it across - // BaseApp.Commit() plus the post-Commit refresh so the swap never races a - // RunTx reader or the live memiavl tree mid-Commit. AppMempool.Lock() is a - // no-op, so stateMu also replaces the mempool lock BaseApp normally relies - // on. Held only around RunTx, never the lock-free pool scan. - stateMu sync.Mutex - runner txRunner - encCache *EncoderCache - txEncoder sdk.TxEncoder - trace bool - // preVerify runs cheap verification lock-free before the tx admission mutex; set to nil for skip. - preVerify func([]byte) error - // state holds the CacheMultiStore branch RunTx uses in place of checkState. - // nil until the first RefreshMempoolStateLocked call (after LoadLatestVersion - // in production, or never in the newManager() test constructor); store() - // returns nil in the meantime so RunTx's 5th arg falls back to checkState. - state *mempoolState - // gen counts mempoolState refreshes; runRecheck aborts a pass once gen - // advances mid-flight, since its candidates were validated against a - // now-superseded base. - gen atomic.Uint64 - - mpool sdkmempool.Mempool - signer sdkmempool.SignerExtractionAdapter - decoder sdk.TxDecoder - // maxRecheckBatch caps RunTx(ReCheck) calls per Commit cycle; 0 = unlimited. - maxRecheckBatch int - // stagingMu guards the staging fields (recheckSenders, deferred, lastCommittedHeight). - // Separate from mu so FinalizeBlock staging never blocks behind mu's RunTx batches. - stagingMu sync.Mutex - // recheckSenders accumulates senders of committed blocks awaiting recheck; merged - // (not overwritten) across blocks so an un-drained block's senders aren't lost. - recheckSenders map[string]struct{} - // deferred carries candidates past maxRecheckBatch to the next cycle, so a - // deep per-sender queue eventually drains instead of being silently dropped. - deferred []sdk.Tx - lastCommittedHeight int64 - // arrival maps each pooled tx to the height RecheckTxs first observed it, for - // ttlNumBlocks eviction. Rebuilt from the snapshot each cycle; recheckMu keeps it single-writer. - arrival map[sdk.Tx]int64 - // ttlNumBlocks evicts txs older than this many blocks by arrival height; 0 = off. - ttlNumBlocks int64 - - recheckMu sync.Mutex // serializes RecheckTxs; always acquired before mu and stagingMu, never after - // Zero-value (trigger nil) when built via the newManager() test constructor; - // TriggerRecheck then runs RecheckTxs inline instead of async. - worker recheckWorker - // recheckDisabled mirrors mempool.recheck=false: skips all rechecking, - // including TTL/expiry eviction - recheckDisabled bool + exec *txExec + adm *admitter + sched *recheckScheduler } // NewManager builds the Manager for mempool.type=app; func NewManager(app *baseapp.BaseApp, encCache *EncoderCache, txEncoder sdk.TxEncoder, mpool sdkmempool.Mempool, signer sdkmempool.SignerExtractionAdapter, decoder sdk.TxDecoder, recheckBatchSize int, ttlNumBlocks int64, recheckDisabled bool) *Manager { a := newManager(app, encCache, txEncoder, decoder) - a.trace = app.Trace() - a.mpool = mpool - a.signer = signer - a.maxRecheckBatch = recheckBatchSize - a.ttlNumBlocks = ttlNumBlocks - a.recheckDisabled = recheckDisabled - a.state = &mempoolState{provider: app.CommitMultiStore} + a.adm.trace = app.Trace() + a.sched.mpool = mpool + a.sched.signer = signer + a.sched.maxRecheckBatch = recheckBatchSize + a.sched.ttlNumBlocks = ttlNumBlocks + a.sched.recheckDisabled = recheckDisabled // Left unrefreshed here: NewManager runs inside baseAppOptions, before // LoadLatestVersion, so branching now would read an unloaded store. state.base // stays nil until App wires the first RefreshMempoolStateLocked call after // LoadLatestVersion succeeds; store() falling back to nil (checkState) until // then is the correct degradation. + a.exec.state = &mempoolState{provider: app.CommitMultiStore} recheckEnabledGauge := float32(0) if !recheckDisabled { recheckEnabledGauge = 1 } telemetry.SetGauge(recheckEnabledGauge, "cronos", "mempool", "recheck", "enabled") - a.worker = newRecheckWorker(a.RecheckTxs) - a.worker.start() + a.sched.worker = newRecheckWorker(a.sched.RecheckTxs) + a.sched.worker.start() return a } @@ -114,631 +53,108 @@ func newManager(runner txRunner, encCache *EncoderCache, txEncoder sdk.TxEncoder panic("mempool: encCache requires txEncoder != nil for canonical bytes") } } - return &Manager{ + exec := &txExec{ runner: runner, encCache: encCache, txEncoder: txEncoder, decoder: decoder, } -} - -// recheckDecodingEnabled reports whether sender decoding/bookkeeping should run. -func (a *Manager) recheckDecodingEnabled() bool { - return !a.recheckDisabled && a.signer != nil && a.decoder != nil -} - -// StageSkippedSenders merges the senders of proposal-gate-rejected txs into -// recheckSenders without touching lastCommittedHeight -func (a *Manager) StageSkippedSenders(txs [][]byte) { - if !a.recheckDecodingEnabled() || len(txs) == 0 { - return - } - senders := make(map[string]struct{}, len(txs)) - for _, bz := range txs { - tx, err := a.decoder(bz) - if err != nil { - continue - } - for _, s := range a.signers(tx) { - senders[s] = struct{}{} - } - } - if len(senders) == 0 { - return - } - a.stagingMu.Lock() - a.mergeRecheckSenders(senders) - a.stagingMu.Unlock() -} - -func (a *Manager) mergeRecheckSenders(senders map[string]struct{}) { - // mergeRecheckSenders folds senders into a.recheckSenders without overwriting, so a - // block whose Commit skipped RecheckTxs doesn't lose its staged senders. - if a.recheckSenders == nil { - a.recheckSenders = senders - } else { - for s := range senders { - a.recheckSenders[s] = struct{}{} - } + return &Manager{ + exec: exec, + adm: &admitter{exec: exec}, + sched: &recheckScheduler{exec: exec}, } } -// AdmissionMutex exposes stateMu so App.Commit can serialize BaseApp.Commit() -// and the mempoolState refresh against RunTx-based admission and recheck. +// AdmissionMutex exposes the admission mutex so App.Commit can serialize +// BaseApp.Commit() and the mempoolState refresh against admission and recheck. func (a *Manager) AdmissionMutex() *sync.Mutex { - return &a.stateMu + return &a.exec.mu } -// RefreshMempoolStateLocked branches mempoolState off the freshly committed -// store and bumps gen, canceling any recheck pass still validating against -// the superseded base. Precondition: the caller holds stateMu (AdmissionMutex), -// which App.Commit does across BaseApp.Commit() and this call. No-op when -// state is nil (newManager() test constructor). +// RefreshMempoolStateLocked rebranches the mempool state off the freshly +// committed store. Precondition: the caller holds AdmissionMutex, which +// App.Commit does across BaseApp.Commit() and this call. func (a *Manager) RefreshMempoolStateLocked() { - if a.state == nil { - return - } - a.state.refreshLocked() - a.gen.Add(1) + a.exec.refreshLocked() } // SetPreVerify sets the pre-verification hook. func (a *Manager) SetPreVerify(fn func([]byte) error) { - a.preVerify = fn + a.adm.preVerify = fn } -// InsertTxHandler validates peer-relayed txs via RunTx(ExecModeCheck) before -// admitting them. func (a *Manager) InsertTxHandler() sdk.InsertTxHandler { - return func(req *abci.RequestInsertTx) (*abci.ResponseInsertTx, error) { - code, _, _ := a.admit(req.Tx) - return &abci.ResponseInsertTx{Code: code}, nil - } + return a.adm.insertTxHandler() +} + +func (a *Manager) CheckTxHandler() sdk.CheckTxHandler { + return a.adm.checkTxHandler() } // InsertTx returns the sync ABCI result; error is always nil (failures surface as ABCI codes). func (a *Manager) InsertTx(txBytes []byte) (*sdk.TxResponse, error) { - code, codespace, log := a.admit(txBytes) + code, codespace, log := a.adm.admit(txBytes) return &sdk.TxResponse{Code: code, Codespace: codespace, RawLog: log}, nil } func (a *Manager) PendingTxs() []sdk.Tx { - if a.mpool == nil { + if a.sched.mpool == nil { return nil } - return PoolSnapshot(context.Background(), a.mpool) + return PoolSnapshot(context.Background(), a.sched.mpool) } func (a *Manager) CountTx() int { - if a.mpool == nil { + if a.sched.mpool == nil { return 0 } - return a.mpool.CountTx() + return a.sched.mpool.CountTx() } // RecheckDisabled reports whether mempool recheck is disabled func (a *Manager) RecheckDisabled() bool { - return a.recheckDisabled -} - -// admit is the shared admission path: preVerify + decode unlocked (bad txs skip -// mu), then RunTx(ExecModeCheck) + cacheTx under mu. Over-capacity maps to -// CodeTypeRetry. tx stays nil when encCache is nil; BaseApp.RunTx accepts nil -// sdk.Tx (uses txBytes). -func (a *Manager) admit(txBytes []byte) (code uint32, codespace, log string) { - if a.preVerify != nil { - if err := a.preVerify(txBytes); err != nil { - cs, c, l := errorsmod.ABCIInfo(err, false) - return c, cs, l - } - } - - var tx sdk.Tx - if a.encCache != nil { - var err error - if tx, err = a.decoder(txBytes); err != nil { - cs, c, l := errorsmod.ABCIInfo(sdkerrors.ErrTxDecode.Wrap(err.Error()), false) - return c, cs, l - } - } - - a.stateMu.Lock() - defer a.stateMu.Unlock() - - _, _, _, err := a.runner.RunTx(sdk.ExecModeCheck, txBytes, tx, -1, a.state.store(), nil) - if err != nil { - if errorsmod.IsOf(err, sdkmempool.ErrMempoolTxMaxCapacity) { - return abci.CodeTypeRetry, "", "mempool is full" - } - cs, c, l := errorsmod.ABCIInfo(err, false) - return c, cs, l - } - - a.cacheTx(tx, txBytes) - return abci.CodeTypeOK, "", "" + return a.sched.recheckDisabled } -// cacheTx registers the already-decoded tx under its canonical bytes (raw -// req.Tx bytes on encode error). No-op without a cache. -func (a *Manager) cacheTx(tx sdk.Tx, raw []byte) { - if a.encCache == nil { - return - } - bz := raw - if canonical, err := a.txEncoder(tx); err == nil { - bz = canonical - } - a.encCache.Set(tx, bz) -} - -// CheckTxHandler runs RPC CheckTx. It calls the runner directly instead of the -// runTx closure baseapp passes in (abci.go CheckTx), which hardcodes -// txMultiStore = nil; the exec-mode mapping below mirrors BaseApp.CheckTx so -// req.Type stays authoritative. -func (a *Manager) CheckTxHandler() sdk.CheckTxHandler { - return func(_ sdk.RunTx, req *abci.RequestCheckTx) (*abci.ResponseCheckTx, error) { - var mode sdk.ExecMode - switch req.Type { - case abci.CheckTxType_New: - mode = sdk.ExecModeCheck - case abci.CheckTxType_Recheck: - mode = sdk.ExecModeReCheck - default: - return nil, fmt.Errorf("unknown RequestCheckTx type: %s", req.Type) - } - - // Decode before locking: proto unmarshal is CPU-intensive; decoder and - // DecodeCache have their own locks. Bad txs return without acquiring stateMu. - var tx sdk.Tx - if a.encCache != nil { - var err error - if tx, err = a.decoder(req.Tx); err != nil { - return sdkerrors.ResponseCheckTxWithEvents(sdkerrors.ErrTxDecode.Wrap(err.Error()), 0, 0, nil, a.trace), nil - } - } - - a.stateMu.Lock() - defer a.stateMu.Unlock() - - gasInfo, result, anteEvents, err := a.runner.RunTx(mode, req.Tx, tx, -1, a.state.store(), nil) - if err != nil { - return sdkerrors.ResponseCheckTxWithEvents(err, gasInfo.GasWanted, gasInfo.GasUsed, anteEvents, a.trace), nil - } - - a.cacheTx(tx, req.Tx) - - // No MarkEventsToIndex (unlike default CheckTx): that flag only feeds - // the tx indexer on FinalizeBlock results, not CheckTx. - return &abci.ResponseCheckTx{ - GasWanted: int64(gasInfo.GasWanted), - GasUsed: int64(gasInfo.GasUsed), - Log: result.Log, - Data: result.Data, - Events: result.Events, - }, nil - } -} - -// StageRecheckSenders records the senders of the just-committed block's txs so -// RecheckTxs can re-validate only their remaining pending txs, and stages the -// committed height. func (a *Manager) StageRecheckSenders(height int64, txs [][]byte) { - // Decode + extract signers unlocked (the expensive part), then publish height - // and recheckSenders in one critical section so a reader never sees a torn update. - var senders map[string]struct{} - if a.recheckDecodingEnabled() { - senders = make(map[string]struct{}, len(txs)) - for _, bz := range txs { - tx, err := a.decoder(bz) - if err != nil { - continue // non-sdk txs (e.g. vote extensions) have no mempool entry - } - for _, s := range a.signers(tx) { - senders[s] = struct{}{} - } - } - } + a.sched.stageRecheckSenders(height, txs) +} - a.stagingMu.Lock() - a.lastCommittedHeight = height - a.mergeRecheckSenders(senders) - a.stagingMu.Unlock() +func (a *Manager) StageSkippedSenders(txs [][]byte) { + a.sched.stageSkippedSenders(txs) } // TriggerRecheck schedules an async recheck. // Call only from the consensus path (App.Commit). func (a *Manager) TriggerRecheck() { - if a.worker.trigger == nil { - a.RecheckTxs() - return - } - a.worker.recheck() + a.sched.triggerRecheck() +} + +func (a *Manager) RecheckTxs() { + a.sched.RecheckTxs() } // Close stops the recheck worker. func (a *Manager) Close() { - a.worker.stop() + a.sched.worker.stop() } // WaitForRecheck blocks until the pending recheck finishes; func (a *Manager) WaitForRecheck(ctx context.Context) { - if a.worker.trigger == nil { + if a.sched.worker.trigger == nil { return } - a.worker.wait(ctx) + a.sched.worker.wait(ctx) } // WaitForRecheckTimedOut is WaitForRecheck bounded by timeout, reporting whether the // timeout was hit. func (a *Manager) WaitForRecheckTimedOut(ctx context.Context, timeout time.Duration) bool { - if a.worker.trigger == nil { + if a.sched.worker.trigger == nil { return false } waitCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - return a.worker.wait(waitCtx) -} - -// RecheckTxs evicts pool txs invalidated by the last block. -func (a *Manager) RecheckTxs() { - if a.mpool == nil || a.recheckDisabled { - return - } - a.recheckMu.Lock() // lock order: see the recheckMu field comment - defer a.recheckMu.Unlock() - recheckSenders, height, deferred := a.drainStaging() - gen := a.gen.Load() - // Before the first block (height 0) with no senders/carry there's nothing to scan. - if len(recheckSenders) == 0 && len(deferred) == 0 && height == 0 { - return - } - - snapshot := PoolSnapshot(context.Background(), a.mpool) - candidates := a.capRecheckTxs(a.selectTxs(snapshot, recheckSenders, height, deferred)) - a.runRecheck(candidates, gen) - - telemetry.SetGauge(float32(a.mpool.CountTx()), "cronos", "mempool", "pool", "size") -} - -// drainStaging atomically takes and clears the staged senders, height, and carry. -func (a *Manager) drainStaging() (recheckSenders map[string]struct{}, height int64, deferred []sdk.Tx) { - a.stagingMu.Lock() - defer a.stagingMu.Unlock() - recheckSenders, height, deferred = a.recheckSenders, a.lastCommittedHeight, a.deferred - a.recheckSenders = nil - a.deferred = nil - return recheckSenders, height, deferred -} - -// selectTxs scans the pool to retrieve txs for recheck. Caller (RecheckTxs) -// only invokes this when recheck is enabled. -func (a *Manager) selectTxs(snapshot []sdk.Tx, recheckSenders map[string]struct{}, height int64, deferred []sdk.Tx) []sdk.Tx { - // deferredLive: carried-over tx -> still in pool. Sized to the small carry; nil if none. - var deferredLive map[sdk.Tx]bool - if len(deferred) > 0 { - deferredLive = make(map[sdk.Tx]bool, len(deferred)) - for _, tx := range deferred { - deferredLive[tx] = false - } - } - - var ( - expiredEvicted float32 - ttlEvicted float32 - ) - // Rebuild arrival from this cycle's snapshot so txs gone from the pool fall out. - var newArrival map[sdk.Tx]int64 - if a.ttlNumBlocks > 0 { - newArrival = make(map[sdk.Tx]int64, len(snapshot)) - } - - // 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() - for _, tx := range snapshot { - if txTimedout(tx, height, now) { - evictedSet, recheckSenders = a.evictForRecheck(tx, evictedSet, recheckSenders) - expiredEvicted++ - continue - } - if a.ttlNumBlocks > 0 { - arrived, expired := txTTLExpired(a.arrival, tx, height, a.ttlNumBlocks) - if expired { - evictedSet, recheckSenders = a.evictForRecheck(tx, evictedSet, recheckSenders) - ttlEvicted++ - continue - } - newArrival[tx] = arrived - } - } - a.arrival = newArrival - if expiredEvicted > 0 { - telemetry.IncrCounter(expiredEvicted, "cronos", "mempool", "recheck", "expired") - } - if ttlEvicted > 0 { - telemetry.IncrCounter(ttlEvicted, "cronos", "mempool", "recheck", "ttl_expired") - } - - // Pass 2: candidate selection over surviving (non-evicted) txs. - var candidates []sdk.Tx - for _, tx := range snapshot { - if _, wasEvicted := evictedSet[tx]; wasEvicted { - continue - } - if deferredLive != nil { - if _, isDeferred := deferredLive[tx]; isDeferred { - deferredLive[tx] = true - } - } - if len(recheckSenders) == 0 { - continue - } - for _, s := range a.signers(tx) { - if _, ok := recheckSenders[s]; ok { - candidates = append(candidates, tx) - break - } - } - } - - if len(deferred) == 0 { - return candidates - } - // Front-load surviving deferred ahead of fresh candidates: the snapshot is - // priority-ordered, so otherwise capRecheckTxs re-takes the same prefix and starves the tail. - ordered := make([]sdk.Tx, 0, len(deferred)+len(candidates)) - for _, tx := range deferred { - if deferredLive[tx] { - ordered = append(ordered, tx) // skip txs included/evicted since carry - } - } - for _, tx := range candidates { - if _, isDeferred := deferredLive[tx]; isDeferred { - continue // sender re-touched this cycle; avoid double recheck - } - ordered = append(ordered, tx) - } - return ordered -} - -// evictForRecheck evicts tx and folds its signers into recheckSenders, allocating -// evictedSet/recheckSenders lazily so a no-eviction cycle stays alloc-free. -func (a *Manager) evictForRecheck(tx sdk.Tx, evictedSet map[sdk.Tx]struct{}, recheckSenders map[string]struct{}) (map[sdk.Tx]struct{}, map[string]struct{}) { - a.evict(tx) - if evictedSet == nil { - evictedSet = make(map[sdk.Tx]struct{}) - } - evictedSet[tx] = struct{}{} - sigs := a.signers(tx) - if len(sigs) > 0 && recheckSenders == nil { - recheckSenders = make(map[string]struct{}) - } - for _, s := range sigs { - recheckSenders[s] = struct{}{} - } - return evictedSet, recheckSenders -} - -// capRecheckTxs bounds RunTx(ReCheck) per cycle; overflow carries forward. -func (a *Manager) capRecheckTxs(candidates []sdk.Tx) []sdk.Tx { - if a.maxRecheckBatch <= 0 || len(candidates) <= a.maxRecheckBatch { - return candidates - } - carried := make([]sdk.Tx, len(candidates)-a.maxRecheckBatch) - copy(carried, candidates[a.maxRecheckBatch:]) - a.stagingMu.Lock() - a.deferred = carried - a.stagingMu.Unlock() - return candidates[:a.maxRecheckBatch] -} - -// recheckCandidate carries the signer nonce alongside the tx: telling a nonce -// gap from a merely stale nonce is what makes cascade eviction safe. -type recheckCandidate struct { - tx sdk.Tx - bz []byte - seq uint64 -} - -// recheckGroup holds one signer's candidates in pool order. cascadable is false -// when the group is not that signer's contiguous ascending-nonce view — an -// unknown signer, a repeated or out-of-order nonce, or a tx dropped on encode -// error — because the cascade rule reasons about the next expected nonce. -type recheckGroup struct { - txs []recheckCandidate - cascadable bool -} - -// runRecheck re-validates candidates via RunTx(ReCheck), one signer group at a -// time so a sender's nonce chain advances atomically with respect to other -// senders' admissions. The pass is abandoned once gen advances mid-flight: the -// remaining candidates would be validated against a base a concurrent Commit -// has already superseded. drainStaging already cleared recheckSenders for this -// cycle, so the unreached candidates' senders are re-merged into staging here — -// otherwise a sender that isn't touched again by a later block would never be -// rechecked until TTL. -func (a *Manager) runRecheck(candidates []sdk.Tx, gen uint64) { - var evicted, cascaded, superseded float32 - groups := a.groupCandidates(candidates) - for i, g := range groups { - if len(g.txs) == 0 { - continue - } - e, c, aborted := a.recheckGroup(g, gen) - evicted += e - cascaded += c - if aborted { - unreached := unreachedTxs(groups[i:]) - superseded = float32(len(unreached)) - a.recoverSenders(unreached) - break - } - } - if evicted > 0 { - telemetry.IncrCounter(evicted, "cronos", "mempool", "recheck", "evicted") - } - if cascaded > 0 { - telemetry.IncrCounter(cascaded, "cronos", "mempool", "recheck", "cascade_evicted") - } - if superseded > 0 { - telemetry.IncrCounter(superseded, "cronos", "mempool", "recheck", "superseded") - } -} - -// groupCandidates buckets candidates by first signer — the one the mempool -// orders by — keeping first-appearance order across groups and pool order -// within one, so the front-loaded deferred prefix still runs first. Encoding -// happens here, outside stateMu, to keep the per-group lock hold to RunTx. -func (a *Manager) groupCandidates(candidates []sdk.Tx) []recheckGroup { - groups := make([]recheckGroup, 0, len(candidates)) - index := make(map[string]int, len(candidates)) - for _, tx := range candidates { - key, seq, known := a.firstSigner(tx) - gi, seen := index[key] - if !seen { - groups = append(groups, recheckGroup{cascadable: known}) - gi = len(groups) - 1 - index[key] = gi - } - g := &groups[gi] - bz, _, err := EncodeTx(a.encCache, a.txEncoder, tx) - if err != nil { - g.cascadable = false - continue - } - if n := len(g.txs); n > 0 && seq <= g.txs[n-1].seq { - g.cascadable = false - } - g.txs = append(g.txs, recheckCandidate{tx: tx, bz: bz, seq: seq}) - } - return groups -} - -// recheckGroup re-validates one signer's candidates under a single stateMu hold. -// Reports aborted when gen advanced before the group started, leaving the group -// untouched. On a nonce gap the remaining higher-nonce siblings are evicted -// without spending a RunTx on each: nothing can fill the gap while they sit in -// the pool. Any other failure evicts only the failing tx, since a later sibling -// may still be the account's next expected nonce. -func (a *Manager) recheckGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, aborted bool) { - a.stateMu.Lock() - defer a.stateMu.Unlock() - // gen only advances under stateMu, so it cannot change once this group starts. - if a.gen.Load() != gen { - return 0, 0, true - } - - var lastOK uint64 - haveOK := false - for i, c := range g.txs { - _, _, _, err := a.runner.RunTx(sdk.ExecModeReCheck, c.bz, c.tx, -1, a.state.store(), nil) - if err == nil { - lastOK, haveOK = c.seq, true - continue - } - a.evict(c.tx) - evicted++ - // A gap is only provable relative to a nonce this pass just accepted; - // without one the failure may be a stale nonce, whose successor is valid. - if g.cascadable && haveOK && c.seq > lastOK+1 && isNonceErr(err) { - for _, rest := range g.txs[i+1:] { - a.evict(rest.tx) - cascaded++ - } - return evicted, cascaded, false - } - } - return evicted, cascaded, false -} - -// isNonceErr matches both ante paths: cosmos sig verification reports -// ErrWrongSequence, the EVM nonce check reports ErrInvalidSequence. -func isNonceErr(err error) bool { - return errorsmod.IsOf(err, sdkerrors.ErrWrongSequence, sdkerrors.ErrInvalidSequence) -} - -func unreachedTxs(groups []recheckGroup) []sdk.Tx { - var txs []sdk.Tx - for _, g := range groups { - for _, c := range g.txs { - txs = append(txs, c.tx) - } - } - return txs -} - -// recoverSenders folds txs' senders back into staged recheckSenders without -// touching deferred, which capRecheckTxs may have already set this cycle. -func (a *Manager) recoverSenders(txs []sdk.Tx) { - senders := make(map[string]struct{}) - for _, tx := range txs { - for _, s := range a.signers(tx) { - senders[s] = struct{}{} - } - } - if len(senders) == 0 { - return - } - a.stagingMu.Lock() - a.mergeRecheckSenders(senders) - a.stagingMu.Unlock() -} - -// txTimedout reports whether tx should be evicted by its own declared timeout: -func txTimedout(tx sdk.Tx, height int64, now time.Time) bool { - if t, ok := tx.(sdk.TxWithTimeoutHeight); ok { - th := t.GetTimeoutHeight() - if th > 0 && uint64(height) >= th { - return true - } - } - if t, ok := tx.(sdk.TxWithTimeoutTimeStamp); ok { - ts := t.GetTimeoutTimeStamp() - if !ts.IsZero() && !now.Before(ts) { - return true - } - } - return false -} - -// txTTLExpired reports whether tx has aged past ttlNumBlocks since first seen. -func txTTLExpired(arrival map[sdk.Tx]int64, tx sdk.Tx, height, ttlNumBlocks int64) (int64, bool) { - arrived, ok := arrival[tx] - if !ok { - arrived = height - } - return arrived, height-arrived >= ttlNumBlocks -} - -// evict removes tx from the pool and encoder cache together, so the cache never -// outlives its pool entry. -func (a *Manager) evict(tx sdk.Tx) { - _ = a.mpool.Remove(tx) - a.encCache.Evict(tx) -} - -// firstSigner returns the signer the mempool orders by, with its nonce. An -// unknown signer only costs the cascade optimization, not the recheck itself. -func (a *Manager) firstSigner(tx sdk.Tx) (key string, seq uint64, known bool) { - if a.signer == nil { - return "", 0, false - } - sigs, err := a.signer.GetSigners(tx) - if err != nil || len(sigs) == 0 { - return "", 0, false - } - return sigs[0].Signer.String(), sigs[0].Sequence, true -} - -func (a *Manager) signers(tx sdk.Tx) []string { - sigs, err := a.signer.GetSigners(tx) - if err != nil { - return nil - } - keys := make([]string, len(sigs)) - for i, s := range sigs { - keys[i] = s.Signer.String() - } - return keys + return a.sched.worker.wait(waitCtx) } diff --git a/app/mempool/manager_test.go b/app/mempool/manager_test.go index bbb04a6e35..84c0ae662e 100644 --- a/app/mempool/manager_test.go +++ b/app/mempool/manager_test.go @@ -402,8 +402,8 @@ func TestManager_InsertAndCheckShareMutex(t *testing.T) { insert := a.InsertTxHandler() check := a.CheckTxHandler() - // CheckTxHandler drives a.runner directly (the same lock-free raceRunner - // InsertTx writes through), so -race flags either path if it skips stateMu. + // CheckTxHandler drives a.exec.runner directly (the same lock-free raceRunner + // InsertTx writes through), so -race flags either path if it skips the admission mutex. const goroutines = 16 const perG = 64 var wg sync.WaitGroup @@ -568,7 +568,7 @@ func TestManagerInsertTx_NoRegisterOnReject(t *testing.T) { // TestManagerInsertTx_SharesAdmitWithHandler proves the RPC InsertTx and the // gossip InsertTxHandler run the same admission body under one mutex: both drive -// the lock-free raceRunner concurrently, which -race flags if a path skips a.mu. +// the lock-free raceRunner concurrently, which -race flags if a path skips the admission mutex. func TestManagerInsertTx_SharesAdmitWithHandler(t *testing.T) { runner := &raceRunner{state: make(map[string]struct{})} a := newManager(runner, nil, noopEncoder, nil) @@ -628,7 +628,7 @@ func TestManagerPendingTxs(t *testing.T) { } tx1, tx2 := &ptrTx{}, &ptrTx{} - a.mpool = &fakePool{txs: []sdk.Tx{tx1, tx2}} + a.sched.mpool = &fakePool{txs: []sdk.Tx{tx1, tx2}} got := a.PendingTxs() if len(got) != 2 || got[0] != tx1 || got[1] != tx2 { @@ -642,7 +642,7 @@ func TestManagerCountTx(t *testing.T) { t.Fatalf("nil mpool must report 0, got %d", got) } - a.mpool = &fakePool{txs: []sdk.Tx{&ptrTx{}, &ptrTx{}, &ptrTx{}}} + a.sched.mpool = &fakePool{txs: []sdk.Tx{&ptrTx{}, &ptrTx{}, &ptrTx{}}} if got := a.CountTx(); got != 3 { t.Fatalf("want 3, got %d", got) } @@ -667,14 +667,14 @@ func TestManager_AllThreeRunTxSitesShareBaseInstance(t *testing.T) { runner := &msCaptureRunner{} a := newManager(runner, nil, noopEncoder, nil) base := newFakeCacheStore() - a.state = &mempoolState{base: base} - a.mpool = &fakePool{} - a.signer = fakeSigner{m: map[sdk.Tx][]sdkmempool.SignerData{}} + a.exec.state = &mempoolState{base: base} + a.sched.mpool = &fakePool{} + a.sched.signer = fakeSigner{m: map[sdk.Tx][]sdkmempool.SignerData{}} - a.admit([]byte("tx1")) + a.adm.admit([]byte("tx1")) check := a.CheckTxHandler() check(nil, &abci.RequestCheckTx{Tx: []byte("tx2")}) //nolint:errcheck - a.runRecheck([]sdk.Tx{&ptrTx{id: 1}}, a.gen.Load()) + a.sched.runRecheck([]sdk.Tx{&ptrTx{id: 1}}, a.exec.gen.Load()) if len(runner.ms) != 3 { t.Fatalf("expected 3 RunTx calls (admit, CheckTxHandler, runRecheck), got %d", len(runner.ms)) @@ -742,11 +742,11 @@ func (r *nonceBranchRunner) RunTx(mode sdk.ExecMode, _ []byte, _ sdk.Tx, _ int, func TestManager_RecheckWriteVisibleToLaterAdmit(t *testing.T) { store := newFakeNonceStore() a := newManager(&nonceBranchRunner{}, nil, noopEncoder, nil) - a.state = &mempoolState{base: store} + a.exec.state = &mempoolState{base: store} - a.runRecheck([]sdk.Tx{&ptrTx{id: 1}}, a.gen.Load()) + a.sched.runRecheck([]sdk.Tx{&ptrTx{id: 1}}, a.exec.gen.Load()) - code, _, log := a.admit([]byte("alice-nonce-8-sibling")) + code, _, log := a.adm.admit([]byte("alice-nonce-8-sibling")) if code != abci.CodeTypeOK { t.Fatalf("admission must see recheck's nonce write-back through the shared base, got code=%d log=%q", code, log) } @@ -756,25 +756,25 @@ func TestManager_RefreshMempoolStateLockedSwapsBaseAndBumpsGen(t *testing.T) { first, second := newFakeCacheStore(), newFakeCacheStore() calls := 0 a := newManager(&stubRunner{}, nil, noopEncoder, nil) - a.state = &mempoolState{provider: func() storetypes.CommitMultiStore { + a.exec.state = &mempoolState{provider: func() storetypes.CommitMultiStore { calls++ if calls == 1 { return &fakeCommitStore{cache: first} } return &fakeCommitStore{cache: second} }} - a.state.refreshLocked() // mirrors NewManager's initial refresh - if got := a.state.store(); got != storetypes.MultiStore(first) { + a.exec.state.refreshLocked() // mirrors NewManager's initial refresh + if got := a.exec.state.store(); got != storetypes.MultiStore(first) { t.Fatalf("expected initial base, got %v", got) } - beforeGen := a.gen.Load() + beforeGen := a.exec.gen.Load() a.RefreshMempoolStateLocked() - if got := a.state.store(); got != storetypes.MultiStore(second) { + if got := a.exec.state.store(); got != storetypes.MultiStore(second) { t.Fatal("RefreshMempoolStateLocked must swap base identity") } - if got := a.gen.Load(); got != beforeGen+1 { + if got := a.exec.gen.Load(); got != beforeGen+1 { t.Fatalf("RefreshMempoolStateLocked must bump gen, got %d want %d", got, beforeGen+1) } } @@ -782,7 +782,7 @@ func TestManager_RefreshMempoolStateLockedSwapsBaseAndBumpsGen(t *testing.T) { func TestManager_RefreshMempoolStateLockedNoopWithoutState(t *testing.T) { a := newManager(&stubRunner{}, nil, noopEncoder, nil) // state nil (test ctor) a.RefreshMempoolStateLocked() // must not panic - if a.gen.Load() != 0 { + if a.exec.gen.Load() != 0 { t.Fatal("nil state must leave gen untouched") } } @@ -797,12 +797,12 @@ func TestManager_NilBaseBeforeFirstRefresh(t *testing.T) { a := newManager(runner, nil, noopEncoder, nil) base := newFakeCacheStore() calls := 0 - a.state = &mempoolState{provider: func() storetypes.CommitMultiStore { + a.exec.state = &mempoolState{provider: func() storetypes.CommitMultiStore { calls++ return &fakeCommitStore{cache: base} }} // provider wired, no refreshLocked call yet: mirrors NewManager exactly - a.admit([]byte("pre-refresh")) + a.adm.admit([]byte("pre-refresh")) if len(runner.ms) != 1 || runner.ms[0] != nil { t.Fatalf("admit before the first refresh must pass a nil store (checkState fallback), got %v", runner.ms) } @@ -812,7 +812,7 @@ func TestManager_NilBaseBeforeFirstRefresh(t *testing.T) { a.RefreshMempoolStateLocked() // mirrors App's post-LoadLatestVersion call - a.admit([]byte("post-refresh")) + a.adm.admit([]byte("post-refresh")) if len(runner.ms) != 2 || runner.ms[1] != storetypes.MultiStore(base) { t.Fatalf("admit after the first refresh must pass the wired base, got %v", runner.ms) } diff --git a/app/mempool/recheck_async_test.go b/app/mempool/recheck_async_test.go index 6eb4a73afd..3a2a3fcb01 100644 --- a/app/mempool/recheck_async_test.go +++ b/app/mempool/recheck_async_test.go @@ -21,8 +21,8 @@ func newAsyncRecheckFixture(t *testing.T, failBytes ...string) *recheckFixture { // startAsyncWorker does NOT register a Cleanup — caller owns Close(). func startAsyncWorker(f *recheckFixture) { - f.a.worker = newRecheckWorker(f.a.RecheckTxs) - f.a.worker.start() + f.a.sched.worker = newRecheckWorker(f.a.sched.RecheckTxs) + f.a.sched.worker.start() } func waitUntil(t *testing.T, cond func() bool, timeout time.Duration, msg string) { @@ -41,7 +41,7 @@ func TestTriggerRecheck_WakesWorker(t *testing.T) { f := newAsyncRecheckFixture(t, "alice-0") stale := f.add(1, "alice", 0, "alice-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} f.a.TriggerRecheck() waitUntil(t, func() bool { return !poolHas(f.pool, stale) }, 2*time.Second, @@ -53,8 +53,8 @@ func TestTriggerRecheck_CoalescedPreservesSenders(t *testing.T) { stale := f.add(1, "alice", 0, "alice-0") survivor := f.add(2, "alice", 1, "alice-1") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.lastCommittedHeight = 2 + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.lastCommittedHeight = 2 // many triggers coalesce to one run; staging merges so no senders are lost. for i := 0; i < 10; i++ { f.a.TriggerRecheck() @@ -81,13 +81,13 @@ func TestTriggerRecheck_ConcurrentCommits(t *testing.T) { f.a.TriggerRecheck() }(int64(i + 1)) } - // admit races commit + recheck through the same stateMu-guarded path + // admit races commit + recheck through the same admission-mutex-guarded path // (RunTx's shared base), exercised together under -race. for i := 0; i < 20; i++ { wg.Add(1) go func(i int) { defer wg.Done() - f.a.admit([]byte("concurrent-" + strconv.Itoa(i))) + f.a.adm.admit([]byte("concurrent-" + strconv.Itoa(i))) }(i) } wg.Wait() @@ -108,7 +108,7 @@ func TestClose_WaitsForInFlight(t *testing.T) { } f := newRecheckFixture() - f.a.runner = runner + f.a.exec.runner = runner startAsyncWorker(f) // unblock before Close so a failed assertion can't hang the cleanup. t.Cleanup(func() { @@ -117,7 +117,7 @@ func TestClose_WaitsForInFlight(t *testing.T) { }) f.add(1, "alice", 0, "alice-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} f.a.TriggerRecheck() waitUntil(t, inFlight.Load, 2*time.Second, "timeout: worker never entered RunTx") @@ -157,7 +157,7 @@ func TestWaitForRecheck_BlocksUntilWorkerDone(t *testing.T) { } f := newRecheckFixture() - f.a.runner = runner + f.a.exec.runner = runner startAsyncWorker(f) // unblock before Close so a failed assertion can't hang the cleanup. defer func() { @@ -166,7 +166,7 @@ func TestWaitForRecheck_BlocksUntilWorkerDone(t *testing.T) { }() f.add(1, "alice", 0, "alice-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} f.a.TriggerRecheck() waitUntil(t, inFlight.Load, 2*time.Second, "timeout: worker never entered RunTx") @@ -207,7 +207,7 @@ func TestWaitForRecheck_CtxTimeoutUnblocks(t *testing.T) { } f := newRecheckFixture() - f.a.runner = runner + f.a.exec.runner = runner startAsyncWorker(f) t.Cleanup(func() { unblockRunner() @@ -215,7 +215,7 @@ func TestWaitForRecheck_CtxTimeoutUnblocks(t *testing.T) { }) f.add(1, "alice", 0, "alice-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} f.a.TriggerRecheck() waitUntil(t, inFlight.Load, 2*time.Second, "timeout: worker never entered RunTx") @@ -239,7 +239,7 @@ func TestWaitForRecheckTimedOut_ReturnsFalseWhenCompletedInTime(t *testing.T) { f := newAsyncRecheckFixture(t, "alice-0") stale := f.add(1, "alice", 0, "alice-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} f.a.TriggerRecheck() if f.a.WaitForRecheckTimedOut(context.Background(), 2*time.Second) { @@ -264,7 +264,7 @@ func TestWaitForRecheckTimedOut_ReturnsTrueWhenStuck(t *testing.T) { } f := newRecheckFixture() - f.a.runner = runner + f.a.exec.runner = runner startAsyncWorker(f) t.Cleanup(func() { unblockRunner() @@ -272,7 +272,7 @@ func TestWaitForRecheckTimedOut_ReturnsTrueWhenStuck(t *testing.T) { }) f.add(1, "alice", 0, "alice-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} f.a.TriggerRecheck() waitUntil(t, inFlight.Load, 2*time.Second, "timeout: worker never entered RunTx") diff --git a/app/mempool/recheck_test.go b/app/mempool/recheck_test.go index ec7b7cb12a..96e77eae03 100644 --- a/app/mempool/recheck_test.go +++ b/app/mempool/recheck_test.go @@ -97,8 +97,8 @@ func newRecheckFixture(failBytes ...string) *recheckFixture { txEncoder := func(tx sdk.Tx) ([]byte, error) { return []byte("enc-" + strconv.Itoa(tx.(*ptrTx).id)), nil } decoder := func([]byte) (sdk.Tx, error) { return nil, errors.New("unused") } a := newManager(runner, enc, txEncoder, decoder) - a.mpool = pool - a.signer = signer + a.sched.mpool = pool + a.sched.signer = signer return &recheckFixture{a: a, pool: pool, enc: enc, signer: signer, runner: runner} } @@ -155,8 +155,8 @@ func TestRecheckTxs_EvictsStaleKeepsValid(t *testing.T) { survivor := f.add(2, "alice", 1, "alice-1") untouched := f.add(3, "bob", 0, "bob-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.RecheckTxs() + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.RecheckTxs() if poolHas(f.pool, stale) { t.Fatal("stale tx should have been removed from the pool") @@ -188,8 +188,8 @@ func TestRecheckTxs_MsgExecFailureEvictsFromPool(t *testing.T) { f.runner.failNoRemoveBytes = map[string]bool{"alice-0": true} stale := f.add(1, "alice", 0, "alice-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.RecheckTxs() + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.RecheckTxs() if poolHas(f.pool, stale) { t.Fatal("tx failing recheck at msg execution (not ante) must still be removed from the pool") @@ -203,7 +203,7 @@ func TestRecheckTxs_EmptyPendingNoOp(t *testing.T) { f := newRecheckFixture() f.add(1, "alice", 0, "alice-0") - f.a.RecheckTxs() // recheckSenders nil + f.a.sched.RecheckTxs() // recheckSenders nil if len(f.runner.modes) != 0 { t.Fatalf("no RunTx expected with empty recheckSenders, got %d calls", len(f.runner.modes)) @@ -213,11 +213,11 @@ func TestRecheckTxs_EmptyPendingNoOp(t *testing.T) { func TestRecheckTxs_DrainsPending(t *testing.T) { f := newRecheckFixture() f.add(1, "alice", 0, "alice-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.RecheckTxs() + f.a.sched.RecheckTxs() first := len(f.runner.modes) - f.a.RecheckTxs() // recheckSenders consumed; second run is a no-op + f.a.sched.RecheckTxs() // recheckSenders consumed; second run is a no-op if len(f.runner.modes) != first { t.Fatal("recheckSenders must be drained after one RecheckTxs") @@ -230,8 +230,8 @@ func TestRecheckTxs_EvictsExpiredUntouchedSender(t *testing.T) { f := newRecheckFixture() expired := f.addTimeout(1, "carol", 0, "carol-0", 5) - f.a.lastCommittedHeight = 5 // next block = 6 > timeoutHeight 5 → never valid again - f.a.RecheckTxs() // recheckSenders nil: only the timeout sweep runs + f.a.sched.lastCommittedHeight = 5 // next block = 6 > timeoutHeight 5 → never valid again + f.a.sched.RecheckTxs() // recheckSenders nil: only the timeout sweep runs if poolHas(f.pool, expired) { t.Fatal("expired tx must be evicted regardless of touched senders") @@ -252,8 +252,8 @@ func TestRecheckTxs_TimeoutBoundary(t *testing.T) { survivor := f.addTimeout(2, "dave", 0, "dave-0", 6) noTimeout := f.addTimeout(3, "erin", 0, "erin-0", 0) - f.a.lastCommittedHeight = 5 - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = 5 + f.a.sched.RecheckTxs() if poolHas(f.pool, atLimit) { t.Fatal("tx with timeoutHeight == committedHeight must be evicted") @@ -273,9 +273,9 @@ func TestRecheckTxs_SweepAndRecheckTogether(t *testing.T) { expired := f.addTimeout(2, "carol", 0, "carol-0", 5) survivor := f.add(3, "alice", 1, "alice-1") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.lastCommittedHeight = 5 - f.a.RecheckTxs() + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.lastCommittedHeight = 5 + f.a.sched.RecheckTxs() if poolHas(f.pool, expired) { t.Fatal("expired tx must be swept") @@ -298,7 +298,7 @@ func TestStageRecheckSenders_StagesHeightForSweep(t *testing.T) { expired := f.addTimeout(1, "carol", 0, "carol-0", 5) f.a.StageRecheckSenders(5, nil) // decoder nil: stages height, leaves recheckSenders nil - f.a.RecheckTxs() + f.a.sched.RecheckTxs() if poolHas(f.pool, expired) { t.Fatal("StageRecheckSenders must stage height so the sweep evicts the expired tx") @@ -325,26 +325,26 @@ func TestStageRecheckSenders_MergesAcrossBlocks(t *testing.T) { return nil, errors.New("unknown") } a := newManager(&stubRunner{}, nil, noopEncoder, decoder) - a.signer = signer + a.sched.signer = signer a.StageRecheckSenders(10, [][]byte{[]byte("a")}) a.StageRecheckSenders(11, [][]byte{[]byte("b")}) // no drain between: must keep alice - if _, ok := a.recheckSenders[sdk.AccAddress("alice").String()]; !ok { + if _, ok := a.sched.recheckSenders[sdk.AccAddress("alice").String()]; !ok { t.Fatal("block-10 sender lost after staging block 11 without a recheck drain") } - if _, ok := a.recheckSenders[sdk.AccAddress("bob").String()]; !ok { + if _, ok := a.sched.recheckSenders[sdk.AccAddress("bob").String()]; !ok { t.Fatal("block-11 sender missing") } - if a.lastCommittedHeight != 11 { - t.Fatalf("height must advance to 11, got %d", a.lastCommittedHeight) + if a.sched.lastCommittedHeight != 11 { + t.Fatalf("height must advance to 11, got %d", a.sched.lastCommittedHeight) } } func TestStageRecheckSenders_NoDepsNoPanic(t *testing.T) { a := newManager(&stubRunner{}, nil, noopEncoder, nil) a.StageRecheckSenders(0, [][]byte{[]byte("x")}) // decoder/signer nil → no-op - a.RecheckTxs() // mpool nil → no-op + a.sched.RecheckTxs() // mpool nil → no-op } func TestStageRecheckSenders_RecheckDisabledSkipsSendersButStagesHeight(t *testing.T) { @@ -354,15 +354,15 @@ func TestStageRecheckSenders_RecheckDisabledSkipsSendersButStagesHeight(t *testi }} decoder := func(b []byte) (sdk.Tx, error) { return tx, nil } a := newManager(&stubRunner{}, nil, noopEncoder, decoder) - a.signer = signer - a.recheckDisabled = true + a.sched.signer = signer + a.sched.recheckDisabled = true a.StageRecheckSenders(7, [][]byte{[]byte("x")}) - if a.lastCommittedHeight != 7 { - t.Fatalf("height must stage even when recheck disabled, got %d", a.lastCommittedHeight) + if a.sched.lastCommittedHeight != 7 { + t.Fatalf("height must stage even when recheck disabled, got %d", a.sched.lastCommittedHeight) } - if a.recheckSenders != nil { + if a.sched.recheckSenders != nil { t.Fatal("recheckDisabled must skip decode+merge into recheckSenders") } } @@ -374,9 +374,9 @@ func TestRecheckTxs_EncoderFallbackOnCacheMiss(t *testing.T) { if _, ok := f.enc.Get(stale); ok { t.Fatal("precondition: tx must not be in encCache") } - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.RecheckTxs() + f.a.sched.RecheckTxs() if !f.runner.seen["enc-1"] { t.Fatal("cache-miss tx must be rechecked using encoder-produced bytes") @@ -392,9 +392,9 @@ func TestRecheckTxs_MultiSignerMatchesAnySigner(t *testing.T) { f := newRecheckFixture("enc-1") // pool key = alice (first signer); recheckSenders names only the second signer, bob. stale := f.insert(1, sdk.AccAddress("alice"), 0, sdk.AccAddress("bob")) - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("bob").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("bob").String(): {}} - f.a.RecheckTxs() + f.a.sched.RecheckTxs() if !f.runner.seen["enc-1"] { t.Fatal("tx must be rechecked when a non-primary signer is touched") @@ -470,10 +470,10 @@ func TestRecheckTxs_BatchCapLimitsCandidates(t *testing.T) { for i := 0; i < total; i++ { f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) } - f.a.maxRecheckBatch = batch - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.maxRecheckBatch = batch + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.RecheckTxs() + f.a.sched.RecheckTxs() if got := len(f.runner.modes); got != batch { t.Fatalf("expected %d RunTx calls with batch cap, got %d", batch, got) @@ -490,14 +490,14 @@ func TestRecheckTxs_BatchCapCarriesOverflow(t *testing.T) { for i := 0; i < total; i++ { f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) } - f.a.maxRecheckBatch = batch - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.maxRecheckBatch = batch + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} // Cycle 1 touches alice; cycles 2-3 have empty recheckSenders but must still drain // the carried overflow. - f.a.RecheckTxs() - f.a.RecheckTxs() - f.a.RecheckTxs() + f.a.sched.RecheckTxs() + f.a.sched.RecheckTxs() + f.a.sched.RecheckTxs() if got := len(f.runner.modes); got != total { t.Fatalf("expected all %d txs rechecked across cycles, got %d", total, got) @@ -507,8 +507,8 @@ func TestRecheckTxs_BatchCapCarriesOverflow(t *testing.T) { t.Fatalf("alice-%d was never rechecked (starved past the cap)", i) } } - if f.a.deferred != nil { - t.Fatalf("deferred queue must be drained, still holds %d", len(f.a.deferred)) + if f.a.sched.deferred != nil { + t.Fatalf("deferred queue must be drained, still holds %d", len(f.a.sched.deferred)) } } @@ -520,9 +520,9 @@ func TestRecheckTxs_BatchCapZeroIsUnlimited(t *testing.T) { f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) } // maxRecheckBatch left at zero default - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.RecheckTxs() + f.a.sched.RecheckTxs() if got := len(f.runner.modes); got != total { t.Fatalf("expected %d RunTx calls with no cap, got %d", total, got) @@ -540,8 +540,8 @@ func TestRecheckTxs_UntouchedSenderNeverRechecked(t *testing.T) { // Three blocks each touch alice only; carol is never in recheckSenders. for i := 0; i < 3; i++ { - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.RecheckTxs() + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.RecheckTxs() } if !poolHas(f.pool, idle) { @@ -560,8 +560,8 @@ func TestRecheckTxs_NonceGapAfterTimeoutEvictionRechecked(t *testing.T) { expired := f.addTimeout(1, "carol", 0, "carol-0", 5) // nonce 0, times out at height 5 gapped := f.addTimeout(2, "carol", 1, "carol-1", 0) // nonce 1, no timeout - f.a.lastCommittedHeight = 5 // sweep evicts nonce 0; carol not in recheckSenders - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = 5 // sweep evicts nonce 0; carol not in recheckSenders + f.a.sched.RecheckTxs() if poolHas(f.pool, expired) { t.Fatal("expired tx must be swept") @@ -578,15 +578,15 @@ func TestRecheckTxs_NonceGapAfterTTLEvictionRechecked(t *testing.T) { // Same class of bug as the TimeoutHeight variant: TTL-evicted lower-nonce tx // must trigger recheck of the surviving higher-nonce sibling. f := newRecheckFixture("carol-1") // carol-1 fails recheck (nonce gap) - f.a.ttlNumBlocks = 5 + f.a.sched.ttlNumBlocks = 5 aged := f.add(1, "carol", 0, "carol-0") gapped := f.add(2, "carol", 1, "carol-1") // Seed arrival directly: aged has been in pool 5+ blocks; gapped just arrived. - f.a.arrival = map[sdk.Tx]int64{aged: 5, gapped: 10} + f.a.sched.arrival = map[sdk.Tx]int64{aged: 5, gapped: 10} - f.a.lastCommittedHeight = 10 // aged: 10-5=5 >= ttl → evicted; gapped: 10-10=0 → survives - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = 10 // aged: 10-5=5 >= ttl → evicted; gapped: 10-10=0 → survives + f.a.sched.RecheckTxs() if poolHas(f.pool, aged) { t.Fatal("TTL-expired tx must be swept") @@ -608,15 +608,15 @@ func TestRecheckTxs_SignerExtractionOutsidePoolLock(t *testing.T) { runner := &recheckRunner{pool: pool, failBytes: map[string]bool{}, seen: map[string]bool{}} txEncoder := func(tx sdk.Tx) ([]byte, error) { return []byte("enc-" + strconv.Itoa(tx.(*ptrTx).id)), nil } a := newManager(runner, enc, txEncoder, func([]byte) (sdk.Tx, error) { return nil, errors.New("unused") }) - a.mpool = pool - a.signer = signer + a.sched.mpool = pool + a.sched.signer = signer tx := &ptrTx{id: 1} signer.m[tx] = []sdkmempool.SignerData{sdkmempool.NewSignerData(sdk.AccAddress("alice"), 0)} _ = pool.Insert(context.Background(), tx) - a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - a.RecheckTxs() + a.sched.RecheckTxs() if signer.sawLocked { t.Fatal("signer extraction ran inside SelectBy (under the pool lock)") @@ -630,17 +630,17 @@ func TestRecheckTxs_SignerExtractionOutsidePoolLock(t *testing.T) { // TimeoutHeight (EVM txs carry th=0 = never expire) and without a RunTx recheck. func TestRecheckTxs_TTLEvictsAgedTx(t *testing.T) { f := newRecheckFixture() - f.a.ttlNumBlocks = 5 + f.a.sched.ttlNumBlocks = 5 aged := f.add(1, "alice", 0, "alice-0") // th=0: the timeout sweep never touches it - f.a.lastCommittedHeight = 10 // first sighting records arrival=10 - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = 10 // first sighting records arrival=10 + f.a.sched.RecheckTxs() if !poolHas(f.pool, aged) { t.Fatal("tx must survive its first sighting") } - f.a.lastCommittedHeight = 15 // 15-10 == 5 == ttl → evicted - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = 15 // 15-10 == 5 == ttl → evicted + f.a.sched.RecheckTxs() if poolHas(f.pool, aged) { t.Fatal("tx older than ttlNumBlocks must be evicted") } @@ -655,13 +655,13 @@ func TestRecheckTxs_TTLEvictsAgedTx(t *testing.T) { // A tx younger than ttlNumBlocks survives the sweep. func TestRecheckTxs_TTLKeepsYoungTx(t *testing.T) { f := newRecheckFixture() - f.a.ttlNumBlocks = 5 + f.a.sched.ttlNumBlocks = 5 young := f.add(1, "alice", 0, "alice-0") - f.a.lastCommittedHeight = 10 // arrival=10 - f.a.RecheckTxs() - f.a.lastCommittedHeight = 14 // 14-10 == 4 < ttl - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = 10 // arrival=10 + f.a.sched.RecheckTxs() + f.a.sched.lastCommittedHeight = 14 // 14-10 == 4 < ttl + f.a.sched.RecheckTxs() if !poolHas(f.pool, young) { t.Fatal("tx younger than ttlNumBlocks must stay") @@ -675,34 +675,34 @@ func TestRecheckTxs_TTLDisabledKeepsOldTx(t *testing.T) { old := f.add(1, "alice", 0, "alice-0") for h := int64(1); h <= 200; h++ { - f.a.lastCommittedHeight = h - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = h + f.a.sched.RecheckTxs() } if !poolHas(f.pool, old) { t.Fatal("TTL disabled: tx must never be evicted by age") } - if f.a.arrival != nil { + if f.a.sched.arrival != nil { t.Fatal("disabled TTL must not allocate the arrival map") } } func TestRecheckTxs_RecheckDisabledSkipsTTLEviction(t *testing.T) { f := newRecheckFixture() - f.a.recheckDisabled = true - f.a.ttlNumBlocks = 5 + f.a.sched.recheckDisabled = true + f.a.sched.ttlNumBlocks = 5 aged := f.add(1, "alice", 0, "alice-0") - f.a.lastCommittedHeight = 10 // first sighting would record arrival=10 if TTL ran - f.a.RecheckTxs() - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.lastCommittedHeight = 15 // 15-10 == ttl, but recheckDisabled skips the sweep entirely - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = 10 // first sighting would record arrival=10 if TTL ran + f.a.sched.RecheckTxs() + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.lastCommittedHeight = 15 // 15-10 == ttl, but recheckDisabled skips the sweep entirely + f.a.sched.RecheckTxs() if !poolHas(f.pool, aged) { t.Fatal("recheckDisabled must skip TTL eviction too, not just RunTx recheck") } - if f.a.arrival != nil { + if f.a.sched.arrival != nil { t.Fatal("recheckDisabled must not build the arrival map") } if len(f.runner.modes) != 0 { @@ -712,12 +712,12 @@ func TestRecheckTxs_RecheckDisabledSkipsTTLEviction(t *testing.T) { func TestRecheckTxs_RecheckDisabledSkipsCandidateRunTx(t *testing.T) { f := newRecheckFixture() - f.a.recheckDisabled = true + f.a.sched.recheckDisabled = true tx := f.add(1, "alice", 0, "alice-0") - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.lastCommittedHeight = 1 - f.a.RecheckTxs() + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.lastCommittedHeight = 1 + f.a.sched.RecheckTxs() if !poolHas(f.pool, tx) { t.Fatal("recheckDisabled must not run RunTx reval even with a staged sender") @@ -731,20 +731,20 @@ func TestRecheckTxs_RecheckDisabledSkipsCandidateRunTx(t *testing.T) { // each cycle, bounding the map to the live pool. func TestRecheckTxs_TTLArrivalReconcilesRemovedTxs(t *testing.T) { f := newRecheckFixture() - f.a.ttlNumBlocks = 100 + f.a.sched.ttlNumBlocks = 100 tx := f.add(1, "alice", 0, "alice-0") - f.a.lastCommittedHeight = 1 - f.a.RecheckTxs() - if len(f.a.arrival) != 1 { - t.Fatalf("arrival must track the live tx, got %d", len(f.a.arrival)) + f.a.sched.lastCommittedHeight = 1 + f.a.sched.RecheckTxs() + if len(f.a.sched.arrival) != 1 { + t.Fatalf("arrival must track the live tx, got %d", len(f.a.sched.arrival)) } _ = f.pool.Remove(tx) // simulate block inclusion - f.a.lastCommittedHeight = 2 - f.a.RecheckTxs() - if len(f.a.arrival) != 0 { - t.Fatalf("arrival must drop the removed tx, got %d", len(f.a.arrival)) + f.a.sched.lastCommittedHeight = 2 + f.a.sched.RecheckTxs() + if len(f.a.sched.arrival) != 0 { + t.Fatalf("arrival must drop the removed tx, got %d", len(f.a.sched.arrival)) } } @@ -753,24 +753,24 @@ func TestRecheckTxs_TTLArrivalReconcilesRemovedTxs(t *testing.T) { func TestRecheckTxs_TTLEvictsRegardlessOfBatchCap(t *testing.T) { const total = 5 f := newRecheckFixture() - f.a.ttlNumBlocks = 2 - f.a.maxRecheckBatch = 1 // far below total + f.a.sched.ttlNumBlocks = 2 + f.a.sched.maxRecheckBatch = 1 // far below total txs := make([]*ptrTx, total) for i := 0; i < total; i++ { txs[i] = f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) } - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.lastCommittedHeight = 100 // first sighting: arrival=100 - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = 100 // first sighting: arrival=100 + f.a.sched.RecheckTxs() if got := len(f.runner.modes); got != 1 { t.Fatalf("cycle1: batch cap must bound recheck to 1, got %d", got) } - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.lastCommittedHeight = 102 // 102-100 == 2 == ttl → all aged out + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.lastCommittedHeight = 102 // 102-100 == 2 == ttl → all aged out before := len(f.runner.modes) - f.a.RecheckTxs() + f.a.sched.RecheckTxs() for _, tx := range txs { if poolHas(f.pool, tx) { @@ -780,8 +780,8 @@ func TestRecheckTxs_TTLEvictsRegardlessOfBatchCap(t *testing.T) { if got := len(f.runner.modes) - before; got != 0 { t.Fatalf("TTL-evicted txs must not be rechecked; got %d new RunTx", got) } - if f.a.deferred != nil { - t.Fatalf("nothing should carry over once all aged out, got %d", len(f.a.deferred)) + if f.a.sched.deferred != nil { + t.Fatalf("nothing should carry over once all aged out, got %d", len(f.a.sched.deferred)) } } @@ -790,32 +790,32 @@ func TestRecheckTxs_TTLEvictsRegardlessOfBatchCap(t *testing.T) { func TestRecheckTxs_TTLEvictsDeferredCarryover(t *testing.T) { const total = 4 f := newRecheckFixture() - f.a.ttlNumBlocks = 3 - f.a.maxRecheckBatch = 1 // force overflow into deferred + f.a.sched.ttlNumBlocks = 3 + f.a.sched.maxRecheckBatch = 1 // force overflow into deferred txs := make([]*ptrTx, total) for i := 0; i < total; i++ { txs[i] = f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) } - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.lastCommittedHeight = 50 // arrival=50 for all - f.a.RecheckTxs() - if len(f.a.deferred) == 0 { + f.a.sched.lastCommittedHeight = 50 // arrival=50 for all + f.a.sched.RecheckTxs() + if len(f.a.sched.deferred) == 0 { t.Fatal("precondition: batch cap must have carried overflow") } // Jump past TTL with empty recheckSenders: only the scan sweep runs. The deferred // carryover must be evicted, not survive as stale candidates. - f.a.lastCommittedHeight = 53 // 53-50 == 3 == ttl - f.a.RecheckTxs() + f.a.sched.lastCommittedHeight = 53 // 53-50 == 3 == ttl + f.a.sched.RecheckTxs() for _, tx := range txs { if poolHas(f.pool, tx) { t.Fatalf("deferred tx %d must be TTL-evicted", tx.id) } } - if f.a.deferred != nil { - t.Fatalf("deferred queue must be empty after aged txs evicted, got %d", len(f.a.deferred)) + if f.a.sched.deferred != nil { + t.Fatalf("deferred queue must be empty after aged txs evicted, got %d", len(f.a.sched.deferred)) } } @@ -831,11 +831,11 @@ func TestStageSkippedSenders_MergesIntoRecheckSenders(t *testing.T) { return nil, errors.New("unknown") } a := newManager(&stubRunner{}, nil, noopEncoder, decoder) - a.signer = signer + a.sched.signer = signer a.StageSkippedSenders([][]byte{[]byte("a")}) - if _, ok := a.recheckSenders[sdk.AccAddress("alice").String()]; !ok { + if _, ok := a.sched.recheckSenders[sdk.AccAddress("alice").String()]; !ok { t.Fatal("gate-skipped sender must appear in recheckSenders") } } @@ -852,13 +852,13 @@ func TestStageSkippedSenders_DoesNotTouchLastCommittedHeight(t *testing.T) { return nil, errors.New("unknown") } a := newManager(&stubRunner{}, nil, noopEncoder, decoder) - a.signer = signer - a.lastCommittedHeight = 42 + a.sched.signer = signer + a.sched.lastCommittedHeight = 42 a.StageSkippedSenders([][]byte{[]byte("a")}) - if a.lastCommittedHeight != 42 { - t.Fatalf("StageSkippedSenders must not touch lastCommittedHeight: got %d, want 42", a.lastCommittedHeight) + if a.sched.lastCommittedHeight != 42 { + t.Fatalf("StageSkippedSenders must not touch lastCommittedHeight: got %d, want 42", a.sched.lastCommittedHeight) } } @@ -880,26 +880,26 @@ func TestStageSkippedSenders_MergesWithCommittedSenders(t *testing.T) { return nil, errors.New("unknown") } a := newManager(&stubRunner{}, nil, noopEncoder, decoder) - a.signer = signer + a.sched.signer = signer a.StageRecheckSenders(10, [][]byte{[]byte("a")}) // alice from committed block a.StageSkippedSenders([][]byte{[]byte("b")}) // bob from gate skip - if _, ok := a.recheckSenders[sdk.AccAddress("alice").String()]; !ok { + if _, ok := a.sched.recheckSenders[sdk.AccAddress("alice").String()]; !ok { t.Fatal("committed sender must be preserved after StageSkippedSenders") } - if _, ok := a.recheckSenders[sdk.AccAddress("bob").String()]; !ok { + if _, ok := a.sched.recheckSenders[sdk.AccAddress("bob").String()]; !ok { t.Fatal("gate-skipped sender must be merged in") } - if a.lastCommittedHeight != 10 { - t.Fatalf("height must stay at 10, got %d", a.lastCommittedHeight) + if a.sched.lastCommittedHeight != 10 { + t.Fatalf("height must stay at 10, got %d", a.sched.lastCommittedHeight) } } func TestStageSkippedSenders_NilDecoderNoop(t *testing.T) { a := newManager(&stubRunner{}, nil, noopEncoder, nil) a.StageSkippedSenders([][]byte{[]byte("x")}) // decoder nil → must not panic - if a.recheckSenders != nil { + if a.sched.recheckSenders != nil { t.Fatal("nil decoder must leave recheckSenders unchanged") } } @@ -908,7 +908,7 @@ func TestStageSkippedSenders_EmptyIsNoop(t *testing.T) { a := newManager(&stubRunner{}, nil, noopEncoder, func([]byte) (sdk.Tx, error) { return &ptrTx{}, nil }) a.StageSkippedSenders(nil) a.StageSkippedSenders([][]byte{}) - if a.recheckSenders != nil { + if a.sched.recheckSenders != nil { t.Fatal("empty input must not allocate recheckSenders") } } @@ -920,12 +920,12 @@ func TestStageSkippedSenders_RecheckDisabledSkipsMerge(t *testing.T) { }} decoder := func(b []byte) (sdk.Tx, error) { return tx, nil } a := newManager(&stubRunner{}, nil, noopEncoder, decoder) - a.signer = signer - a.recheckDisabled = true + a.sched.signer = signer + a.sched.recheckDisabled = true a.StageSkippedSenders([][]byte{[]byte("x")}) - if a.recheckSenders != nil { + if a.sched.recheckSenders != nil { t.Fatal("recheckDisabled must skip decode+merge into recheckSenders") } } @@ -940,7 +940,7 @@ func TestStageSkippedSenders_TriggerRecheckNextCycle(t *testing.T) { // the stale tx. The fakeSigner already has stale → alice, so // StageSkippedSenders extracts alice and adds her to recheckSenders. gateSkippedBz := []byte("gate-skipped-alice") - f.a.decoder = func(b []byte) (sdk.Tx, error) { + f.a.exec.decoder = func(b []byte) (sdk.Tx, error) { if string(b) == string(gateSkippedBz) { return stale, nil } @@ -948,7 +948,7 @@ func TestStageSkippedSenders_TriggerRecheckNextCycle(t *testing.T) { } f.a.StageSkippedSenders([][]byte{gateSkippedBz}) - f.a.RecheckTxs() + f.a.sched.RecheckTxs() if poolHas(f.pool, stale) { t.Fatal("gate-skipped and recheck-failed tx must be evicted in one cycle") @@ -966,9 +966,9 @@ func TestRecheckTxs_NilEncCacheEvictionNoPanic(t *testing.T) { SignerExtractor: signer, }) a := newManager(&stubRunner{}, nil, noopEncoder, nil) // encCache nil - a.mpool = pool - a.signer = signer - a.ttlNumBlocks = 2 + a.sched.mpool = pool + a.sched.signer = signer + a.sched.ttlNumBlocks = 2 tx := &ptrTx{id: 1} signer.m[tx] = []sdkmempool.SignerData{sdkmempool.NewSignerData(sdk.AccAddress("alice"), 0)} @@ -976,10 +976,10 @@ func TestRecheckTxs_NilEncCacheEvictionNoPanic(t *testing.T) { t.Fatal(err) } - a.lastCommittedHeight = 10 - a.RecheckTxs() // arrival=10 - a.lastCommittedHeight = 12 - a.RecheckTxs() // 12-10 == 2 → evict via nil encCache; must not panic + a.sched.lastCommittedHeight = 10 + a.sched.RecheckTxs() // arrival=10 + a.sched.lastCommittedHeight = 12 + a.sched.RecheckTxs() // 12-10 == 2 → evict via nil encCache; must not panic if poolHas(pool, tx) { t.Fatal("aged tx must be evicted even with nil encCache") @@ -989,8 +989,8 @@ func TestRecheckTxs_NilEncCacheEvictionNoPanic(t *testing.T) { const aliceSeq0Bytes = "alice-0" // A generation bump cannot split one signer's group: gen only advances under -// stateMu, which recheckGroup holds for the whole group. The bump here is raised -// from inside RunTx (i.e. without stateMu) to show the group still completes, +// the admission mutex, which recheckGroup holds for the whole group. The bump here is raised +// from inside RunTx (i.e. without the admission mutex) to show the group still completes, // and that cancellation is a between-groups decision. func TestRecheckTxs_GenerationBumpDoesNotSplitASignersGroup(t *testing.T) { f := newRecheckFixture() @@ -999,11 +999,11 @@ func TestRecheckTxs_GenerationBumpDoesNotSplitASignersGroup(t *testing.T) { f.runner.onCall = func(txBytes []byte) { if string(txBytes) == aliceSeq0Bytes { - f.a.gen.Add(1) + f.a.exec.gen.Add(1) } } - f.a.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} - f.a.RecheckTxs() + f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.RecheckTxs() if !f.runner.seen[aliceSeq0Bytes] || !f.runner.seen["alice-1"] { t.Fatal("both candidates of one signer must run under the same stateMu hold") @@ -1021,15 +1021,15 @@ func TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred(t *te bobTx := f.add(2, "bob", 0, "bob-0") carryTx := f.add(3, "carol", 0, "carol-carry") // stands in for capRecheckTxs' overflow carry - f.a.deferred = []sdk.Tx{carryTx} + f.a.sched.deferred = []sdk.Tx{carryTx} f.runner.onCall = func(txBytes []byte) { if string(txBytes) == aliceSeq0Bytes { - f.a.gen.Add(1) // simulate a Commit's refresh landing after the first candidate + f.a.exec.gen.Add(1) // simulate a Commit's refresh landing after the first candidate } } - gen := f.a.gen.Load() - f.a.runRecheck([]sdk.Tx{aliceTx, bobTx}, gen) + gen := f.a.exec.gen.Load() + f.a.sched.runRecheck([]sdk.Tx{aliceTx, bobTx}, gen) if !f.runner.seen[aliceSeq0Bytes] { t.Fatal("the candidate validated before the bump must still run") @@ -1037,17 +1037,17 @@ func TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred(t *te if f.runner.seen["bob-0"] { t.Fatal("the candidate after the bump must be skipped, not rechecked against a superseded base") } - if _, ok := f.a.recheckSenders[sdk.AccAddress("bob").String()]; !ok { + if _, ok := f.a.sched.recheckSenders[sdk.AccAddress("bob").String()]; !ok { t.Fatal("bob must be re-covered in staging after its candidate was skipped") } - if len(f.a.deferred) != 1 || f.a.deferred[0] != carryTx { + if len(f.a.sched.deferred) != 1 || f.a.sched.deferred[0] != carryTx { t.Fatal("an already-set deferred carry from this cycle must not be clobbered") } // Next RecheckTxs cycle: bob (re-covered) and the carried carol tx must both // get rechecked. f.runner.onCall = nil - f.a.RecheckTxs() + f.a.sched.RecheckTxs() if !f.runner.seen["bob-0"] { t.Fatal("the re-covered sender's tx must be rechecked by the next RecheckTxs cycle") @@ -1069,7 +1069,7 @@ func TestRunRecheck_GroupsCandidatesBySigner(t *testing.T) { bob := f.add(2, "bob", 0, "bob-0") aliceHigh := f.add(3, "alice", 1, "alice-1") - f.a.runRecheck([]sdk.Tx{aliceLow, bob, aliceHigh}, f.a.gen.Load()) + f.a.sched.runRecheck([]sdk.Tx{aliceLow, bob, aliceHigh}, f.a.exec.gen.Load()) want := []string{aliceSeq0Bytes, "alice-1", "bob-0"} if !slices.Equal(f.runner.calls, want) { @@ -1084,7 +1084,7 @@ func TestRunRecheck_NonceGapCascadesToHigherSiblings(t *testing.T) { higher := f.add(3, "carl", 8, carlSeq8Bytes) f.runner.failErrs = map[string]error{carlSeq7Bytes: errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} - f.a.runRecheck([]sdk.Tx{valid, gapped, higher}, f.a.gen.Load()) + f.a.sched.runRecheck([]sdk.Tx{valid, gapped, higher}, f.a.exec.gen.Load()) if f.runner.seen[carlSeq8Bytes] { t.Fatal("a sibling behind a proven nonce gap must be evicted without spending a RunTx") @@ -1105,7 +1105,7 @@ func TestRunRecheck_StaleNonceDoesNotCascade(t *testing.T) { next := f.add(2, "carl", 6, "carl-6") f.runner.failErrs = map[string]error{carlSeq5Bytes: errorsmod.Wrap(sdkerrors.ErrInvalidSequence, "stale")} - f.a.runRecheck([]sdk.Tx{stale, next}, f.a.gen.Load()) + f.a.sched.runRecheck([]sdk.Tx{stale, next}, f.a.exec.gen.Load()) if !f.runner.seen["carl-6"] { t.Fatal("the successor of a stale nonce must still be rechecked") @@ -1125,7 +1125,7 @@ func TestRunRecheck_NonNonceFailureDoesNotCascade(t *testing.T) { higher := f.add(3, "carl", 8, carlSeq8Bytes) f.runner.failErrs = map[string]error{carlSeq7Bytes: errorsmod.Wrap(sdkerrors.ErrInsufficientFunds, "no funds")} - f.a.runRecheck([]sdk.Tx{valid, failing, higher}, f.a.gen.Load()) + f.a.sched.runRecheck([]sdk.Tx{valid, failing, higher}, f.a.exec.gen.Load()) if !f.runner.seen[carlSeq8Bytes] { t.Fatal("only a nonce gap justifies skipping a sibling's RunTx") @@ -1141,7 +1141,7 @@ func TestRunRecheck_OutOfOrderNoncesDisableCascade(t *testing.T) { lower := f.add(3, "carl", 7, carlSeq7Bytes) f.runner.failErrs = map[string]error{"carl-9": errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} - f.a.runRecheck([]sdk.Tx{valid, gapped, lower}, f.a.gen.Load()) + f.a.sched.runRecheck([]sdk.Tx{valid, gapped, lower}, f.a.exec.gen.Load()) if !f.runner.seen[carlSeq7Bytes] { t.Fatal("a non-ascending group must not cascade") diff --git a/app/mempool/scheduler.go b/app/mempool/scheduler.go new file mode 100644 index 0000000000..0e21f07f46 --- /dev/null +++ b/app/mempool/scheduler.go @@ -0,0 +1,489 @@ +package mempool + +import ( + "context" + "sync" + "time" + + errorsmod "cosmossdk.io/errors" + + "github.com/cosmos/cosmos-sdk/telemetry" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" +) + +// recheckScheduler is the recheck half of the app mempool: it stages the senders +// each block touched, picks which pending txs to re-validate, and evicts the ones +// the new state invalidated. +type recheckScheduler struct { + exec *txExec + mpool sdkmempool.Mempool + signer sdkmempool.SignerExtractionAdapter + // maxRecheckBatch caps RunTx(ReCheck) calls per Commit cycle; 0 = unlimited. + maxRecheckBatch int + // stagingMu guards the staging fields (recheckSenders, deferred, lastCommittedHeight). + // Separate from the admission mutex so FinalizeBlock staging never blocks behind a recheck batch. + stagingMu sync.Mutex + // recheckSenders accumulates senders of committed blocks awaiting recheck; merged + // (not overwritten) across blocks so an un-drained block's senders aren't lost. + recheckSenders map[string]struct{} + // deferred carries candidates past maxRecheckBatch to the next cycle, so a + // deep per-sender queue eventually drains instead of being silently dropped. + deferred []sdk.Tx + lastCommittedHeight int64 + // arrival maps each pooled tx to the height RecheckTxs first observed it, for + // ttlNumBlocks eviction. Rebuilt from the snapshot each cycle; recheckMu keeps it single-writer. + arrival map[sdk.Tx]int64 + // ttlNumBlocks evicts txs older than this many blocks by arrival height; 0 = off. + ttlNumBlocks int64 + + recheckMu sync.Mutex // serializes RecheckTxs; always acquired before the admission mutex and stagingMu, never after + // Zero-value (trigger nil) when built via the newManager() test constructor; + // TriggerRecheck then runs RecheckTxs inline instead of async. + worker recheckWorker + // recheckDisabled mirrors mempool.recheck=false: skips all rechecking, + // including TTL/expiry eviction + recheckDisabled bool +} + +// recheckDecodingEnabled reports whether sender decoding/bookkeeping should run. +func (s *recheckScheduler) recheckDecodingEnabled() bool { + return !s.recheckDisabled && s.signer != nil && s.exec.decoder != nil +} + +// stageSkippedSenders merges the senders of proposal-gate-rejected txs into +// recheckSenders without touching lastCommittedHeight +func (s *recheckScheduler) stageSkippedSenders(txs [][]byte) { + if !s.recheckDecodingEnabled() || len(txs) == 0 { + return + } + senders := make(map[string]struct{}, len(txs)) + for _, bz := range txs { + tx, err := s.exec.decoder(bz) + if err != nil { + continue + } + for _, sg := range s.signers(tx) { + senders[sg] = struct{}{} + } + } + if len(senders) == 0 { + return + } + s.stagingMu.Lock() + s.mergeRecheckSenders(senders) + s.stagingMu.Unlock() +} + +func (s *recheckScheduler) mergeRecheckSenders(senders map[string]struct{}) { + // mergeRecheckSenders folds senders into recheckSenders without overwriting, so a + // block whose Commit skipped RecheckTxs doesn't lose its staged senders. + if s.recheckSenders == nil { + s.recheckSenders = senders + } else { + for sg := range senders { + s.recheckSenders[sg] = struct{}{} + } + } +} + +// stageRecheckSenders records the senders of the just-committed block's txs so +// RecheckTxs can re-validate only their remaining pending txs, and stages the +// committed height. +func (s *recheckScheduler) stageRecheckSenders(height int64, txs [][]byte) { + // Decode + extract signers unlocked (the expensive part), then publish height + // and recheckSenders in one critical section so a reader never sees a torn update. + var senders map[string]struct{} + if s.recheckDecodingEnabled() { + senders = make(map[string]struct{}, len(txs)) + for _, bz := range txs { + tx, err := s.exec.decoder(bz) + if err != nil { + continue // non-sdk txs (e.g. vote extensions) have no mempool entry + } + for _, sg := range s.signers(tx) { + senders[sg] = struct{}{} + } + } + } + + s.stagingMu.Lock() + s.lastCommittedHeight = height + s.mergeRecheckSenders(senders) + s.stagingMu.Unlock() +} + +// triggerRecheck schedules an async recheck. +// Call only from the consensus path (App.Commit). +func (s *recheckScheduler) triggerRecheck() { + if s.worker.trigger == nil { + s.RecheckTxs() + return + } + s.worker.recheck() +} + +// RecheckTxs evicts pool txs invalidated by the last block. +func (s *recheckScheduler) RecheckTxs() { + if s.mpool == nil || s.recheckDisabled { + return + } + s.recheckMu.Lock() // lock order: see the recheckMu field comment + defer s.recheckMu.Unlock() + recheckSenders, height, deferred := s.drainStaging() + gen := s.exec.gen.Load() + // Before the first block (height 0) with no senders/carry there's nothing to scan. + if len(recheckSenders) == 0 && len(deferred) == 0 && height == 0 { + return + } + + snapshot := PoolSnapshot(context.Background(), s.mpool) + candidates := s.capRecheckTxs(s.selectTxs(snapshot, recheckSenders, height, deferred)) + s.runRecheck(candidates, gen) + + telemetry.SetGauge(float32(s.mpool.CountTx()), "cronos", "mempool", "pool", "size") +} + +// drainStaging atomically takes and clears the staged senders, height, and carry. +func (s *recheckScheduler) drainStaging() (recheckSenders map[string]struct{}, height int64, deferred []sdk.Tx) { + s.stagingMu.Lock() + defer s.stagingMu.Unlock() + recheckSenders, height, deferred = s.recheckSenders, s.lastCommittedHeight, s.deferred + s.recheckSenders = nil + s.deferred = nil + return recheckSenders, height, deferred +} + +// selectTxs scans the pool to retrieve txs for recheck. Caller (RecheckTxs) +// only invokes this when recheck is enabled. +func (s *recheckScheduler) selectTxs(snapshot []sdk.Tx, recheckSenders map[string]struct{}, height int64, deferred []sdk.Tx) []sdk.Tx { + // deferredLive: carried-over tx -> still in pool. Sized to the small carry; nil if none. + var deferredLive map[sdk.Tx]bool + if len(deferred) > 0 { + deferredLive = make(map[sdk.Tx]bool, len(deferred)) + for _, tx := range deferred { + deferredLive[tx] = false + } + } + + var ( + expiredEvicted float32 + ttlEvicted float32 + ) + // Rebuild arrival from this cycle's snapshot so txs gone from the pool fall out. + var newArrival map[sdk.Tx]int64 + if s.ttlNumBlocks > 0 { + newArrival = make(map[sdk.Tx]int64, len(snapshot)) + } + + // 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() + for _, tx := range snapshot { + if txTimedout(tx, height, now) { + evictedSet, recheckSenders = s.evictForRecheck(tx, evictedSet, recheckSenders) + expiredEvicted++ + continue + } + if s.ttlNumBlocks > 0 { + arrived, expired := txTTLExpired(s.arrival, tx, height, s.ttlNumBlocks) + if expired { + evictedSet, recheckSenders = s.evictForRecheck(tx, evictedSet, recheckSenders) + ttlEvicted++ + continue + } + newArrival[tx] = arrived + } + } + s.arrival = newArrival + if expiredEvicted > 0 { + telemetry.IncrCounter(expiredEvicted, "cronos", "mempool", "recheck", "expired") + } + if ttlEvicted > 0 { + telemetry.IncrCounter(ttlEvicted, "cronos", "mempool", "recheck", "ttl_expired") + } + + // Pass 2: candidate selection over surviving (non-evicted) txs. + var candidates []sdk.Tx + for _, tx := range snapshot { + if _, wasEvicted := evictedSet[tx]; wasEvicted { + continue + } + if deferredLive != nil { + if _, isDeferred := deferredLive[tx]; isDeferred { + deferredLive[tx] = true + } + } + if len(recheckSenders) == 0 { + continue + } + for _, sg := range s.signers(tx) { + if _, ok := recheckSenders[sg]; ok { + candidates = append(candidates, tx) + break + } + } + } + + if len(deferred) == 0 { + return candidates + } + // Front-load surviving deferred ahead of fresh candidates: the snapshot is + // priority-ordered, so otherwise capRecheckTxs re-takes the same prefix and starves the tail. + ordered := make([]sdk.Tx, 0, len(deferred)+len(candidates)) + for _, tx := range deferred { + if deferredLive[tx] { + ordered = append(ordered, tx) // skip txs included/evicted since carry + } + } + for _, tx := range candidates { + if _, isDeferred := deferredLive[tx]; isDeferred { + continue // sender re-touched this cycle; avoid double recheck + } + ordered = append(ordered, tx) + } + return ordered +} + +// evictForRecheck evicts tx and folds its signers into recheckSenders, allocating +// evictedSet/recheckSenders lazily so a no-eviction cycle stays alloc-free. +func (s *recheckScheduler) evictForRecheck(tx sdk.Tx, evictedSet map[sdk.Tx]struct{}, recheckSenders map[string]struct{}) (map[sdk.Tx]struct{}, map[string]struct{}) { + s.evict(tx) + if evictedSet == nil { + evictedSet = make(map[sdk.Tx]struct{}) + } + evictedSet[tx] = struct{}{} + sigs := s.signers(tx) + if len(sigs) > 0 && recheckSenders == nil { + recheckSenders = make(map[string]struct{}) + } + for _, sg := range sigs { + recheckSenders[sg] = struct{}{} + } + return evictedSet, recheckSenders +} + +// capRecheckTxs bounds RunTx(ReCheck) per cycle; overflow carries forward. +func (s *recheckScheduler) capRecheckTxs(candidates []sdk.Tx) []sdk.Tx { + if s.maxRecheckBatch <= 0 || len(candidates) <= s.maxRecheckBatch { + return candidates + } + carried := make([]sdk.Tx, len(candidates)-s.maxRecheckBatch) + copy(carried, candidates[s.maxRecheckBatch:]) + s.stagingMu.Lock() + s.deferred = carried + s.stagingMu.Unlock() + return candidates[:s.maxRecheckBatch] +} + +// recheckCandidate carries the signer nonce alongside the tx: telling a nonce +// gap from a merely stale nonce is what makes cascade eviction safe. +type recheckCandidate struct { + tx sdk.Tx + bz []byte + seq uint64 +} + +// recheckGroup holds one signer's candidates in pool order. cascadable is false +// when the group is not that signer's contiguous ascending-nonce view — an +// unknown signer, a repeated or out-of-order nonce, or a tx dropped on encode +// error — because the cascade rule reasons about the next expected nonce. +type recheckGroup struct { + txs []recheckCandidate + cascadable bool +} + +// runRecheck re-validates candidates via RunTx(ReCheck), one signer group at a +// time so a sender's nonce chain advances atomically with respect to other +// senders' admissions. The pass is abandoned once gen advances mid-flight: the +// remaining candidates would be validated against a base a concurrent Commit +// has already superseded. drainStaging already cleared recheckSenders for this +// cycle, so the unreached candidates' senders are re-merged into staging here — +// otherwise a sender that isn't touched again by a later block would never be +// rechecked until TTL. +func (s *recheckScheduler) runRecheck(candidates []sdk.Tx, gen uint64) { + var evicted, cascaded, superseded float32 + groups := s.groupCandidates(candidates) + for i, g := range groups { + if len(g.txs) == 0 { + continue + } + e, c, aborted := s.recheckGroup(g, gen) + evicted += e + cascaded += c + if aborted { + unreached := unreachedTxs(groups[i:]) + superseded = float32(len(unreached)) + s.recoverSenders(unreached) + break + } + } + if evicted > 0 { + telemetry.IncrCounter(evicted, "cronos", "mempool", "recheck", "evicted") + } + if cascaded > 0 { + telemetry.IncrCounter(cascaded, "cronos", "mempool", "recheck", "cascade_evicted") + } + if superseded > 0 { + telemetry.IncrCounter(superseded, "cronos", "mempool", "recheck", "superseded") + } +} + +// groupCandidates buckets candidates by first signer — the one the mempool +// orders by — keeping first-appearance order across groups and pool order +// within one, so the front-loaded deferred prefix still runs first. Encoding +// happens here, outside the admission mutex, to keep the per-group hold to RunTx. +func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { + groups := make([]recheckGroup, 0, len(candidates)) + index := make(map[string]int, len(candidates)) + for _, tx := range candidates { + key, seq, known := s.firstSigner(tx) + gi, seen := index[key] + if !seen { + groups = append(groups, recheckGroup{cascadable: known}) + gi = len(groups) - 1 + index[key] = gi + } + g := &groups[gi] + bz, _, err := EncodeTx(s.exec.encCache, s.exec.txEncoder, tx) + if err != nil { + g.cascadable = false + continue + } + if n := len(g.txs); n > 0 && seq <= g.txs[n-1].seq { + g.cascadable = false + } + g.txs = append(g.txs, recheckCandidate{tx: tx, bz: bz, seq: seq}) + } + return groups +} + +// recheckGroup re-validates one signer's candidates under a single hold of the +// admission mutex. Reports aborted when gen advanced before the group started, +// leaving the group untouched. On a nonce gap the remaining higher-nonce siblings +// are evicted without spending a RunTx on each: nothing can fill the gap while +// they sit in the pool. Any other failure evicts only the failing tx, since a +// later sibling may still be the account's next expected nonce. +func (s *recheckScheduler) recheckGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, aborted bool) { + s.exec.mu.Lock() + defer s.exec.mu.Unlock() + // gen only advances under the same mutex, so it cannot change once this group starts. + if s.exec.gen.Load() != gen { + return 0, 0, true + } + + var lastOK uint64 + haveOK := false + for i, c := range g.txs { + _, _, _, err := s.exec.runTxLocked(sdk.ExecModeReCheck, c.bz, c.tx) + if err == nil { + lastOK, haveOK = c.seq, true + continue + } + s.evict(c.tx) + evicted++ + // A gap is only provable relative to a nonce this pass just accepted; + // without one the failure may be a stale nonce, whose successor is valid. + if g.cascadable && haveOK && c.seq > lastOK+1 && isNonceErr(err) { + for _, rest := range g.txs[i+1:] { + s.evict(rest.tx) + cascaded++ + } + return evicted, cascaded, false + } + } + return evicted, cascaded, false +} + +// isNonceErr matches both ante paths: cosmos sig verification reports +// ErrWrongSequence, the EVM nonce check reports ErrInvalidSequence. +func isNonceErr(err error) bool { + return errorsmod.IsOf(err, sdkerrors.ErrWrongSequence, sdkerrors.ErrInvalidSequence) +} + +func unreachedTxs(groups []recheckGroup) []sdk.Tx { + var txs []sdk.Tx + for _, g := range groups { + for _, c := range g.txs { + txs = append(txs, c.tx) + } + } + return txs +} + +// recoverSenders folds txs' senders back into staged recheckSenders without +// touching deferred, which capRecheckTxs may have already set this cycle. +func (s *recheckScheduler) recoverSenders(txs []sdk.Tx) { + senders := make(map[string]struct{}) + for _, tx := range txs { + for _, sg := range s.signers(tx) { + senders[sg] = struct{}{} + } + } + if len(senders) == 0 { + return + } + s.stagingMu.Lock() + s.mergeRecheckSenders(senders) + s.stagingMu.Unlock() +} + +// txTimedout reports whether tx should be evicted by its own declared timeout: +func txTimedout(tx sdk.Tx, height int64, now time.Time) bool { + if t, ok := tx.(sdk.TxWithTimeoutHeight); ok { + th := t.GetTimeoutHeight() + if th > 0 && uint64(height) >= th { + return true + } + } + if t, ok := tx.(sdk.TxWithTimeoutTimeStamp); ok { + ts := t.GetTimeoutTimeStamp() + if !ts.IsZero() && !now.Before(ts) { + return true + } + } + return false +} + +// txTTLExpired reports whether tx has aged past ttlNumBlocks since first seen. +func txTTLExpired(arrival map[sdk.Tx]int64, tx sdk.Tx, height, ttlNumBlocks int64) (int64, bool) { + arrived, ok := arrival[tx] + if !ok { + arrived = height + } + return arrived, height-arrived >= ttlNumBlocks +} + +// evict removes tx from the pool and encoder cache together, so the cache never +// outlives its pool entry. +func (s *recheckScheduler) evict(tx sdk.Tx) { + _ = s.mpool.Remove(tx) + s.exec.encCache.Evict(tx) +} + +// firstSigner returns the signer the mempool orders by, with its nonce. An +// unknown signer only costs the cascade optimization, not the recheck itself. +func (s *recheckScheduler) firstSigner(tx sdk.Tx) (key string, seq uint64, known bool) { + if s.signer == nil { + return "", 0, false + } + sigs, err := s.signer.GetSigners(tx) + if err != nil || len(sigs) == 0 { + return "", 0, false + } + return sigs[0].Signer.String(), sigs[0].Sequence, true +} + +func (s *recheckScheduler) signers(tx sdk.Tx) []string { + sigs, err := s.signer.GetSigners(tx) + if err != nil { + return nil + } + keys := make([]string, len(sigs)) + for i, sg := range sigs { + keys[i] = sg.Signer.String() + } + return keys +} diff --git a/app/mempool/state.go b/app/mempool/state.go index a60a82fd88..1872a258e8 100644 --- a/app/mempool/state.go +++ b/app/mempool/state.go @@ -8,10 +8,10 @@ import ( // mempoolState holds the CacheMultiStore branch that admission and recheck // share as the sole nonce authority (docs/architecture/mempool-branched-recheck-context.md). -// mu guards base independently of Manager.stateMu: it is always the innermost -// lock (mempoolState never calls back into Manager while holding it), so -// nesting it under stateMu adds no ordering hazard, and base stays safe to -// read even if a future caller forgets to hold stateMu. +// mu guards base independently of txExec.mu: it is always the innermost lock +// (mempoolState never calls back out while holding it), so nesting it under +// txExec.mu adds no ordering hazard, and base stays safe to read even if a +// future caller forgets to hold txExec.mu. type mempoolState struct { mu sync.RWMutex base storetypes.CacheMultiStore @@ -19,7 +19,7 @@ type mempoolState struct { } // refreshLocked branches a fresh base off the committed store. Precondition: -// the caller holds Manager.stateMu, which is what actually keeps this swap +// the caller holds txExec.mu, which is what actually keeps this swap // from racing a concurrent RunTx or the live memiavl tree mid-Commit. func (s *mempoolState) refreshLocked() { base := s.provider().CacheMultiStore() @@ -30,7 +30,7 @@ func (s *mempoolState) refreshLocked() { // store returns the current base, or nil so RunTx falls back to checkState. // Nil-safe on a nil receiver (and nil base) so the newManager() test -// constructor, which leaves Manager.state nil, keeps working without a store. +// constructor, which leaves txExec.state nil, keeps working without a store. func (s *mempoolState) store() storetypes.MultiStore { if s == nil { return nil From bb0c293ece05794616967086455b1b7ff9bfe10b Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Wed, 29 Jul 2026 20:46:35 -0400 Subject: [PATCH 04/12] test(app): pin down where the two PrepareProposal paths may diverge 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. --- app/proposal_diff_test.go | 361 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 app/proposal_diff_test.go diff --git a/app/proposal_diff_test.go b/app/proposal_diff_test.go new file mode 100644 index 0000000000..7e292f4d24 --- /dev/null +++ b/app/proposal_diff_test.go @@ -0,0 +1,361 @@ +package app + +import ( + "fmt" + "math/big" + "testing" + + abci "github.com/cometbft/cometbft/abci/types" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + cronosmempool "github.com/crypto-org-chain/cronos/app/mempool" + "github.com/stretchr/testify/require" + protov2 "google.golang.org/protobuf/proto" + + "cosmossdk.io/log/v2" + + "github.com/cosmos/cosmos-sdk/baseapp" + authcodec "github.com/cosmos/cosmos-sdk/codec/address" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/mempool" + signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" +) + +// The fast PrepareProposal path (mempool.type=app with the encoder cache) trusts +// admission + recheck and only encodes each pooled tx, where the default path +// re-runs the ante for every candidate. The tests below run both over identically +// seeded pools and pin down where the two are allowed to disagree: the fast path +// may propose a tx the ante would reject, and it leaves rejected txs in the pool +// for recheck instead of evicting them during the proposal. +// +// Every difference here is bounded by what the proposal contract permits: cronos +// ProcessProposal is blocklist-only, so an ante-invalid tx cannot make peers reject +// the block — FinalizeBlock records it as a failed tx result. + +const ( + txAlice0 = "alice-0" + txAlice1 = "alice-1" + txAlice2 = "alice-2" + txBob0 = "bob-0" + + diffDenom = "basecro" + diffMaxTxB = 1 << 20 + diffMaxGas = 100_000_000 +) + +// diffTx carries everything both paths need — signer and nonce for ordering, fee +// and gas for the baseFee gate, a timeout height for the expiry case — so neither +// needs a real codec or account keeper. Its raw bytes are its name, keeping a +// proposal's tx set readable in assertions. +type diffTx struct { + name string + signer sdk.AccAddress + seq uint64 + gas uint64 + fee int64 + timeout uint64 + priority int64 +} + +var ( + _ sdk.FeeTx = (*diffTx)(nil) + _ sdk.TxWithTimeoutHeight = (*diffTx)(nil) +) + +func (t *diffTx) GetMsgs() []sdk.Msg { return nil } +func (t *diffTx) GetMsgsV2() ([]protov2.Message, error) { return nil, nil } +func (t *diffTx) GetGas() uint64 { return t.gas } +func (t *diffTx) GetFee() sdk.Coins { return sdk.NewCoins(sdk.NewInt64Coin(diffDenom, t.fee)) } +func (t *diffTx) FeePayer() []byte { return t.signer } +func (t *diffTx) FeeGranter() []byte { return nil } +func (t *diffTx) GetTimeoutHeight() uint64 { return t.timeout } +func (t *diffTx) GetSigners() ([][]byte, error) { return [][]byte{t.signer}, nil } +func (t *diffTx) GetPubKeys() ([]cryptotypes.PubKey, error) { return nil, nil } +func (t *diffTx) GetSignaturesV2() ([]signingtypes.SignatureV2, error) { return nil, nil } + +func diffAddr(name string) sdk.AccAddress { return sdk.AccAddress(fmt.Sprintf("%-20s", name)) } + +type diffSignerExtractor struct{} + +func (diffSignerExtractor) GetSigners(tx sdk.Tx) ([]mempool.SignerData, error) { + dt, ok := tx.(*diffTx) + if !ok { + return nil, fmt.Errorf("unexpected tx type %T", tx) + } + return []mempool.SignerData{mempool.NewSignerData(dt.signer, dt.seq)}, nil +} + +// diffVerifier is the codec half of baseapp.ProposalTxVerifier for diffTx. ante +// stands in for the default path's RunTx(PrepareProposal); nil means encode-only, +// which is what the cached fast path does. +type diffVerifier struct { + byName map[string]*diffTx + ante func(*diffTx) error +} + +func (v *diffVerifier) TxEncode(tx sdk.Tx) ([]byte, error) { + dt, ok := tx.(*diffTx) + if !ok { + return nil, fmt.Errorf("unexpected tx type %T", tx) + } + return []byte(dt.name), nil +} + +func (v *diffVerifier) TxDecode(bz []byte) (sdk.Tx, error) { + dt, ok := v.byName[string(bz)] + if !ok { + return nil, fmt.Errorf("unknown tx %q", bz) + } + return dt, nil +} + +func (v *diffVerifier) PrepareProposalVerifyTx(tx sdk.Tx) ([]byte, error) { + bz, err := v.TxEncode(tx) + if err != nil { + return nil, err + } + if v.ante != nil { + if err := v.ante(tx.(*diffTx)); err != nil { + return nil, err + } + } + return bz, nil +} + +func (v *diffVerifier) ProcessProposalVerifyTx(bz []byte) (sdk.Tx, error) { + tx, err := v.TxDecode(bz) + if err != nil { + return nil, err + } + if v.ante != nil { + if err := v.ante(tx.(*diffTx)); err != nil { + return nil, err + } + } + return tx, nil +} + +func diffCtx(height int64) sdk.Context { + return sdk.NewContext(nil, cmtproto.Header{Height: height}, false, log.NewNopLogger()). + WithConsensusParams(cmtproto.ConsensusParams{Block: &cmtproto.BlockParams{MaxGas: diffMaxGas}}) +} + +// newDiffPool seeds a fresh pool per path, since the default path evicts the txs +// its ante rejects and would otherwise leak that into the other path's run. +func newDiffPool(t *testing.T, ctx sdk.Context, txs []*diffTx) *mempool.PriorityNonceMempool[int64] { + t.Helper() + pool := mempool.NewPriorityMempool(mempool.PriorityNonceMempoolConfig[int64]{ + TxPriority: mempool.NewDefaultTxPriority(), + SignerExtractor: diffSignerExtractor{}, + }) + for _, tx := range txs { + require.NoError(t, pool.Insert(ctx.WithPriority(tx.priority), tx)) + } + return pool +} + +func diffNames(txs []*diffTx) map[string]*diffTx { + byName := make(map[string]*diffTx, len(txs)) + for _, tx := range txs { + byName[tx.name] = tx + } + return byName +} + +func rawNames(raw [][]byte) []string { + names := make([]string, len(raw)) + for i, bz := range raw { + names[i] = string(bz) + } + return names +} + +func poolNames(t *testing.T, ctx sdk.Context, pool mempool.Mempool) []string { + t.Helper() + var names []string + for _, tx := range cronosmempool.PoolSnapshot(ctx, pool) { + dt, ok := tx.(*diffTx) + require.True(t, ok) + names = append(names, dt.name) + } + return names +} + +func acceptAllTxs(_ sdk.Tx, _ []byte) error { return nil } + +// runFastPath drives the production fast path: CacheProposalTxVerifier over a +// pre-warmed encoder cache (so no tx re-runs the ante) plus the cronos wrapper. +func runFastPath(t *testing.T, ctx sdk.Context, txs []*diffTx, feeGate func(sdk.Context) (*big.Int, string)) ([]string, []string) { + t.Helper() + pool := newDiffPool(t, ctx, txs) + base := &diffVerifier{byName: diffNames(txs)} + encCache := cronosmempool.NewEncoderCache(0, 0) + for _, tx := range txs { + encCache.Set(tx, []byte(tx.name)) + } + inner := baseapp.NewDefaultProposalHandler(pool, NewCacheProposalTxVerifier(base, encCache)) + h := NewMempoolProposalHandler(inner, acceptAllTxs, feeGate, diffSignerExtractor{}) + resp, err := h.PrepareProposalHandler()(ctx, &abci.RequestPrepareProposal{MaxTxBytes: diffMaxTxB, Height: ctx.BlockHeight()}) + require.NoError(t, err) + return rawNames(resp.Txs), poolNames(t, ctx, pool) +} + +// runAntePath drives the default handler with a full-ante verifier, the +// configuration used when the encoder cache is disabled. +func runAntePath(t *testing.T, ctx sdk.Context, txs []*diffTx, ante func(*diffTx) error) ([]string, []string) { + t.Helper() + pool := newDiffPool(t, ctx, txs) + h := baseapp.NewDefaultProposalHandler(pool, &diffVerifier{byName: diffNames(txs), ante: ante}) + h.SetTxSelector(NewExtTxSelector(acceptAllTxs, nil)) + h.SetSignerExtractionAdapter(diffSignerExtractor{}) + resp, err := h.PrepareProposalHandler()(ctx, &abci.RequestPrepareProposal{MaxTxBytes: diffMaxTxB, Height: ctx.BlockHeight()}) + require.NoError(t, err) + return rawNames(resp.Txs), poolNames(t, ctx, pool) +} + +// requireProcessProposalAccepts runs the real cronos ProcessProposal over a +// proposal, with a non-empty blocklist so the per-tx validation actually runs. +func requireProcessProposalAccepts(t *testing.T, ctx sdk.Context, txs []*diffTx, proposal []string) { + t.Helper() + base := &diffVerifier{byName: diffNames(txs)} + codec := authcodec.NewBech32Codec(sdk.GetConfig().GetBech32AccountAddrPrefix()) + blocked, err := codec.BytesToString(diffAddr("mallory")) + require.NoError(t, err) + ph := NewProposalHandler(base.TxDecode, nil, codec) + ph.blocklist = map[string]struct{}{blocked: {}} + + raw := make([][]byte, len(proposal)) + for i, name := range proposal { + raw[i] = []byte(name) + } + resp, err := ph.ProcessProposalHandler()(ctx, &abci.RequestProcessProposal{Txs: raw, Height: ctx.BlockHeight()}) + require.NoError(t, err) + require.Equal(t, abci.ResponseProcessProposal_ACCEPT, resp.Status) +} + +func TestProposalPathsDiff(t *testing.T) { + alice, bob := diffAddr("alice"), diffAddr("bob") + ctx := diffCtx(10) + acceptAllAnte := func(*diffTx) error { return nil } + + t.Run("all pooled txs valid: paths agree on selection and pool", func(t *testing.T) { + txs := []*diffTx{ + {name: txAlice0, signer: alice, seq: 0, gas: 21_000, fee: 21_000, priority: 5}, + {name: txAlice1, signer: alice, seq: 1, gas: 21_000, fee: 21_000, priority: 5}, + {name: txBob0, signer: bob, seq: 0, gas: 21_000, fee: 21_000, priority: 9}, + } + fastSel, fastPool := runFastPath(t, ctx, txs, nil) + anteSel, antePool := runAntePath(t, ctx, txs, acceptAllAnte) + require.Equal(t, anteSel, fastSel) + require.ElementsMatch(t, antePool, fastPool) + require.Len(t, fastSel, 3) + }) + + t.Run("nonce gap: both paths stop at the gap, neither evicts", func(t *testing.T) { + // The gap guard lives in DefaultProposalHandler's per-signer sequence + // tracking, which both paths share, so this must not diverge. + txs := []*diffTx{ + {name: txAlice0, signer: alice, seq: 0, gas: 21_000, fee: 21_000, priority: 5}, + {name: txAlice2, signer: alice, seq: 2, gas: 21_000, fee: 21_000, priority: 5}, + } + fastSel, fastPool := runFastPath(t, ctx, txs, nil) + anteSel, antePool := runAntePath(t, ctx, txs, acceptAllAnte) + require.Equal(t, []string{txAlice0}, fastSel) + require.Equal(t, anteSel, fastSel) + require.ElementsMatch(t, []string{txAlice0, txAlice2}, fastPool) + require.ElementsMatch(t, antePool, fastPool) + }) + + t.Run("stale nonce: fast path proposes it, ante path drops and evicts it", func(t *testing.T) { + // alice-0 was already committed; recheck hasn't evicted it yet. + txs := []*diffTx{ + {name: txAlice0, signer: alice, seq: 0, gas: 21_000, fee: 21_000, priority: 5}, + {name: txAlice1, signer: alice, seq: 1, gas: 21_000, fee: 21_000, priority: 5}, + } + staleBelow1 := func(tx *diffTx) error { + if tx.seq < 1 { + return fmt.Errorf("account sequence mismatch: got %d, expected 1", tx.seq) + } + return nil + } + fastSel, fastPool := runFastPath(t, ctx, txs, nil) + anteSel, antePool := runAntePath(t, ctx, txs, staleBelow1) + + require.Equal(t, []string{txAlice0, txAlice1}, fastSel) + require.Equal(t, []string{txAlice1}, anteSel, "ante rejects the committed nonce") + require.ElementsMatch(t, []string{txAlice0, txAlice1}, fastPool, "fast path leaves eviction to recheck") + require.Equal(t, []string{txAlice1}, antePool, "ante path evicts during the proposal") + requireProcessProposalAccepts(t, ctx, txs, fastSel) + }) + + t.Run("recheck backlog: fast path proposes the whole stale prefix", func(t *testing.T) { + // Worst case of the above: a block committed three of alice's txs and the + // async recheck hasn't run, so every pooled tx is stale. + txs := []*diffTx{ + {name: txAlice0, signer: alice, seq: 0, gas: 21_000, fee: 21_000, priority: 5}, + {name: txAlice1, signer: alice, seq: 1, gas: 21_000, fee: 21_000, priority: 5}, + {name: txAlice2, signer: alice, seq: 2, gas: 21_000, fee: 21_000, priority: 5}, + {name: txBob0, signer: bob, seq: 0, gas: 21_000, fee: 21_000, priority: 9}, + } + allStale := func(tx *diffTx) error { + if tx.signer.Equals(alice) { + return fmt.Errorf("account sequence mismatch: got %d, expected 3", tx.seq) + } + return nil + } + fastSel, fastPool := runFastPath(t, ctx, txs, nil) + anteSel, antePool := runAntePath(t, ctx, txs, allStale) + + require.ElementsMatch(t, []string{txAlice0, txAlice1, txAlice2, txBob0}, fastSel) + require.Equal(t, []string{txBob0}, anteSel) + require.Len(t, fastPool, 4) + require.Equal(t, []string{txBob0}, antePool) + requireProcessProposalAccepts(t, ctx, txs, fastSel) + }) + + t.Run("baseFee drift: paths agree on selection, differ on eviction", func(t *testing.T) { + // The gate replaces the ante's fee check on the fast path, so the selections + // match; only the pool side effect differs. + txs := []*diffTx{ + {name: txAlice0, signer: alice, seq: 0, gas: 100, fee: 1_000, priority: 5}, // feeCap 10 + {name: txBob0, signer: bob, seq: 0, gas: 100, fee: 10_000, priority: 9}, // feeCap 100 + } + gate := func(sdk.Context) (*big.Int, string) { return big.NewInt(50), diffDenom } + lowFeeRejected := func(tx *diffTx) error { + if tx.fee/int64(tx.gas) < 50 { + return fmt.Errorf("insufficient fee: feeCap %d below baseFee 50", tx.fee/int64(tx.gas)) + } + return nil + } + fastSel, fastPool := runFastPath(t, ctx, txs, gate) + anteSel, antePool := runAntePath(t, ctx, txs, lowFeeRejected) + + require.Equal(t, []string{txBob0}, fastSel) + require.Equal(t, anteSel, fastSel) + require.ElementsMatch(t, []string{txAlice0, txBob0}, fastPool, "gated tx stays pooled for a later block") + require.Equal(t, []string{txBob0}, antePool) + }) + + t.Run("timeout height: fast path proposes the expired tx", func(t *testing.T) { + // Timeout eviction is recheck's job on the fast path; the selector doesn't + // look at timeout height, so an expired tx survives until recheck runs. + txs := []*diffTx{ + {name: txAlice0, signer: alice, seq: 0, gas: 21_000, fee: 21_000, priority: 5, timeout: 5}, + {name: txBob0, signer: bob, seq: 0, gas: 21_000, fee: 21_000, priority: 9}, + } + timedOut := func(tx *diffTx) error { + if tx.timeout > 0 && uint64(ctx.BlockHeight()) >= tx.timeout { + return fmt.Errorf("tx timeout height %d exceeded", tx.timeout) + } + return nil + } + fastSel, fastPool := runFastPath(t, ctx, txs, nil) + anteSel, antePool := runAntePath(t, ctx, txs, timedOut) + + require.ElementsMatch(t, []string{txAlice0, txBob0}, fastSel) + require.Equal(t, []string{txBob0}, anteSel) + require.Len(t, fastPool, 2) + require.Equal(t, []string{txBob0}, antePool) + requireProcessProposalAccepts(t, ctx, txs, fastSel) + }) +} From ee7c14b2082312bded884a0d572f2f64b2d5260f Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Wed, 29 Jul 2026 21:23:58 -0400 Subject: [PATCH 05/12] fix(mempool): keep cascade off for every signer a multi-signer tx names Sort recheck groups ascending by seq and disable cascade for co-signers of a multi-signer tx, whose nonces the keyed group cannot see. --- app/app.go | 4 ++ app/mempool/recheck_async_test.go | 36 ++++++++++ app/mempool/recheck_test.go | 112 +++++++++++++++++++++++++++-- app/mempool/scheduler.go | 77 +++++++++++++++----- app/mempool/signer_adapter_test.go | 32 +++++++++ 5 files changed, 239 insertions(+), 22 deletions(-) create mode 100644 app/mempool/signer_adapter_test.go diff --git a/app/app.go b/app/app.go index f6bcf7efa0..2ab1a2a399 100644 --- a/app/app.go +++ b/app/app.go @@ -1699,6 +1699,10 @@ func (app *App) Commit() (*abci.ResponseCommit, error) { if err == nil { app.mempoolManager.RefreshMempoolStateLocked() } + // On error, base is left pointing at the superseded store: a Commit + // error is effectively fatal, and a node still restoring from state + // sync isn't proposing, so there's no admission/recheck traffic to + // serve a stale base to in the meantime. return resp, err }() diff --git a/app/mempool/recheck_async_test.go b/app/mempool/recheck_async_test.go index 3a2a3fcb01..576840441b 100644 --- a/app/mempool/recheck_async_test.go +++ b/app/mempool/recheck_async_test.go @@ -281,3 +281,39 @@ func TestWaitForRecheckTimedOut_ReturnsTrueWhenStuck(t *testing.T) { t.Fatal("expected timedOut=true; recheck is still stuck") } } + +// A pass that aborts mid-flight (gen advanced) still returns from RunTx's +// perspective having reached no further candidates; WaitForRecheck must not +// mistake that early return for a still-in-flight recheck and hang. +func TestWaitForRecheck_ReturnsAfterGenerationAbortedPass(t *testing.T) { + f := newAsyncRecheckFixture(t) + f.add(1, "alice", 0, aliceSeq0Bytes) + f.add(2, "bob", 0, "bob-0") + + // bob's group runs first (pool priority order); bump gen from inside it so + // alice's group aborts before it starts. + f.runner.onCall = func(txBytes []byte) { + if string(txBytes) == "bob-0" { + f.a.exec.gen.Add(1) // simulates a concurrent Commit landing mid-pass + } + } + f.a.sched.recheckSenders = map[string]struct{}{ + sdk.AccAddress("alice").String(): {}, + sdk.AccAddress("bob").String(): {}, + } + f.a.TriggerRecheck() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + f.a.WaitForRecheck(ctx) + + if ctx.Err() != nil { + t.Fatal("WaitForRecheck did not return promptly after a generation-aborted pass") + } + if !f.runner.seen["bob-0"] { + t.Fatal("the candidate validated before the bump must still have run") + } + if f.runner.seen[aliceSeq0Bytes] { + t.Fatal("the group after the bump must have been skipped, not rechecked against a superseded base") + } +} diff --git a/app/mempool/recheck_test.go b/app/mempool/recheck_test.go index 96e77eae03..7cc0ab0165 100644 --- a/app/mempool/recheck_test.go +++ b/app/mempool/recheck_test.go @@ -1132,18 +1132,122 @@ func TestRunRecheck_NonNonceFailureDoesNotCascade(t *testing.T) { } } -// Cascading assumes the group is the signer's ascending-nonce view; a descending -// pair means the assumption doesn't hold, so every candidate keeps its RunTx. -func TestRunRecheck_OutOfOrderNoncesDisableCascade(t *testing.T) { +// groupCandidates sorts a group ascending by seq before it runs, so a +// non-ascending pool/deferred order (5, 9, 7) no longer disables the cascade — +// the group becomes the signer's clean ascending view (5, 7, 9), and every +// candidate still gets its own RunTx up to the real gap. +func TestRunRecheck_NonAscendingPoolOrderSortedBeforeCascade(t *testing.T) { f := newRecheckFixture() valid := f.add(1, "carl", 5, carlSeq5Bytes) gapped := f.add(2, "carl", 9, "carl-9") lower := f.add(3, "carl", 7, carlSeq7Bytes) f.runner.failErrs = map[string]error{"carl-9": errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} + groups := f.a.sched.groupCandidates([]sdk.Tx{valid, gapped, lower}) + if len(groups) != 1 || !groups[0].cascadable { + t.Fatalf("sorted group must be cascadable, got groups=%+v", groups) + } + f.a.sched.runRecheck([]sdk.Tx{valid, gapped, lower}, f.a.exec.gen.Load()) if !f.runner.seen[carlSeq7Bytes] { - t.Fatal("a non-ascending group must not cascade") + t.Fatal("seq 7 sits between the valid and gapped candidates in the sorted group and must still run") + } + if !poolHas(f.pool, valid) || !poolHas(f.pool, lower) { + t.Fatal("the two candidates that pass recheck must stay in the pool") + } + if poolHas(f.pool, gapped) { + t.Fatal("the failing candidate must be evicted") + } +} + +// A multi-signer candidate makes its own group's nonce view incomplete: the +// group is keyed on the first signer, so the co-signers' nonces it also +// advances are invisible there. +func TestGroupCandidates_MultiSignerDisablesCascade(t *testing.T) { + f := newRecheckFixture() + single := f.insert(1, sdk.AccAddress("alice"), 3) + multi := f.insert(2, sdk.AccAddress("alice"), 5, sdk.AccAddress("bob")) + + groups := f.a.sched.groupCandidates([]sdk.Tx{single, multi}) + + if len(groups) != 1 { + t.Fatalf("expected both txs in one group keyed on alice, got %d groups", len(groups)) + } + if groups[0].cascadable { + t.Fatal("a multi-signer candidate in the group must disable cascade") + } +} + +// The multi-signer tx is keyed on bob, so alice's own group [3, 5] looks like a +// clean ascending view with a gap at 4 — but the bob-keyed tx also carries +// alice at nonce 4 and would fill it. Every signer a multi-signer tx names must +// lose cascade, not just the group that tx lands in. +func TestGroupCandidates_MultiSignerDisablesCascadeInCoSignerGroup(t *testing.T) { + f := newRecheckFixture() + alice3 := f.insert(1, sdk.AccAddress("alice"), 3) + alice5 := f.insert(2, sdk.AccAddress("alice"), 5) + bobMulti := f.insert(3, sdk.AccAddress("bob"), 4, sdk.AccAddress("alice")) + + groups := f.a.sched.groupCandidates([]sdk.Tx{alice3, alice5, bobMulti}) + + if len(groups) != 2 { + t.Fatalf("expected an alice group and a bob group, got %d", len(groups)) + } + for _, g := range groups { + if g.cascadable { + t.Fatalf("group %q must not cascade: the bob-keyed multi-signer tx names alice too", g.key) + } + } +} + +// Deferred front-loading can hand groupCandidates an out-of-nonce-order group +// (e.g. alice-5 ahead of alice-3 and alice-4). Without sorting, alice-5 would +// run first and fail wrong-sequence even though it becomes valid two txs later. +func TestGroupCandidates_SortsBySeqAscending(t *testing.T) { + f := newRecheckFixture() + seq5 := f.add(1, "alice", 5, "alice-5") + seq3 := f.add(2, "alice", 3, "alice-3") + seq4 := f.add(3, "alice", 4, "alice-4") + + groups := f.a.sched.groupCandidates([]sdk.Tx{seq5, seq3, seq4}) + + if len(groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(groups)) + } + got := []uint64{groups[0].txs[0].seq, groups[0].txs[1].seq, groups[0].txs[2].seq} + if got[0] != 3 || got[1] != 4 || got[2] != 5 { + t.Fatalf("group must be sorted ascending by seq, got %v", got) + } +} + +// The seq <= previous-seq check still needs to catch duplicates once sorting +// is in play, and the sort must be stable so tied seqs keep pool order. +func TestGroupCandidates_DuplicateSeqDisablesCascadeStableOrder(t *testing.T) { + f := newRecheckFixture() + first := f.add(1, "alice", 5, "alice-5a") + second := f.add(2, "alice", 5, "alice-5b") // duplicate seq + + groups := f.a.sched.groupCandidates([]sdk.Tx{first, second}) + + if len(groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(groups)) + } + g := groups[0] + if g.cascadable { + t.Fatal("a duplicate seq within a group must disable cascade") + } + if g.txs[0].tx != first || g.txs[1].tx != second { + t.Fatal("stable sort must preserve pool order for equal-seq txs") + } +} + +// firstSigner has a nil guard on s.signer; signers() must agree so an abort +// path (recoverSenders -> signers) can't panic when the scheduler was never +// wired with a signer extractor. +func TestSigners_NilSignerNoPanic(t *testing.T) { + s := &recheckScheduler{} + if got := s.signers(&ptrTx{id: 1}); got != nil { + t.Fatalf("expected nil signers with a nil extractor, got %v", got) } } diff --git a/app/mempool/scheduler.go b/app/mempool/scheduler.go index 0e21f07f46..8b2e472747 100644 --- a/app/mempool/scheduler.go +++ b/app/mempool/scheduler.go @@ -1,7 +1,9 @@ package mempool import ( + "cmp" "context" + "slices" "sync" "time" @@ -240,7 +242,7 @@ func (s *recheckScheduler) selectTxs(snapshot []sdk.Tx, recheckSenders map[strin } for _, tx := range candidates { if _, isDeferred := deferredLive[tx]; isDeferred { - continue // sender re-touched this cycle; avoid double recheck + continue // this tx is already in the deferred carry; avoid double recheck } ordered = append(ordered, tx) } @@ -286,11 +288,14 @@ type recheckCandidate struct { seq uint64 } -// recheckGroup holds one signer's candidates in pool order. cascadable is false -// when the group is not that signer's contiguous ascending-nonce view — an -// unknown signer, a repeated or out-of-order nonce, or a tx dropped on encode -// error — because the cascade rule reasons about the next expected nonce. +// recheckGroup holds one signer's candidates sorted ascending by seq. +// cascadable is false when the group is not that signer's contiguous +// ascending-nonce view — an unknown signer, a signer named by a multi-signer tx +// (that tx is grouped elsewhere, so it can fill a nonce this group can't see), a +// duplicate seq, or a tx dropped on encode error — because the cascade rule +// reasons about the next expected nonce. type recheckGroup struct { + key string txs []recheckCandidate cascadable bool } @@ -332,30 +337,59 @@ func (s *recheckScheduler) runRecheck(candidates []sdk.Tx, gen uint64) { } // groupCandidates buckets candidates by first signer — the one the mempool -// orders by — keeping first-appearance order across groups and pool order -// within one, so the front-loaded deferred prefix still runs first. Encoding -// happens here, outside the admission mutex, to keep the per-group hold to RunTx. +// orders by — keeping first-appearance order across groups. Encoding happens +// here, outside the admission mutex, to keep the per-group hold to RunTx. +// Within a group, candidates are sorted ascending by seq: deferred front- +// loading can hand candidates out of nonce order, and running them out of +// order would fail wrong-sequence against a nonce that a later candidate in +// the same group would have satisfied. func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { groups := make([]recheckGroup, 0, len(candidates)) index := make(map[string]int, len(candidates)) + // Every signer named by a multi-signer tx: that tx is grouped under its first + // signer only, so it can advance a co-signer's nonce from outside that + // co-signer's group, making a gap there unprovable. + var coSigned map[string]struct{} for _, tx := range candidates { - key, seq, known := s.firstSigner(tx) + key, seq, known, multiSigner := s.firstSigner(tx) gi, seen := index[key] if !seen { - groups = append(groups, recheckGroup{cascadable: known}) + groups = append(groups, recheckGroup{key: key, cascadable: known}) gi = len(groups) - 1 index[key] = gi } g := &groups[gi] + if multiSigner { + if coSigned == nil { + coSigned = make(map[string]struct{}) + } + for _, sg := range s.signers(tx) { + coSigned[sg] = struct{}{} + } + } bz, _, err := EncodeTx(s.exec.encCache, s.exec.txEncoder, tx) if err != nil { g.cascadable = false continue } - if n := len(g.txs); n > 0 && seq <= g.txs[n-1].seq { + g.txs = append(g.txs, recheckCandidate{tx: tx, bz: bz, seq: seq}) + } + for i := range groups { + g := &groups[i] + if _, ok := coSigned[g.key]; ok { g.cascadable = false } - g.txs = append(g.txs, recheckCandidate{tx: tx, bz: bz, seq: seq}) + slices.SortStableFunc(g.txs, func(a, b recheckCandidate) int { + return cmp.Compare(a.seq, b.seq) + }) + // A duplicate seq can only appear as adjacent equal entries once sorted; + // it still means the group isn't a clean ascending-nonce view. + for j := 1; j < len(g.txs); j++ { + if g.txs[j].seq <= g.txs[j-1].seq { + g.cascadable = false + break + } + } } return groups } @@ -463,20 +497,27 @@ func (s *recheckScheduler) evict(tx sdk.Tx) { s.exec.encCache.Evict(tx) } -// firstSigner returns the signer the mempool orders by, with its nonce. An -// unknown signer only costs the cascade optimization, not the recheck itself. -func (s *recheckScheduler) firstSigner(tx sdk.Tx) (key string, seq uint64, known bool) { +// firstSigner returns the signer the mempool orders by, with its nonce, and +// whether tx has more than one signer. An unknown signer only costs the +// cascade optimization, not the recheck itself. A multi-signer tx must also +// disable the cascade: a secondary signer's nonce isn't visible to the group +// keyed on the first signer, so a gap in that group may really be filled by +// a multi-signer tx grouped elsewhere. +func (s *recheckScheduler) firstSigner(tx sdk.Tx) (key string, seq uint64, known, multiSigner bool) { if s.signer == nil { - return "", 0, false + return "", 0, false, false } sigs, err := s.signer.GetSigners(tx) if err != nil || len(sigs) == 0 { - return "", 0, false + return "", 0, false, false } - return sigs[0].Signer.String(), sigs[0].Sequence, true + return sigs[0].Signer.String(), sigs[0].Sequence, true, len(sigs) > 1 } func (s *recheckScheduler) signers(tx sdk.Tx) []string { + if s.signer == nil { + return nil + } sigs, err := s.signer.GetSigners(tx) if err != nil { return nil diff --git a/app/mempool/signer_adapter_test.go b/app/mempool/signer_adapter_test.go new file mode 100644 index 0000000000..5d20544d7b --- /dev/null +++ b/app/mempool/signer_adapter_test.go @@ -0,0 +1,32 @@ +package mempool_test + +import ( + "testing" + + "github.com/evmos/ethermint/evmd" + "github.com/stretchr/testify/require" + + sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" +) + +// TestEthSignerExtractionAdapter_SequenceIsEvmNonce pins the mapping the +// recheck cascade's seq arithmetic depends on: for a MsgEthereumTx, the +// adapter wired in app.go (evmd.NewEthSignerExtractionAdapter) puts the EVM +// tx's nonce into SignerData.Sequence, not the signer's cosmos account +// sequence. +func TestEthSignerExtractionAdapter_SequenceIsEvmNonce(t *testing.T) { + f := setupAdmissionApp(t, 1) + acc := &f.accounts[0] + acc.Nonce = 7 // distinct from the fresh account's cosmos sequence (0) + + bz := f.signTransfer(t, acc, nil) + tx, err := f.app.TxConfig().TxDecoder()(bz) + require.NoError(t, err) + + adapter := evmd.NewEthSignerExtractionAdapter(sdkmempool.NewDefaultSignerExtractionAdapter()) + signers, err := adapter.GetSigners(tx) + require.NoError(t, err) + require.Len(t, signers, 1) + require.Equal(t, uint64(7), signers[0].Sequence, + "adapter must map the EVM tx nonce, not the cosmos account sequence, into SignerData.Sequence") +} From 307d0821ccb0427ea33c699bb26d422c908676ba Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Wed, 29 Jul 2026 21:58:28 -0400 Subject: [PATCH 06/12] fix(mempool): cap recheck at group boundaries and chunk the mutex hold 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. --- app/app.go | 9 +- app/mempool/admission_test.go | 2 +- app/mempool/manager_test.go | 4 +- app/mempool/recheck_test.go | 330 ++++++++++++++++++++++++++++++---- app/mempool/scheduler.go | 130 ++++++++++---- 5 files changed, 393 insertions(+), 82 deletions(-) diff --git a/app/app.go b/app/app.go index 2ab1a2a399..e06d10f055 100644 --- a/app/app.go +++ b/app/app.go @@ -1699,10 +1699,11 @@ func (app *App) Commit() (*abci.ResponseCommit, error) { if err == nil { app.mempoolManager.RefreshMempoolStateLocked() } - // On error, base is left pointing at the superseded store: a Commit - // error is effectively fatal, and a node still restoring from state - // sync isn't proposing, so there's no admission/recheck traffic to - // serve a stale base to in the meantime. + // On error, base is left pointing at the superseded store. Same for the + // store reload in ApplySnapshotChunk, which doesn't refresh base at + // all: a Commit error is effectively fatal and a state-syncing node + // isn't admitting or proposing, so nothing reads base until the next + // successful Commit refreshes it. return resp, err }() diff --git a/app/mempool/admission_test.go b/app/mempool/admission_test.go index 26c89f7ea4..d78d68d2b8 100644 --- a/app/mempool/admission_test.go +++ b/app/mempool/admission_test.go @@ -251,7 +251,7 @@ func TestReapVsRecheckConcurrentRealTxs(t *testing.T) { } require.Equal(t, accounts, f.app.Mempool().CountTx()) - // Admission bumped each sender's checkState nonce; an empty block resets it to + // Admission bumped each sender's nonce in base; an empty block resets it to // committed (nonce 0) so recheck of these nonce-0 txs passes instead of // failing stale. _, err := f.app.FinalizeBlock(&abci.RequestFinalizeBlock{Height: 2, ProposerAddress: f.consAddress}) diff --git a/app/mempool/manager_test.go b/app/mempool/manager_test.go index 84c0ae662e..6ebd07e7ee 100644 --- a/app/mempool/manager_test.go +++ b/app/mempool/manager_test.go @@ -674,7 +674,7 @@ func TestManager_AllThreeRunTxSitesShareBaseInstance(t *testing.T) { a.adm.admit([]byte("tx1")) check := a.CheckTxHandler() check(nil, &abci.RequestCheckTx{Tx: []byte("tx2")}) //nolint:errcheck - a.sched.runRecheck([]sdk.Tx{&ptrTx{id: 1}}, a.exec.gen.Load()) + a.sched.runRecheck(a.sched.groupCandidates([]sdk.Tx{&ptrTx{id: 1}}), a.exec.gen.Load()) if len(runner.ms) != 3 { t.Fatalf("expected 3 RunTx calls (admit, CheckTxHandler, runRecheck), got %d", len(runner.ms)) @@ -744,7 +744,7 @@ func TestManager_RecheckWriteVisibleToLaterAdmit(t *testing.T) { a := newManager(&nonceBranchRunner{}, nil, noopEncoder, nil) a.exec.state = &mempoolState{base: store} - a.sched.runRecheck([]sdk.Tx{&ptrTx{id: 1}}, a.exec.gen.Load()) + a.sched.runRecheck(a.sched.groupCandidates([]sdk.Tx{&ptrTx{id: 1}}), a.exec.gen.Load()) code, _, log := a.adm.admit([]byte("alice-nonce-8-sibling")) if code != abci.CodeTypeOK { diff --git a/app/mempool/recheck_test.go b/app/mempool/recheck_test.go index 7cc0ab0165..d63a9a3937 100644 --- a/app/mempool/recheck_test.go +++ b/app/mempool/recheck_test.go @@ -48,6 +48,12 @@ type recheckRunner struct { // onCall, if set, runs after recording the call but before returning, letting // a test bump gen mid-pass to exercise runRecheck's cancellation check. onCall func(txBytes []byte) + // signer + expectedNonce implement per-sender expected-nonce ante + // semantics: a tx whose seq is above its sender's expected nonce fails + // wrong-sequence, and a successful recheck advances that sender's expected + // nonce. Nil signer disables this; other tests drive failBytes/failErrs. + signer sdkmempool.SignerExtractionAdapter + expectedNonce map[string]uint64 } func (r *recheckRunner) RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, _ int, _ storetypes.MultiStore, _ map[string]any) (sdk.GasInfo, *sdk.Result, []abci.Event, error) { @@ -59,6 +65,19 @@ func (r *recheckRunner) RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, _ in if r.onCall != nil { r.onCall(txBytes) } + + var sender string + var seq uint64 + trackNonce := false + if r.signer != nil { + if sigs, err := r.signer.GetSigners(tx); err == nil && len(sigs) > 0 { + sender, seq, trackNonce = sigs[0].Signer.String(), sigs[0].Sequence, true + } + } + if trackNonce && seq > r.expectedNonce[sender] { + return sdk.GasInfo{}, nil, nil, errorsmod.Wrap(sdkerrors.ErrWrongSequence, "nonce gap") + } + if err, ok := r.failErrs[string(txBytes)]; ok { return sdk.GasInfo{}, nil, nil, err } @@ -69,6 +88,13 @@ func (r *recheckRunner) RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, _ in if r.failNoRemoveBytes[string(txBytes)] { return sdk.GasInfo{}, nil, nil, errors.New("msg execution failed on recheck") } + + if trackNonce { + if r.expectedNonce == nil { + r.expectedNonce = map[string]uint64{} + } + r.expectedNonce[sender] = seq + 1 + } return sdk.GasInfo{}, &sdk.Result{}, nil, nil } @@ -462,16 +488,20 @@ func (s *lockObservingSigner) GetSigners(tx sdk.Tx) ([]sdkmempool.SignerData, er return sd, nil } -// RecheckTxs must not run more than maxRecheckBatch RunTx calls in one cycle. +// RecheckTxs must not run more than maxRecheckBatch RunTx calls in one cycle +// when the cap boundary falls between (single-tx) signer groups. func TestRecheckTxs_BatchCapLimitsCandidates(t *testing.T) { const total = 5 const batch = 2 f := newRecheckFixture() + recheckSenders := make(map[string]struct{}, total) for i := 0; i < total; i++ { - f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) + sender := "sender" + strconv.Itoa(i) + f.add(i+1, sender, 0, sender+"-0") + recheckSenders[sdk.AccAddress(sender).String()] = struct{}{} } f.a.sched.maxRecheckBatch = batch - f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = recheckSenders f.a.sched.RecheckTxs() @@ -480,35 +510,67 @@ func TestRecheckTxs_BatchCapLimitsCandidates(t *testing.T) { } } -// Overflow past the batch cap must carry forward and drain over later cycles — -// front-loaded so the priority-ordered tail isn't re-deferred forever — with -// every tx rechecked exactly once. -func TestRecheckTxs_BatchCapCarriesOverflow(t *testing.T) { - const total = 5 - const batch = 2 +// Reproduces the batch cap splitting a signer's nonce chain (pre-fix, the flat +// cap sliced the candidate list before grouping by signer). bob's one-tx group +// fills the cap; alice's five-tx chain must carry forward whole rather than +// being split mid-chain, so it revalidates cleanly from her real nonce (8) +// once a Commit lands between cycles and no valid tx is evicted. +func TestRecheckTxs_BatchCapCarriesOverflowWithoutSplittingGroup(t *testing.T) { + const batch = 3 f := newRecheckFixture() - for i := 0; i < total; i++ { - f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) + f.runner.signer = f.signer // enables per-sender expected-nonce semantics in the fake RunTx + f.runner.expectedNonce = map[string]uint64{sdk.AccAddress("alice").String(): 8} // account nonce 8 + + bob := f.add(1, "bob", 0, "bob-0") + aliceSeqs := []uint64{8, 9, 10, 11, 12} + alice := make([]*ptrTx, len(aliceSeqs)) + for i, seq := range aliceSeqs { + alice[i] = f.add(10+i, "alice", seq, "alice-"+strconv.FormatUint(seq, 10)) } + f.a.sched.maxRecheckBatch = batch - f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = map[string]struct{}{ + sdk.AccAddress("bob").String(): {}, + sdk.AccAddress("alice").String(): {}, + } - // Cycle 1 touches alice; cycles 2-3 have empty recheckSenders but must still drain - // the carried overflow. - f.a.sched.RecheckTxs() - f.a.sched.RecheckTxs() + // Cycle 1: bob's group (1 tx) fits under the cap; alice's group (5 txs) + // would push the running total to 6 > 3, so the whole group must defer. f.a.sched.RecheckTxs() - if got := len(f.runner.modes); got != total { - t.Fatalf("expected all %d txs rechecked across cycles, got %d", total, got) + if !f.runner.seen["bob-0"] { + t.Fatal("bob's group must run in cycle 1") } - for i := 0; i < total; i++ { - if !f.runner.seen["alice-"+strconv.Itoa(i)] { - t.Fatalf("alice-%d was never rechecked (starved past the cap)", i) + for _, seq := range aliceSeqs { + if f.runner.seen["alice-"+strconv.FormatUint(seq, 10)] { + t.Fatalf("alice's group must not be partially run before deferring, but seq %d ran", seq) } } - if f.a.sched.deferred != nil { - t.Fatalf("deferred queue must be drained, still holds %d", len(f.a.sched.deferred)) + if len(f.a.sched.deferred) != len(aliceSeqs) { + t.Fatalf("expected alice's whole group (%d txs) deferred, got %d", len(aliceSeqs), len(f.a.sched.deferred)) + } + + // A Commit lands between cycles: base rebranches off the committed store + // (alice's chain was only rechecked, never included in a block, so her + // real nonce is still 8) and gen advances. + f.a.exec.gen.Add(1) + + // Cycle 2: recheckSenders is empty, but the deferred carry must still run + // as one atomic group against alice's real nonce. + f.a.sched.RecheckTxs() + + for _, seq := range aliceSeqs { + if !f.runner.seen["alice-"+strconv.FormatUint(seq, 10)] { + t.Fatalf("alice-%d must be rechecked in cycle 2", seq) + } + } + if !poolHas(f.pool, bob) { + t.Fatal("bob's tx must remain valid in the pool") + } + for i, tx := range alice { + if !poolHas(f.pool, tx) { + t.Fatalf("alice's tx at index %d (seq %d) must not be evicted: the cap must not split her nonce chain", i, aliceSeqs[i]) + } } } @@ -754,12 +816,15 @@ func TestRecheckTxs_TTLEvictsRegardlessOfBatchCap(t *testing.T) { const total = 5 f := newRecheckFixture() f.a.sched.ttlNumBlocks = 2 - f.a.sched.maxRecheckBatch = 1 // far below total + f.a.sched.maxRecheckBatch = 1 // far below total; one sender per tx so the cap can bite txs := make([]*ptrTx, total) + recheckSenders := make(map[string]struct{}, total) for i := 0; i < total; i++ { - txs[i] = f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) + sender := "sender" + strconv.Itoa(i) + txs[i] = f.add(i+1, sender, 0, sender+"-0") + recheckSenders[sdk.AccAddress(sender).String()] = struct{}{} } - f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = recheckSenders f.a.sched.lastCommittedHeight = 100 // first sighting: arrival=100 f.a.sched.RecheckTxs() @@ -767,7 +832,7 @@ func TestRecheckTxs_TTLEvictsRegardlessOfBatchCap(t *testing.T) { t.Fatalf("cycle1: batch cap must bound recheck to 1, got %d", got) } - f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = recheckSenders f.a.sched.lastCommittedHeight = 102 // 102-100 == 2 == ttl → all aged out before := len(f.runner.modes) f.a.sched.RecheckTxs() @@ -791,12 +856,15 @@ func TestRecheckTxs_TTLEvictsDeferredCarryover(t *testing.T) { const total = 4 f := newRecheckFixture() f.a.sched.ttlNumBlocks = 3 - f.a.sched.maxRecheckBatch = 1 // force overflow into deferred + f.a.sched.maxRecheckBatch = 1 // force overflow into deferred; one sender per tx so the cap can bite txs := make([]*ptrTx, total) + recheckSenders := make(map[string]struct{}, total) for i := 0; i < total; i++ { - txs[i] = f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) + sender := "sender" + strconv.Itoa(i) + txs[i] = f.add(i+1, sender, 0, sender+"-0") + recheckSenders[sdk.AccAddress(sender).String()] = struct{}{} } - f.a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + f.a.sched.recheckSenders = recheckSenders f.a.sched.lastCommittedHeight = 50 // arrival=50 for all f.a.sched.RecheckTxs() @@ -1029,7 +1097,7 @@ func TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred(t *te } } gen := f.a.exec.gen.Load() - f.a.sched.runRecheck([]sdk.Tx{aliceTx, bobTx}, gen) + f.a.sched.runRecheck(f.a.sched.groupCandidates([]sdk.Tx{aliceTx, bobTx}), gen) if !f.runner.seen[aliceSeq0Bytes] { t.Fatal("the candidate validated before the bump must still run") @@ -1069,7 +1137,7 @@ func TestRunRecheck_GroupsCandidatesBySigner(t *testing.T) { bob := f.add(2, "bob", 0, "bob-0") aliceHigh := f.add(3, "alice", 1, "alice-1") - f.a.sched.runRecheck([]sdk.Tx{aliceLow, bob, aliceHigh}, f.a.exec.gen.Load()) + f.a.sched.runRecheck(f.a.sched.groupCandidates([]sdk.Tx{aliceLow, bob, aliceHigh}), f.a.exec.gen.Load()) want := []string{aliceSeq0Bytes, "alice-1", "bob-0"} if !slices.Equal(f.runner.calls, want) { @@ -1084,7 +1152,7 @@ func TestRunRecheck_NonceGapCascadesToHigherSiblings(t *testing.T) { higher := f.add(3, "carl", 8, carlSeq8Bytes) f.runner.failErrs = map[string]error{carlSeq7Bytes: errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} - f.a.sched.runRecheck([]sdk.Tx{valid, gapped, higher}, f.a.exec.gen.Load()) + f.a.sched.runRecheck(f.a.sched.groupCandidates([]sdk.Tx{valid, gapped, higher}), f.a.exec.gen.Load()) if f.runner.seen[carlSeq8Bytes] { t.Fatal("a sibling behind a proven nonce gap must be evicted without spending a RunTx") @@ -1105,7 +1173,7 @@ func TestRunRecheck_StaleNonceDoesNotCascade(t *testing.T) { next := f.add(2, "carl", 6, "carl-6") f.runner.failErrs = map[string]error{carlSeq5Bytes: errorsmod.Wrap(sdkerrors.ErrInvalidSequence, "stale")} - f.a.sched.runRecheck([]sdk.Tx{stale, next}, f.a.exec.gen.Load()) + f.a.sched.runRecheck(f.a.sched.groupCandidates([]sdk.Tx{stale, next}), f.a.exec.gen.Load()) if !f.runner.seen["carl-6"] { t.Fatal("the successor of a stale nonce must still be rechecked") @@ -1125,7 +1193,7 @@ func TestRunRecheck_NonNonceFailureDoesNotCascade(t *testing.T) { higher := f.add(3, "carl", 8, carlSeq8Bytes) f.runner.failErrs = map[string]error{carlSeq7Bytes: errorsmod.Wrap(sdkerrors.ErrInsufficientFunds, "no funds")} - f.a.sched.runRecheck([]sdk.Tx{valid, failing, higher}, f.a.exec.gen.Load()) + f.a.sched.runRecheck(f.a.sched.groupCandidates([]sdk.Tx{valid, failing, higher}), f.a.exec.gen.Load()) if !f.runner.seen[carlSeq8Bytes] { t.Fatal("only a nonce gap justifies skipping a sibling's RunTx") @@ -1148,7 +1216,7 @@ func TestRunRecheck_NonAscendingPoolOrderSortedBeforeCascade(t *testing.T) { t.Fatalf("sorted group must be cascadable, got groups=%+v", groups) } - f.a.sched.runRecheck([]sdk.Tx{valid, gapped, lower}, f.a.exec.gen.Load()) + f.a.sched.runRecheck(f.a.sched.groupCandidates([]sdk.Tx{valid, gapped, lower}), f.a.exec.gen.Load()) if !f.runner.seen[carlSeq7Bytes] { t.Fatal("seq 7 sits between the valid and gapped candidates in the sorted group and must still run") @@ -1242,6 +1310,196 @@ func TestGroupCandidates_DuplicateSeqDisablesCascadeStableOrder(t *testing.T) { } } +// A group larger than recheckChunkSize must still run every candidate: the +// chunking in recheckGroup bounds one mutex hold, not how much of the group +// eventually gets rechecked. +func TestRecheckGroup_LargerThanChunkRunsEveryCandidate(t *testing.T) { + const total = recheckChunkSize + 50 + f := newRecheckFixture() + txs := make([]sdk.Tx, total) + for i := 0; i < total; i++ { + txs[i] = f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) + } + + groups := f.a.sched.groupCandidates(txs) + if len(groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(groups)) + } + + evicted, cascaded, aborted, unreachedFrom := f.a.sched.recheckGroup(groups[0], f.a.exec.gen.Load()) + if aborted { + t.Fatal("group must not abort: gen never changed") + } + if unreachedFrom != total { + t.Fatalf("expected the whole group reached, got unreachedFrom=%d", unreachedFrom) + } + if evicted != 0 || cascaded != 0 { + t.Fatalf("expected no evictions, got evicted=%v cascaded=%v", evicted, cascaded) + } + if got := len(f.runner.calls); got != total { + t.Fatalf("expected every candidate across chunk boundaries to run, got %d RunTx calls", got) + } + for i := 0; i < total; i++ { + if !f.runner.seen["alice-"+strconv.Itoa(i)] { + t.Fatalf("alice-%d must have run", i) + } + } +} + +// A gen bump landing exactly at a chunk boundary must abort the group there: +// the completed chunk stays rechecked, and the untouched tail's sender is +// re-staged so the next cycle covers it — mirroring the same-generation +// recovery runRecheck already does between groups. +func TestRunRecheck_GenBumpAtChunkBoundaryAbortsAndRestagesSender(t *testing.T) { + const total = recheckChunkSize + 50 + f := newRecheckFixture() + txs := make([]sdk.Tx, total) + ptrTxs := make([]*ptrTx, total) + for i := 0; i < total; i++ { + ptrTxs[i] = f.add(i+1, "alice", uint64(i), "alice-"+strconv.Itoa(i)) + txs[i] = ptrTxs[i] + } + lastOfFirstChunk := "alice-" + strconv.Itoa(recheckChunkSize-1) + f.runner.onCall = func(txBytes []byte) { + if string(txBytes) == lastOfFirstChunk { + f.a.exec.gen.Add(1) // simulate a Commit landing right as the first chunk finishes + } + } + + gen := f.a.exec.gen.Load() + groups := f.a.sched.groupCandidates(txs) + f.a.sched.runRecheck(groups, gen) + + if got := len(f.runner.calls); got != recheckChunkSize { + t.Fatalf("expected exactly the first chunk (%d) to run, got %d", recheckChunkSize, got) + } + for i := recheckChunkSize; i < total; i++ { + if f.runner.seen["alice-"+strconv.Itoa(i)] { + t.Fatalf("candidate %d in the aborted second chunk must not have run", i) + } + if !poolHas(f.pool, ptrTxs[i]) { + t.Fatalf("candidate %d must remain in the pool after the abort", i) + } + } + if _, ok := f.a.sched.recheckSenders[sdk.AccAddress("alice").String()]; !ok { + t.Fatal("alice must be re-staged after the aborted chunk so the next cycle covers her unreached tail") + } +} + +// A nonce gap discovered in a later chunk must still cascade-evict every +// higher-nonce sibling, including ones that live in a chunk beyond the one +// where the gap was found — the cascade doesn't stop at a chunk boundary. +func TestRecheckGroup_CascadeEvictsAcrossChunkBoundary(t *testing.T) { + const total = 3*recheckChunkSize - 88 // spans 3 chunks; boundaries at 256, 512 + const gapIndex = 400 // inside chunk 2 ([256, 512)) + f := newRecheckFixture() + txs := make([]sdk.Tx, total) + ptrTxs := make([]*ptrTx, total) + bz := func(i int) string { return "carl-" + strconv.Itoa(i) } + for i := 0; i < total; i++ { + seq := uint64(i) + if i >= gapIndex { + seq += 2 // opens a gap at gapIndex and keeps ascending order past it + } + ptrTxs[i] = f.add(i+1, "carl", seq, bz(i)) + txs[i] = ptrTxs[i] + } + f.runner.failErrs = map[string]error{bz(gapIndex): errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} + + groups := f.a.sched.groupCandidates(txs) + if len(groups) != 1 || !groups[0].cascadable { + t.Fatalf("expected 1 cascadable group, got %+v", groups) + } + + evicted, cascaded, aborted, unreachedFrom := f.a.sched.recheckGroup(groups[0], f.a.exec.gen.Load()) + if aborted { + t.Fatal("group must not abort: gen never changed") + } + if unreachedFrom != total { + t.Fatalf("expected the whole group reached (run or cascade-evicted), got unreachedFrom=%d", unreachedFrom) + } + if evicted != 1 { + t.Fatalf("expected 1 direct eviction (the gapped tx), got %v", evicted) + } + if want := float32(total - gapIndex - 1); cascaded != want { + t.Fatalf("expected %v cascade-evicted siblings spanning chunk 2 and chunk 3, got %v", want, cascaded) + } + for i := gapIndex + 1; i < total; i++ { + if f.runner.seen[bz(i)] { + t.Fatalf("sibling at index %d must be cascade-evicted without a RunTx", i) + } + if poolHas(f.pool, ptrTxs[i]) { + t.Fatalf("sibling at index %d must be evicted from the pool", i) + } + } + if !f.runner.seen[bz(gapIndex-1)] { + t.Fatal("the last successful candidate before the gap must have run") + } +} + +// F3: gen is read right before runRecheck, not right after drainStaging, so a +// Commit landing during the O(pool) scan/grouping no longer wastes the whole +// pass at group 0. Bumping gen as PoolSnapshot starts (before selectTxs runs) +// simulates that landing point. +func TestRecheckTxs_GenBumpBeforeScanStillRunsCandidates(t *testing.T) { + signer := fakeSigner{m: map[sdk.Tx][]sdkmempool.SignerData{}} + tx := &ptrTx{id: 1} + signer.m[tx] = []sdkmempool.SignerData{sdkmempool.NewSignerData(sdk.AccAddress("alice"), 0)} + pool := &scanHookMempool{txs: []sdk.Tx{tx}} + runner := &recheckRunner{pool: pool, failBytes: map[string]bool{}, seen: map[string]bool{}} + txEncoder := func(sdk.Tx) ([]byte, error) { return []byte("alice-0"), nil } + a := newManager(runner, NewEncoderCache(0, 0), txEncoder, func([]byte) (sdk.Tx, error) { return nil, errors.New("unused") }) + a.sched.mpool = pool + a.sched.signer = signer + a.sched.recheckSenders = map[string]struct{}{sdk.AccAddress("alice").String(): {}} + pool.onScan = func() { a.exec.gen.Add(1) } + + a.sched.RecheckTxs() + + if !runner.seen["alice-0"] { + t.Fatal("a gen bump before the scan starts must not abort the pass before it runs anything") + } +} + +// scanHookMempool runs onScan when the pool size is first queried (the start +// of PoolSnapshot), so a test can simulate a Commit's gen bump landing right +// as the O(pool) scan begins. +type scanHookMempool struct { + txs []sdk.Tx + onScan func() +} + +func (m *scanHookMempool) Insert(context.Context, sdk.Tx) error { return nil } +func (m *scanHookMempool) Select(context.Context, [][]byte) sdkmempool.Iterator { return nil } +func (m *scanHookMempool) CountTx() int { + if m.onScan != nil { + m.onScan() + } + return len(m.txs) +} + +func (m *scanHookMempool) Remove(tx sdk.Tx) error { + for i, t := range m.txs { + if t == tx { + m.txs = append(m.txs[:i], m.txs[i+1:]...) + return nil + } + } + return nil +} + +func (m *scanHookMempool) RemoveWithReason(_ context.Context, tx sdk.Tx, _ sdkmempool.RemoveReason) error { + return m.Remove(tx) +} + +func (m *scanHookMempool) SelectBy(_ context.Context, _ [][]byte, cb func(sdk.Tx) bool) { + for _, tx := range m.txs { + if !cb(tx) { + return + } + } +} + // firstSigner has a nil guard on s.signer; signers() must agree so an abort // path (recoverSenders -> signers) can't panic when the scheduler was never // wired with a signer extractor. diff --git a/app/mempool/scheduler.go b/app/mempool/scheduler.go index 8b2e472747..d2522bdf9e 100644 --- a/app/mempool/scheduler.go +++ b/app/mempool/scheduler.go @@ -22,7 +22,9 @@ type recheckScheduler struct { exec *txExec mpool sdkmempool.Mempool signer sdkmempool.SignerExtractionAdapter - // maxRecheckBatch caps RunTx(ReCheck) calls per Commit cycle; 0 = unlimited. + // maxRecheckBatch softly caps RunTx(ReCheck) calls per Commit cycle: it splits + // only at group boundaries, so a signer's whole nonce chain always runs + // together even if that group alone exceeds the cap. 0 = unlimited. maxRecheckBatch int // stagingMu guards the staging fields (recheckSenders, deferred, lastCommittedHeight). // Separate from the admission mutex so FinalizeBlock staging never blocks behind a recheck batch. @@ -134,15 +136,19 @@ func (s *recheckScheduler) RecheckTxs() { s.recheckMu.Lock() // lock order: see the recheckMu field comment defer s.recheckMu.Unlock() recheckSenders, height, deferred := s.drainStaging() - gen := s.exec.gen.Load() // Before the first block (height 0) with no senders/carry there's nothing to scan. if len(recheckSenders) == 0 && len(deferred) == 0 && height == 0 { return } snapshot := PoolSnapshot(context.Background(), s.mpool) - candidates := s.capRecheckTxs(s.selectTxs(snapshot, recheckSenders, height, deferred)) - s.runRecheck(candidates, gen) + candidates := s.selectTxs(snapshot, recheckSenders, height, deferred) + groups := s.capRecheckGroups(s.groupCandidates(candidates)) + // Read gen only now: it must cover the RunTx phase below, not the O(pool) + // scan/grouping above, or a Commit landing during the scan would abort the + // whole pass before a single group runs. + gen := s.exec.gen.Load() + s.runRecheck(groups, gen) telemetry.SetGauge(float32(s.mpool.CountTx()), "cronos", "mempool", "pool", "size") } @@ -267,17 +273,25 @@ func (s *recheckScheduler) evictForRecheck(tx sdk.Tx, evictedSet map[sdk.Tx]stru return evictedSet, recheckSenders } -// capRecheckTxs bounds RunTx(ReCheck) per cycle; overflow carries forward. -func (s *recheckScheduler) capRecheckTxs(candidates []sdk.Tx) []sdk.Tx { - if s.maxRecheckBatch <= 0 || len(candidates) <= s.maxRecheckBatch { - return candidates +// capRecheckGroups bounds RunTx(ReCheck) calls per cycle without ever +// splitting a signer's group: the first group always runs in full regardless +// of size, and once the running total would exceed maxRecheckBatch the +// remaining groups carry forward whole into deferred. +func (s *recheckScheduler) capRecheckGroups(groups []recheckGroup) []recheckGroup { + if s.maxRecheckBatch <= 0 { + return groups } - carried := make([]sdk.Tx, len(candidates)-s.maxRecheckBatch) - copy(carried, candidates[s.maxRecheckBatch:]) - s.stagingMu.Lock() - s.deferred = carried - s.stagingMu.Unlock() - return candidates[:s.maxRecheckBatch] + count := 0 + for i, g := range groups { + if count > 0 && count+len(g.txs) > s.maxRecheckBatch { + s.stagingMu.Lock() + s.deferred = unreachedTxs(groups[i:]) + s.stagingMu.Unlock() + return groups[:i] + } + count += len(g.txs) + } + return groups } // recheckCandidate carries the signer nonce alongside the tx: telling a nonce @@ -308,18 +322,21 @@ type recheckGroup struct { // cycle, so the unreached candidates' senders are re-merged into staging here — // otherwise a sender that isn't touched again by a later block would never be // rechecked until TTL. -func (s *recheckScheduler) runRecheck(candidates []sdk.Tx, gen uint64) { +func (s *recheckScheduler) runRecheck(groups []recheckGroup, gen uint64) { var evicted, cascaded, superseded float32 - groups := s.groupCandidates(candidates) for i, g := range groups { if len(g.txs) == 0 { continue } - e, c, aborted := s.recheckGroup(g, gen) + e, c, aborted, unreachedFrom := s.recheckGroup(g, gen) evicted += e cascaded += c if aborted { - unreached := unreachedTxs(groups[i:]) + unreached := make([]sdk.Tx, 0, len(g.txs)-unreachedFrom) + for _, cand := range g.txs[unreachedFrom:] { + unreached = append(unreached, cand.tx) + } + unreached = append(unreached, unreachedTxs(groups[i+1:])...) superseded = float32(len(unreached)) s.recoverSenders(unreached) break @@ -344,8 +361,8 @@ func (s *recheckScheduler) runRecheck(candidates []sdk.Tx, gen uint64) { // order would fail wrong-sequence against a nonce that a later candidate in // the same group would have satisfied. func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { - groups := make([]recheckGroup, 0, len(candidates)) - index := make(map[string]int, len(candidates)) + var groups []recheckGroup + index := make(map[string]int) // Every signer named by a multi-signer tx: that tx is grouped under its first // signer only, so it can advance a co-signer's nonce from outside that // co-signer's group, making a gap there unprovable. @@ -394,41 +411,71 @@ func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { return groups } -// recheckGroup re-validates one signer's candidates under a single hold of the -// admission mutex. Reports aborted when gen advanced before the group started, -// leaving the group untouched. On a nonce gap the remaining higher-nonce siblings -// are evicted without spending a RunTx on each: nothing can fill the gap while +// recheckChunkSize bounds how many candidates one signer group runs under a +// single hold of exec.mu. Without this, a group's size is bounded only by one +// sender's pool depth, and App.Commit — which blocks on the same mutex — would +// stall behind an arbitrarily deep queue. +const recheckChunkSize = 256 + +// recheckGroup re-validates one signer's candidates in bounded chunks, so a +// deep queue for one sender can't hold the admission mutex indefinitely. +// Nonce contiguity holds within and across chunks (lastOK/haveOK carry over); +// an admission of the same sender landing between chunks is the same residual +// interleaving the design doc already accepts between groups. Reports aborted +// with unreachedFrom set to the index where the aborting chunk would have +// started, leaving the group untouched from there on. On a nonce gap the +// remaining higher-nonce siblings — including any in later chunks — are +// evicted without spending a RunTx on each: nothing can fill the gap while // they sit in the pool. Any other failure evicts only the failing tx, since a // later sibling may still be the account's next expected nonce. -func (s *recheckScheduler) recheckGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, aborted bool) { +func (s *recheckScheduler) recheckGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, aborted bool, unreachedFrom int) { + var lastOK uint64 + haveOK := false + for start := 0; start < len(g.txs); start += recheckChunkSize { + end := min(start+recheckChunkSize, len(g.txs)) + e, gapAt, ok := s.recheckChunkLocked(g, start, end, gen, &lastOK, &haveOK) + evicted += e + if !ok { + return evicted, cascaded, true, start + } + if gapAt >= 0 { + for _, rest := range g.txs[gapAt+1:] { + s.evict(rest.tx) + cascaded++ + } + return evicted, cascaded, false, len(g.txs) + } + } + return evicted, cascaded, false, len(g.txs) +} + +// recheckChunkLocked runs g.txs[start:end] under one hold of exec.mu. Returns +// ok=false if gen advanced before the chunk started, meaning nothing in +// [start, len(g.txs)) ran. gapAt is the index of a proven nonce gap, or -1. +func (s *recheckScheduler) recheckChunkLocked(g recheckGroup, start, end int, gen uint64, lastOK *uint64, haveOK *bool) (evicted float32, gapAt int, ok bool) { s.exec.mu.Lock() defer s.exec.mu.Unlock() - // gen only advances under the same mutex, so it cannot change once this group starts. + // gen only advances under the same mutex, so it cannot change once this chunk starts. if s.exec.gen.Load() != gen { - return 0, 0, true + return 0, -1, false } - var lastOK uint64 - haveOK := false - for i, c := range g.txs { + for i := start; i < end; i++ { + c := g.txs[i] _, _, _, err := s.exec.runTxLocked(sdk.ExecModeReCheck, c.bz, c.tx) if err == nil { - lastOK, haveOK = c.seq, true + *lastOK, *haveOK = c.seq, true continue } s.evict(c.tx) evicted++ // A gap is only provable relative to a nonce this pass just accepted; // without one the failure may be a stale nonce, whose successor is valid. - if g.cascadable && haveOK && c.seq > lastOK+1 && isNonceErr(err) { - for _, rest := range g.txs[i+1:] { - s.evict(rest.tx) - cascaded++ - } - return evicted, cascaded, false + if g.cascadable && *haveOK && c.seq > *lastOK+1 && isNonceErr(err) { + return evicted, i, true } } - return evicted, cascaded, false + return evicted, -1, true } // isNonceErr matches both ante paths: cosmos sig verification reports @@ -448,8 +495,13 @@ func unreachedTxs(groups []recheckGroup) []sdk.Tx { } // recoverSenders folds txs' senders back into staged recheckSenders without -// touching deferred, which capRecheckTxs may have already set this cycle. +// touching deferred, which capRecheckGroups may have already set this cycle. A +// candidate whose signer can't be extracted is silently dropped here, so it +// waits for TTL eviction instead of being re-covered. func (s *recheckScheduler) recoverSenders(txs []sdk.Tx) { + if len(txs) == 0 { + return + } senders := make(map[string]struct{}) for _, tx := range txs { for _, sg := range s.signers(tx) { From f2780cd446fc4ed8dc74c00449330d2a87b94e84 Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Wed, 29 Jul 2026 22:24:36 -0400 Subject: [PATCH 07/12] fix(mempool): re-stage deferred senders and keep cascade eviction locked 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. --- app/app.go | 10 +- app/mempool/exec.go | 4 +- app/mempool/recheck_test.go | 125 +++++-- app/mempool/scheduler.go | 128 ++++--- app/mempool/scheduler.go.bak2 | 632 ++++++++++++++++++++++++++++++++++ 5 files changed, 834 insertions(+), 65 deletions(-) create mode 100644 app/mempool/scheduler.go.bak2 diff --git a/app/app.go b/app/app.go index e06d10f055..ab873681ba 100644 --- a/app/app.go +++ b/app/app.go @@ -1699,10 +1699,12 @@ func (app *App) Commit() (*abci.ResponseCommit, error) { if err == nil { app.mempoolManager.RefreshMempoolStateLocked() } - // On error, base is left pointing at the superseded store. Same for the - // store reload in ApplySnapshotChunk, which doesn't refresh base at - // all: a Commit error is effectively fatal and a state-syncing node - // isn't admitting or proposing, so nothing reads base until the next + // On error, base is left pointing at the superseded store. Same for + // ApplySnapshotChunk: RestoreChunk streams straight into the live + // CommitMultiStore (snapshots.Manager.doRestoreSnapshot -> + // multistore.Restore) without ever calling RefreshMempoolStateLocked. A + // Commit error is effectively fatal and a state-syncing node isn't + // admitting or proposing, so nothing reads base until the next // successful Commit refreshes it. return resp, err }() diff --git a/app/mempool/exec.go b/app/mempool/exec.go index ffb2c90249..a36e5378cc 100644 --- a/app/mempool/exec.go +++ b/app/mempool/exec.go @@ -25,7 +25,9 @@ type txExec struct { // BaseApp.Commit() plus the post-Commit refresh so the swap never races a // RunTx reader or the live memiavl tree mid-Commit. AppMempool.Lock() is a // no-op, so mu also replaces the mempool lock BaseApp normally relies on. - // Held only around RunTx, never the lock-free pool scan. + // Held around RunTx and the cascade eviction that follows a proven nonce + // gap in the same chunk, so eviction stays atomic with admission; never + // held across the lock-free pool scan. mu sync.Mutex runner txRunner // state holds the CacheMultiStore branch RunTx uses in place of checkState. diff --git a/app/mempool/recheck_test.go b/app/mempool/recheck_test.go index d63a9a3937..5e262c8ae2 100644 --- a/app/mempool/recheck_test.go +++ b/app/mempool/recheck_test.go @@ -74,8 +74,10 @@ func (r *recheckRunner) RunTx(mode sdk.ExecMode, txBytes []byte, tx sdk.Tx, _ in sender, seq, trackNonce = sigs[0].Signer.String(), sigs[0].Sequence, true } } - if trackNonce && seq > r.expectedNonce[sender] { - return sdk.GasInfo{}, nil, nil, errorsmod.Wrap(sdkerrors.ErrWrongSequence, "nonce gap") + // The real ante rejects any mismatch, not just a gap: a stale nonce + // (seq < expected) is just as invalid as a gap (seq > expected). + if trackNonce && seq != r.expectedNonce[sender] { + return sdk.GasInfo{}, nil, nil, errorsmod.Wrap(sdkerrors.ErrWrongSequence, "nonce mismatch") } if err, ok := r.failErrs[string(txBytes)]; ok { @@ -1056,11 +1058,12 @@ func TestRecheckTxs_NilEncCacheEvictionNoPanic(t *testing.T) { const aliceSeq0Bytes = "alice-0" -// A generation bump cannot split one signer's group: gen only advances under -// the admission mutex, which recheckGroup holds for the whole group. The bump here is raised -// from inside RunTx (i.e. without the admission mutex) to show the group still completes, -// and that cancellation is a between-groups decision. -func TestRecheckTxs_GenerationBumpDoesNotSplitASignersGroup(t *testing.T) { +// A generation bump cannot split one chunk: gen only advances under the +// admission mutex, which recheckChunkLocked holds for the whole chunk. Both +// candidates here fit in a single chunk, so the bump — raised from inside +// RunTx, i.e. without the admission mutex — shows the chunk still completes; +// cancellation is a between-chunks decision. +func TestRecheckTxs_GenerationBumpDoesNotSplitAChunk(t *testing.T) { f := newRecheckFixture() f.add(1, "alice", 0, aliceSeq0Bytes) f.add(2, "alice", 1, "alice-1") @@ -1081,13 +1084,13 @@ func TestRecheckTxs_GenerationBumpDoesNotSplitASignersGroup(t *testing.T) { // TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred covers // the two-sender abort case explicitly: the unreached sender must land in // staging (not just its raw tx, which runRecheck never touches), and an -// already-set deferred carry from this same cycle's capRecheckTxs must survive +// already-set deferred carry from this same cycle's capRecheckGroups must survive // untouched. func TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred(t *testing.T) { f := newRecheckFixture() aliceTx := f.add(1, "alice", 0, aliceSeq0Bytes) bobTx := f.add(2, "bob", 0, "bob-0") - carryTx := f.add(3, "carol", 0, "carol-carry") // stands in for capRecheckTxs' overflow carry + carryTx := f.add(3, "carol", 0, "carol-carry") // stands in for capRecheckGroups' overflow carry f.a.sched.deferred = []sdk.Tx{carryTx} @@ -1311,9 +1314,9 @@ func TestGroupCandidates_DuplicateSeqDisablesCascadeStableOrder(t *testing.T) { } // A group larger than recheckChunkSize must still run every candidate: the -// chunking in recheckGroup bounds one mutex hold, not how much of the group +// chunking in runGroup bounds one mutex hold, not how much of the group // eventually gets rechecked. -func TestRecheckGroup_LargerThanChunkRunsEveryCandidate(t *testing.T) { +func TestRunGroup_LargerThanChunkRunsEveryCandidate(t *testing.T) { const total = recheckChunkSize + 50 f := newRecheckFixture() txs := make([]sdk.Tx, total) @@ -1326,11 +1329,8 @@ func TestRecheckGroup_LargerThanChunkRunsEveryCandidate(t *testing.T) { t.Fatalf("expected 1 group, got %d", len(groups)) } - evicted, cascaded, aborted, unreachedFrom := f.a.sched.recheckGroup(groups[0], f.a.exec.gen.Load()) - if aborted { - t.Fatal("group must not abort: gen never changed") - } - if unreachedFrom != total { + evicted, cascaded, unreachedFrom := f.a.sched.runGroup(groups[0], f.a.exec.gen.Load()) + if unreachedFrom != -1 { t.Fatalf("expected the whole group reached, got unreachedFrom=%d", unreachedFrom) } if evicted != 0 || cascaded != 0 { @@ -1411,11 +1411,8 @@ func TestRecheckGroup_CascadeEvictsAcrossChunkBoundary(t *testing.T) { t.Fatalf("expected 1 cascadable group, got %+v", groups) } - evicted, cascaded, aborted, unreachedFrom := f.a.sched.recheckGroup(groups[0], f.a.exec.gen.Load()) - if aborted { - t.Fatal("group must not abort: gen never changed") - } - if unreachedFrom != total { + evicted, cascaded, unreachedFrom := f.a.sched.runGroup(groups[0], f.a.exec.gen.Load()) + if unreachedFrom != -1 { t.Fatalf("expected the whole group reached (run or cascade-evicted), got unreachedFrom=%d", unreachedFrom) } if evicted != 1 { @@ -1509,3 +1506,89 @@ func TestSigners_NilSignerNoPanic(t *testing.T) { t.Fatalf("expected nil signers with a nil extractor, got %v", got) } } + +// F1 regression: the deferred carry from capRecheckGroups is tx-identity-keyed +// (deferredLive), so it alone cannot survive a fee bump replacing the head of +// a deferred group at the same (sender, nonce) key. capRecheckGroups must +// also merge the deferred groups' senders into recheckSenders, so the next +// cycle's selectTxs re-picks alice's whole live queue by sender instead of +// relying on the stale deferred pointer. Without that, the surviving tail +// (seq 6-9) would be regrouped alone, fail wrong-sequence against a base +// still expecting nonce 5, and be evicted in full. +func TestRecheckTxs_DeferredCarryWithReplacedHeadDoesNotEvictTail(t *testing.T) { + const batch = 3 + f := newRecheckFixture() + f.runner.signer = f.signer + f.runner.expectedNonce = map[string]uint64{sdk.AccAddress("alice").String(): 5} + + bob := f.add(1, "bob", 0, "bob-0") + aliceSeqs := []uint64{5, 6, 7, 8, 9} + alice := make([]*ptrTx, len(aliceSeqs)) + for i, seq := range aliceSeqs { + alice[i] = f.add(10+i, "alice", seq, "alice-"+strconv.FormatUint(seq, 10)) + } + + f.a.sched.maxRecheckBatch = batch + f.a.sched.recheckSenders = map[string]struct{}{ + sdk.AccAddress("bob").String(): {}, + sdk.AccAddress("alice").String(): {}, + } + + // Cycle 1: bob's group (1 tx) fits under the cap; alice's group (5 txs) + // overflows and must defer whole. + f.a.sched.RecheckTxs() + if !poolHas(f.pool, bob) { + t.Fatal("precondition: bob's tx must survive cycle 1") + } + if len(f.a.sched.deferred) != len(aliceSeqs) { + t.Fatalf("precondition: alice's whole group must defer, got %d", len(f.a.sched.deferred)) + } + + // alice fee-bumps her head tx: same (sender, nonce) key, new tx identity. + // PriorityNonceMempool.Insert replaces the deferred pointer's pool entry. + bumped := f.add(99, "alice", aliceSeqs[0], "alice-5-bumped") + if poolHas(f.pool, alice[0]) { + t.Fatal("precondition: fee bump must replace the original nonce-5 entry") + } + + // No block touches alice between cycles; her real nonce stays 5. A Commit + // still lands (gen advances) but doesn't change the fake runner's nonce + // view, mirroring "alice's chain was only rechecked, never included". + f.a.exec.gen.Add(1) + + // Cycle 2: recheckSenders is drained empty going in; only capRecheckGroups' + // re-staging from cycle 1 covers alice here. + f.a.sched.RecheckTxs() + + if !poolHas(f.pool, bumped) { + t.Fatal("the fee-bumped replacement at nonce 5 must survive recheck") + } + for i, seq := range aliceSeqs[1:] { + if !poolHas(f.pool, alice[i+1]) { + t.Fatalf("alice's tail tx at seq %d must not be evicted after a head fee bump", seq) + } + } +} + +// F2 (documented residual, not fixed in code): PriorityNonceMempool.Remove +// resolves by (sender, nonce) key, not tx identity, so evicting a stale +// recheck candidate drops whatever currently occupies that key. If an +// admission lands between the snapshot and the eviction and replaces the slot +// (e.g. a fee bump), the freshly admitted replacement is what gets dropped, +// not the stale tx the pass was actually rechecking. +func TestEvict_KeyBasedRemovalDropsReplacementNotStaleTx(t *testing.T) { + f := newRecheckFixture() + stale := f.add(1, "alice", 0, "alice-0") + + // A fee bump lands at the same (sender, nonce) key before eviction runs. + replacement := f.insert(2, sdk.AccAddress("alice"), 0) + if poolHas(f.pool, stale) { + t.Fatal("precondition: fee bump must replace the original nonce-0 entry") + } + + f.a.sched.evict(stale) + + if poolHas(f.pool, replacement) { + t.Fatal("key-based Remove must drop whatever occupies (alice, 0) now, i.e. the replacement") + } +} diff --git a/app/mempool/scheduler.go b/app/mempool/scheduler.go index d2522bdf9e..e1ef8b29bd 100644 --- a/app/mempool/scheduler.go +++ b/app/mempool/scheduler.go @@ -32,8 +32,12 @@ type recheckScheduler struct { // recheckSenders accumulates senders of committed blocks awaiting recheck; merged // (not overwritten) across blocks so an un-drained block's senders aren't lost. recheckSenders map[string]struct{} - // deferred carries candidates past maxRecheckBatch to the next cycle, so a - // deep per-sender queue eventually drains instead of being silently dropped. + // deferred is an ordering hint only: it front-loads capRecheckGroups' + // overflow ahead of fresh candidates next cycle. The actual guarantee that + // those senders get re-picked comes from capRecheckGroups also merging + // their senders into recheckSenders — deferred alone would miss a carried + // tx whose pool identity changed, e.g. a fee bump replacing it at the same + // nonce. deferred []sdk.Tx lastCommittedHeight int64 // arrival maps each pooled tx to the height RecheckTxs first observed it, for @@ -239,7 +243,7 @@ func (s *recheckScheduler) selectTxs(snapshot []sdk.Tx, recheckSenders map[strin return candidates } // Front-load surviving deferred ahead of fresh candidates: the snapshot is - // priority-ordered, so otherwise capRecheckTxs re-takes the same prefix and starves the tail. + // priority-ordered, so otherwise capRecheckGroups re-takes the same prefix and starves the tail. ordered := make([]sdk.Tx, 0, len(deferred)+len(candidates)) for _, tx := range deferred { if deferredLive[tx] { @@ -276,7 +280,12 @@ func (s *recheckScheduler) evictForRecheck(tx sdk.Tx, evictedSet map[sdk.Tx]stru // capRecheckGroups bounds RunTx(ReCheck) calls per cycle without ever // splitting a signer's group: the first group always runs in full regardless // of size, and once the running total would exceed maxRecheckBatch the -// remaining groups carry forward whole into deferred. +// remaining groups carry forward whole into deferred. Their senders are also +// merged into recheckSenders (the recoverSenders path), because deferred is +// keyed on tx identity: a fee bump replacing a carried tx at the same nonce +// would otherwise vanish from deferredLive next cycle and take its whole live +// tail down as a false wrong-sequence failure. Re-selecting by sender instead +// picks up whatever the pool holds for that (sender, nonce) key now. func (s *recheckScheduler) capRecheckGroups(groups []recheckGroup) []recheckGroup { if s.maxRecheckBatch <= 0 { return groups @@ -284,9 +293,11 @@ func (s *recheckScheduler) capRecheckGroups(groups []recheckGroup) []recheckGrou count := 0 for i, g := range groups { if count > 0 && count+len(g.txs) > s.maxRecheckBatch { + carry := unreachedTxs(groups[i:]) s.stagingMu.Lock() - s.deferred = unreachedTxs(groups[i:]) + s.deferred = carry s.stagingMu.Unlock() + s.recoverSenders(carry) return groups[:i] } count += len(g.txs) @@ -328,16 +339,16 @@ func (s *recheckScheduler) runRecheck(groups []recheckGroup, gen uint64) { if len(g.txs) == 0 { continue } - e, c, aborted, unreachedFrom := s.recheckGroup(g, gen) + e, c, unreachedFrom := s.runGroup(g, gen) evicted += e cascaded += c - if aborted { + if unreachedFrom != -1 { unreached := make([]sdk.Tx, 0, len(g.txs)-unreachedFrom) for _, cand := range g.txs[unreachedFrom:] { unreached = append(unreached, cand.tx) } unreached = append(unreached, unreachedTxs(groups[i+1:])...) - superseded = float32(len(unreached)) + superseded += float32(len(unreached)) s.recoverSenders(unreached) break } @@ -417,65 +428,104 @@ func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { // stall behind an arbitrarily deep queue. const recheckChunkSize = 256 -// recheckGroup re-validates one signer's candidates in bounded chunks, so a -// deep queue for one sender can't hold the admission mutex indefinitely. -// Nonce contiguity holds within and across chunks (lastOK/haveOK carry over); -// an admission of the same sender landing between chunks is the same residual -// interleaving the design doc already accepts between groups. Reports aborted -// with unreachedFrom set to the index where the aborting chunk would have -// started, leaving the group untouched from there on. On a nonce gap the -// remaining higher-nonce siblings — including any in later chunks — are -// evicted without spending a RunTx on each: nothing can fill the gap while -// they sit in the pool. Any other failure evicts only the failing tx, since a -// later sibling may still be the account's next expected nonce. -func (s *recheckScheduler) recheckGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, aborted bool, unreachedFrom int) { - var lastOK uint64 - haveOK := false +// nonceCursor is the account's next-expected-nonce view, carried across a +// group's chunks so cascade detection in a later chunk can still reason about +// a candidate accepted in an earlier one. +type nonceCursor struct { + last uint64 + ok bool +} + +// runGroup re-validates one signer's candidates in bounded chunks, so a deep +// queue for one sender can't hold the admission mutex indefinitely. Nonce +// contiguity holds within and across chunks via the returned cursor; an +// admission of the same sender landing between chunks is the same residual +// interleaving the design doc already accepts between groups. unreachedFrom +// is -1 once every candidate has either run or been cascade-evicted; otherwise +// it is the index where the aborting chunk would have started, leaving the +// group untouched from there on. On a nonce gap the remaining higher-nonce +// siblings — including any in later chunks — are evicted without spending a +// RunTx on each: nothing can fill the gap while they sit in the pool. Any +// other failure evicts only the failing tx, since a later sibling may still be +// the account's next expected nonce. +func (s *recheckScheduler) runGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, unreachedFrom int) { + cursor := nonceCursor{} + gapFound := false for start := 0; start < len(g.txs); start += recheckChunkSize { end := min(start+recheckChunkSize, len(g.txs)) - e, gapAt, ok := s.recheckChunkLocked(g, start, end, gen, &lastOK, &haveOK) + if gapFound { + // A gap was already proven in an earlier chunk: every candidate from + // here on is unreachable, so just cascade-evict this chunk under the + // same bounded lock hold rather than spend a RunTx on any of them. + c, ok := s.cascadeChunkLocked(g, start, end, gen) + cascaded += c + if !ok { + return evicted, cascaded, start + } + continue + } + e, c, next, gap, ok := s.recheckChunkLocked(g, start, end, gen, cursor) evicted += e + cascaded += c if !ok { - return evicted, cascaded, true, start - } - if gapAt >= 0 { - for _, rest := range g.txs[gapAt+1:] { - s.evict(rest.tx) - cascaded++ - } - return evicted, cascaded, false, len(g.txs) + return evicted, cascaded, start } + cursor = next + gapFound = gap } - return evicted, cascaded, false, len(g.txs) + return evicted, cascaded, -1 } // recheckChunkLocked runs g.txs[start:end] under one hold of exec.mu. Returns // ok=false if gen advanced before the chunk started, meaning nothing in -// [start, len(g.txs)) ran. gapAt is the index of a proven nonce gap, or -1. -func (s *recheckScheduler) recheckChunkLocked(g recheckGroup, start, end int, gen uint64, lastOK *uint64, haveOK *bool) (evicted float32, gapAt int, ok bool) { +// [start, len(g.txs)) ran. gapFound reports a proven nonce gap discovered in +// this chunk: the cascade for the rest of this chunk already ran here, under +// the same lock as the admissions it must stay atomic with respect to. +func (s *recheckScheduler) recheckChunkLocked(g recheckGroup, start, end int, gen uint64, cursor nonceCursor) (evicted, cascaded float32, next nonceCursor, gapFound, ok bool) { s.exec.mu.Lock() defer s.exec.mu.Unlock() // gen only advances under the same mutex, so it cannot change once this chunk starts. if s.exec.gen.Load() != gen { - return 0, -1, false + return 0, 0, cursor, false, false } for i := start; i < end; i++ { c := g.txs[i] _, _, _, err := s.exec.runTxLocked(sdk.ExecModeReCheck, c.bz, c.tx) if err == nil { - *lastOK, *haveOK = c.seq, true + cursor = nonceCursor{last: c.seq, ok: true} continue } s.evict(c.tx) evicted++ // A gap is only provable relative to a nonce this pass just accepted; // without one the failure may be a stale nonce, whose successor is valid. - if g.cascadable && *haveOK && c.seq > *lastOK+1 && isNonceErr(err) { - return evicted, i, true + if g.cascadable && cursor.ok && c.seq > cursor.last+1 && isNonceErr(err) { + for _, rest := range g.txs[i+1 : end] { + s.evict(rest.tx) + cascaded++ + } + return evicted, cascaded, cursor, true, true } } - return evicted, -1, true + return evicted, cascaded, cursor, false, true +} + +// cascadeChunkLocked evicts g.txs[start:end] under one hold of exec.mu, for a +// chunk that starts after a gap was already proven in an earlier chunk. +// Chunked the same as recheckChunkLocked so a long cascaded tail can't hold +// the admission mutex in one unbounded stretch. +func (s *recheckScheduler) cascadeChunkLocked(g recheckGroup, start, end int, gen uint64) (cascaded float32, ok bool) { + s.exec.mu.Lock() + defer s.exec.mu.Unlock() + if s.exec.gen.Load() != gen { + return 0, false + } + for _, c := range g.txs[start:end] { + s.evict(c.tx) + cascaded++ + } + return cascaded, true } // isNonceErr matches both ante paths: cosmos sig verification reports diff --git a/app/mempool/scheduler.go.bak2 b/app/mempool/scheduler.go.bak2 new file mode 100644 index 0000000000..e1ef8b29bd --- /dev/null +++ b/app/mempool/scheduler.go.bak2 @@ -0,0 +1,632 @@ +package mempool + +import ( + "cmp" + "context" + "slices" + "sync" + "time" + + errorsmod "cosmossdk.io/errors" + + "github.com/cosmos/cosmos-sdk/telemetry" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" +) + +// recheckScheduler is the recheck half of the app mempool: it stages the senders +// each block touched, picks which pending txs to re-validate, and evicts the ones +// the new state invalidated. +type recheckScheduler struct { + exec *txExec + mpool sdkmempool.Mempool + signer sdkmempool.SignerExtractionAdapter + // maxRecheckBatch softly caps RunTx(ReCheck) calls per Commit cycle: it splits + // only at group boundaries, so a signer's whole nonce chain always runs + // together even if that group alone exceeds the cap. 0 = unlimited. + maxRecheckBatch int + // stagingMu guards the staging fields (recheckSenders, deferred, lastCommittedHeight). + // Separate from the admission mutex so FinalizeBlock staging never blocks behind a recheck batch. + stagingMu sync.Mutex + // recheckSenders accumulates senders of committed blocks awaiting recheck; merged + // (not overwritten) across blocks so an un-drained block's senders aren't lost. + recheckSenders map[string]struct{} + // deferred is an ordering hint only: it front-loads capRecheckGroups' + // overflow ahead of fresh candidates next cycle. The actual guarantee that + // those senders get re-picked comes from capRecheckGroups also merging + // their senders into recheckSenders — deferred alone would miss a carried + // tx whose pool identity changed, e.g. a fee bump replacing it at the same + // nonce. + deferred []sdk.Tx + lastCommittedHeight int64 + // arrival maps each pooled tx to the height RecheckTxs first observed it, for + // ttlNumBlocks eviction. Rebuilt from the snapshot each cycle; recheckMu keeps it single-writer. + arrival map[sdk.Tx]int64 + // ttlNumBlocks evicts txs older than this many blocks by arrival height; 0 = off. + ttlNumBlocks int64 + + recheckMu sync.Mutex // serializes RecheckTxs; always acquired before the admission mutex and stagingMu, never after + // Zero-value (trigger nil) when built via the newManager() test constructor; + // TriggerRecheck then runs RecheckTxs inline instead of async. + worker recheckWorker + // recheckDisabled mirrors mempool.recheck=false: skips all rechecking, + // including TTL/expiry eviction + recheckDisabled bool +} + +// recheckDecodingEnabled reports whether sender decoding/bookkeeping should run. +func (s *recheckScheduler) recheckDecodingEnabled() bool { + return !s.recheckDisabled && s.signer != nil && s.exec.decoder != nil +} + +// stageSkippedSenders merges the senders of proposal-gate-rejected txs into +// recheckSenders without touching lastCommittedHeight +func (s *recheckScheduler) stageSkippedSenders(txs [][]byte) { + if !s.recheckDecodingEnabled() || len(txs) == 0 { + return + } + senders := make(map[string]struct{}, len(txs)) + for _, bz := range txs { + tx, err := s.exec.decoder(bz) + if err != nil { + continue + } + for _, sg := range s.signers(tx) { + senders[sg] = struct{}{} + } + } + if len(senders) == 0 { + return + } + s.stagingMu.Lock() + s.mergeRecheckSenders(senders) + s.stagingMu.Unlock() +} + +func (s *recheckScheduler) mergeRecheckSenders(senders map[string]struct{}) { + // mergeRecheckSenders folds senders into recheckSenders without overwriting, so a + // block whose Commit skipped RecheckTxs doesn't lose its staged senders. + if s.recheckSenders == nil { + s.recheckSenders = senders + } else { + for sg := range senders { + s.recheckSenders[sg] = struct{}{} + } + } +} + +// stageRecheckSenders records the senders of the just-committed block's txs so +// RecheckTxs can re-validate only their remaining pending txs, and stages the +// committed height. +func (s *recheckScheduler) stageRecheckSenders(height int64, txs [][]byte) { + // Decode + extract signers unlocked (the expensive part), then publish height + // and recheckSenders in one critical section so a reader never sees a torn update. + var senders map[string]struct{} + if s.recheckDecodingEnabled() { + senders = make(map[string]struct{}, len(txs)) + for _, bz := range txs { + tx, err := s.exec.decoder(bz) + if err != nil { + continue // non-sdk txs (e.g. vote extensions) have no mempool entry + } + for _, sg := range s.signers(tx) { + senders[sg] = struct{}{} + } + } + } + + s.stagingMu.Lock() + s.lastCommittedHeight = height + s.mergeRecheckSenders(senders) + s.stagingMu.Unlock() +} + +// triggerRecheck schedules an async recheck. +// Call only from the consensus path (App.Commit). +func (s *recheckScheduler) triggerRecheck() { + if s.worker.trigger == nil { + s.RecheckTxs() + return + } + s.worker.recheck() +} + +// RecheckTxs evicts pool txs invalidated by the last block. +func (s *recheckScheduler) RecheckTxs() { + if s.mpool == nil || s.recheckDisabled { + return + } + s.recheckMu.Lock() // lock order: see the recheckMu field comment + defer s.recheckMu.Unlock() + recheckSenders, height, deferred := s.drainStaging() + // Before the first block (height 0) with no senders/carry there's nothing to scan. + if len(recheckSenders) == 0 && len(deferred) == 0 && height == 0 { + return + } + + snapshot := PoolSnapshot(context.Background(), s.mpool) + candidates := s.selectTxs(snapshot, recheckSenders, height, deferred) + groups := s.capRecheckGroups(s.groupCandidates(candidates)) + // Read gen only now: it must cover the RunTx phase below, not the O(pool) + // scan/grouping above, or a Commit landing during the scan would abort the + // whole pass before a single group runs. + gen := s.exec.gen.Load() + s.runRecheck(groups, gen) + + telemetry.SetGauge(float32(s.mpool.CountTx()), "cronos", "mempool", "pool", "size") +} + +// drainStaging atomically takes and clears the staged senders, height, and carry. +func (s *recheckScheduler) drainStaging() (recheckSenders map[string]struct{}, height int64, deferred []sdk.Tx) { + s.stagingMu.Lock() + defer s.stagingMu.Unlock() + recheckSenders, height, deferred = s.recheckSenders, s.lastCommittedHeight, s.deferred + s.recheckSenders = nil + s.deferred = nil + return recheckSenders, height, deferred +} + +// selectTxs scans the pool to retrieve txs for recheck. Caller (RecheckTxs) +// only invokes this when recheck is enabled. +func (s *recheckScheduler) selectTxs(snapshot []sdk.Tx, recheckSenders map[string]struct{}, height int64, deferred []sdk.Tx) []sdk.Tx { + // deferredLive: carried-over tx -> still in pool. Sized to the small carry; nil if none. + var deferredLive map[sdk.Tx]bool + if len(deferred) > 0 { + deferredLive = make(map[sdk.Tx]bool, len(deferred)) + for _, tx := range deferred { + deferredLive[tx] = false + } + } + + var ( + expiredEvicted float32 + ttlEvicted float32 + ) + // Rebuild arrival from this cycle's snapshot so txs gone from the pool fall out. + var newArrival map[sdk.Tx]int64 + if s.ttlNumBlocks > 0 { + newArrival = make(map[sdk.Tx]int64, len(snapshot)) + } + + // 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() + for _, tx := range snapshot { + if txTimedout(tx, height, now) { + evictedSet, recheckSenders = s.evictForRecheck(tx, evictedSet, recheckSenders) + expiredEvicted++ + continue + } + if s.ttlNumBlocks > 0 { + arrived, expired := txTTLExpired(s.arrival, tx, height, s.ttlNumBlocks) + if expired { + evictedSet, recheckSenders = s.evictForRecheck(tx, evictedSet, recheckSenders) + ttlEvicted++ + continue + } + newArrival[tx] = arrived + } + } + s.arrival = newArrival + if expiredEvicted > 0 { + telemetry.IncrCounter(expiredEvicted, "cronos", "mempool", "recheck", "expired") + } + if ttlEvicted > 0 { + telemetry.IncrCounter(ttlEvicted, "cronos", "mempool", "recheck", "ttl_expired") + } + + // Pass 2: candidate selection over surviving (non-evicted) txs. + var candidates []sdk.Tx + for _, tx := range snapshot { + if _, wasEvicted := evictedSet[tx]; wasEvicted { + continue + } + if deferredLive != nil { + if _, isDeferred := deferredLive[tx]; isDeferred { + deferredLive[tx] = true + } + } + if len(recheckSenders) == 0 { + continue + } + for _, sg := range s.signers(tx) { + if _, ok := recheckSenders[sg]; ok { + candidates = append(candidates, tx) + break + } + } + } + + if len(deferred) == 0 { + return candidates + } + // Front-load surviving deferred ahead of fresh candidates: the snapshot is + // priority-ordered, so otherwise capRecheckGroups re-takes the same prefix and starves the tail. + ordered := make([]sdk.Tx, 0, len(deferred)+len(candidates)) + for _, tx := range deferred { + if deferredLive[tx] { + ordered = append(ordered, tx) // skip txs included/evicted since carry + } + } + for _, tx := range candidates { + if _, isDeferred := deferredLive[tx]; isDeferred { + continue // this tx is already in the deferred carry; avoid double recheck + } + ordered = append(ordered, tx) + } + return ordered +} + +// evictForRecheck evicts tx and folds its signers into recheckSenders, allocating +// evictedSet/recheckSenders lazily so a no-eviction cycle stays alloc-free. +func (s *recheckScheduler) evictForRecheck(tx sdk.Tx, evictedSet map[sdk.Tx]struct{}, recheckSenders map[string]struct{}) (map[sdk.Tx]struct{}, map[string]struct{}) { + s.evict(tx) + if evictedSet == nil { + evictedSet = make(map[sdk.Tx]struct{}) + } + evictedSet[tx] = struct{}{} + sigs := s.signers(tx) + if len(sigs) > 0 && recheckSenders == nil { + recheckSenders = make(map[string]struct{}) + } + for _, sg := range sigs { + recheckSenders[sg] = struct{}{} + } + return evictedSet, recheckSenders +} + +// capRecheckGroups bounds RunTx(ReCheck) calls per cycle without ever +// splitting a signer's group: the first group always runs in full regardless +// of size, and once the running total would exceed maxRecheckBatch the +// remaining groups carry forward whole into deferred. Their senders are also +// merged into recheckSenders (the recoverSenders path), because deferred is +// keyed on tx identity: a fee bump replacing a carried tx at the same nonce +// would otherwise vanish from deferredLive next cycle and take its whole live +// tail down as a false wrong-sequence failure. Re-selecting by sender instead +// picks up whatever the pool holds for that (sender, nonce) key now. +func (s *recheckScheduler) capRecheckGroups(groups []recheckGroup) []recheckGroup { + if s.maxRecheckBatch <= 0 { + return groups + } + count := 0 + for i, g := range groups { + if count > 0 && count+len(g.txs) > s.maxRecheckBatch { + carry := unreachedTxs(groups[i:]) + s.stagingMu.Lock() + s.deferred = carry + s.stagingMu.Unlock() + s.recoverSenders(carry) + return groups[:i] + } + count += len(g.txs) + } + return groups +} + +// recheckCandidate carries the signer nonce alongside the tx: telling a nonce +// gap from a merely stale nonce is what makes cascade eviction safe. +type recheckCandidate struct { + tx sdk.Tx + bz []byte + seq uint64 +} + +// recheckGroup holds one signer's candidates sorted ascending by seq. +// cascadable is false when the group is not that signer's contiguous +// ascending-nonce view — an unknown signer, a signer named by a multi-signer tx +// (that tx is grouped elsewhere, so it can fill a nonce this group can't see), a +// duplicate seq, or a tx dropped on encode error — because the cascade rule +// reasons about the next expected nonce. +type recheckGroup struct { + key string + txs []recheckCandidate + cascadable bool +} + +// runRecheck re-validates candidates via RunTx(ReCheck), one signer group at a +// time so a sender's nonce chain advances atomically with respect to other +// senders' admissions. The pass is abandoned once gen advances mid-flight: the +// remaining candidates would be validated against a base a concurrent Commit +// has already superseded. drainStaging already cleared recheckSenders for this +// cycle, so the unreached candidates' senders are re-merged into staging here — +// otherwise a sender that isn't touched again by a later block would never be +// rechecked until TTL. +func (s *recheckScheduler) runRecheck(groups []recheckGroup, gen uint64) { + var evicted, cascaded, superseded float32 + for i, g := range groups { + if len(g.txs) == 0 { + continue + } + e, c, unreachedFrom := s.runGroup(g, gen) + evicted += e + cascaded += c + if unreachedFrom != -1 { + unreached := make([]sdk.Tx, 0, len(g.txs)-unreachedFrom) + for _, cand := range g.txs[unreachedFrom:] { + unreached = append(unreached, cand.tx) + } + unreached = append(unreached, unreachedTxs(groups[i+1:])...) + superseded += float32(len(unreached)) + s.recoverSenders(unreached) + break + } + } + if evicted > 0 { + telemetry.IncrCounter(evicted, "cronos", "mempool", "recheck", "evicted") + } + if cascaded > 0 { + telemetry.IncrCounter(cascaded, "cronos", "mempool", "recheck", "cascade_evicted") + } + if superseded > 0 { + telemetry.IncrCounter(superseded, "cronos", "mempool", "recheck", "superseded") + } +} + +// groupCandidates buckets candidates by first signer — the one the mempool +// orders by — keeping first-appearance order across groups. Encoding happens +// here, outside the admission mutex, to keep the per-group hold to RunTx. +// Within a group, candidates are sorted ascending by seq: deferred front- +// loading can hand candidates out of nonce order, and running them out of +// order would fail wrong-sequence against a nonce that a later candidate in +// the same group would have satisfied. +func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { + var groups []recheckGroup + index := make(map[string]int) + // Every signer named by a multi-signer tx: that tx is grouped under its first + // signer only, so it can advance a co-signer's nonce from outside that + // co-signer's group, making a gap there unprovable. + var coSigned map[string]struct{} + for _, tx := range candidates { + key, seq, known, multiSigner := s.firstSigner(tx) + gi, seen := index[key] + if !seen { + groups = append(groups, recheckGroup{key: key, cascadable: known}) + gi = len(groups) - 1 + index[key] = gi + } + g := &groups[gi] + if multiSigner { + if coSigned == nil { + coSigned = make(map[string]struct{}) + } + for _, sg := range s.signers(tx) { + coSigned[sg] = struct{}{} + } + } + bz, _, err := EncodeTx(s.exec.encCache, s.exec.txEncoder, tx) + if err != nil { + g.cascadable = false + continue + } + g.txs = append(g.txs, recheckCandidate{tx: tx, bz: bz, seq: seq}) + } + for i := range groups { + g := &groups[i] + if _, ok := coSigned[g.key]; ok { + g.cascadable = false + } + slices.SortStableFunc(g.txs, func(a, b recheckCandidate) int { + return cmp.Compare(a.seq, b.seq) + }) + // A duplicate seq can only appear as adjacent equal entries once sorted; + // it still means the group isn't a clean ascending-nonce view. + for j := 1; j < len(g.txs); j++ { + if g.txs[j].seq <= g.txs[j-1].seq { + g.cascadable = false + break + } + } + } + return groups +} + +// recheckChunkSize bounds how many candidates one signer group runs under a +// single hold of exec.mu. Without this, a group's size is bounded only by one +// sender's pool depth, and App.Commit — which blocks on the same mutex — would +// stall behind an arbitrarily deep queue. +const recheckChunkSize = 256 + +// nonceCursor is the account's next-expected-nonce view, carried across a +// group's chunks so cascade detection in a later chunk can still reason about +// a candidate accepted in an earlier one. +type nonceCursor struct { + last uint64 + ok bool +} + +// runGroup re-validates one signer's candidates in bounded chunks, so a deep +// queue for one sender can't hold the admission mutex indefinitely. Nonce +// contiguity holds within and across chunks via the returned cursor; an +// admission of the same sender landing between chunks is the same residual +// interleaving the design doc already accepts between groups. unreachedFrom +// is -1 once every candidate has either run or been cascade-evicted; otherwise +// it is the index where the aborting chunk would have started, leaving the +// group untouched from there on. On a nonce gap the remaining higher-nonce +// siblings — including any in later chunks — are evicted without spending a +// RunTx on each: nothing can fill the gap while they sit in the pool. Any +// other failure evicts only the failing tx, since a later sibling may still be +// the account's next expected nonce. +func (s *recheckScheduler) runGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, unreachedFrom int) { + cursor := nonceCursor{} + gapFound := false + for start := 0; start < len(g.txs); start += recheckChunkSize { + end := min(start+recheckChunkSize, len(g.txs)) + if gapFound { + // A gap was already proven in an earlier chunk: every candidate from + // here on is unreachable, so just cascade-evict this chunk under the + // same bounded lock hold rather than spend a RunTx on any of them. + c, ok := s.cascadeChunkLocked(g, start, end, gen) + cascaded += c + if !ok { + return evicted, cascaded, start + } + continue + } + e, c, next, gap, ok := s.recheckChunkLocked(g, start, end, gen, cursor) + evicted += e + cascaded += c + if !ok { + return evicted, cascaded, start + } + cursor = next + gapFound = gap + } + return evicted, cascaded, -1 +} + +// recheckChunkLocked runs g.txs[start:end] under one hold of exec.mu. Returns +// ok=false if gen advanced before the chunk started, meaning nothing in +// [start, len(g.txs)) ran. gapFound reports a proven nonce gap discovered in +// this chunk: the cascade for the rest of this chunk already ran here, under +// the same lock as the admissions it must stay atomic with respect to. +func (s *recheckScheduler) recheckChunkLocked(g recheckGroup, start, end int, gen uint64, cursor nonceCursor) (evicted, cascaded float32, next nonceCursor, gapFound, ok bool) { + s.exec.mu.Lock() + defer s.exec.mu.Unlock() + // gen only advances under the same mutex, so it cannot change once this chunk starts. + if s.exec.gen.Load() != gen { + return 0, 0, cursor, false, false + } + + for i := start; i < end; i++ { + c := g.txs[i] + _, _, _, err := s.exec.runTxLocked(sdk.ExecModeReCheck, c.bz, c.tx) + if err == nil { + cursor = nonceCursor{last: c.seq, ok: true} + continue + } + s.evict(c.tx) + evicted++ + // A gap is only provable relative to a nonce this pass just accepted; + // without one the failure may be a stale nonce, whose successor is valid. + if g.cascadable && cursor.ok && c.seq > cursor.last+1 && isNonceErr(err) { + for _, rest := range g.txs[i+1 : end] { + s.evict(rest.tx) + cascaded++ + } + return evicted, cascaded, cursor, true, true + } + } + return evicted, cascaded, cursor, false, true +} + +// cascadeChunkLocked evicts g.txs[start:end] under one hold of exec.mu, for a +// chunk that starts after a gap was already proven in an earlier chunk. +// Chunked the same as recheckChunkLocked so a long cascaded tail can't hold +// the admission mutex in one unbounded stretch. +func (s *recheckScheduler) cascadeChunkLocked(g recheckGroup, start, end int, gen uint64) (cascaded float32, ok bool) { + s.exec.mu.Lock() + defer s.exec.mu.Unlock() + if s.exec.gen.Load() != gen { + return 0, false + } + for _, c := range g.txs[start:end] { + s.evict(c.tx) + cascaded++ + } + return cascaded, true +} + +// isNonceErr matches both ante paths: cosmos sig verification reports +// ErrWrongSequence, the EVM nonce check reports ErrInvalidSequence. +func isNonceErr(err error) bool { + return errorsmod.IsOf(err, sdkerrors.ErrWrongSequence, sdkerrors.ErrInvalidSequence) +} + +func unreachedTxs(groups []recheckGroup) []sdk.Tx { + var txs []sdk.Tx + for _, g := range groups { + for _, c := range g.txs { + txs = append(txs, c.tx) + } + } + return txs +} + +// recoverSenders folds txs' senders back into staged recheckSenders without +// touching deferred, which capRecheckGroups may have already set this cycle. A +// candidate whose signer can't be extracted is silently dropped here, so it +// waits for TTL eviction instead of being re-covered. +func (s *recheckScheduler) recoverSenders(txs []sdk.Tx) { + if len(txs) == 0 { + return + } + senders := make(map[string]struct{}) + for _, tx := range txs { + for _, sg := range s.signers(tx) { + senders[sg] = struct{}{} + } + } + if len(senders) == 0 { + return + } + s.stagingMu.Lock() + s.mergeRecheckSenders(senders) + s.stagingMu.Unlock() +} + +// txTimedout reports whether tx should be evicted by its own declared timeout: +func txTimedout(tx sdk.Tx, height int64, now time.Time) bool { + if t, ok := tx.(sdk.TxWithTimeoutHeight); ok { + th := t.GetTimeoutHeight() + if th > 0 && uint64(height) >= th { + return true + } + } + if t, ok := tx.(sdk.TxWithTimeoutTimeStamp); ok { + ts := t.GetTimeoutTimeStamp() + if !ts.IsZero() && !now.Before(ts) { + return true + } + } + return false +} + +// txTTLExpired reports whether tx has aged past ttlNumBlocks since first seen. +func txTTLExpired(arrival map[sdk.Tx]int64, tx sdk.Tx, height, ttlNumBlocks int64) (int64, bool) { + arrived, ok := arrival[tx] + if !ok { + arrived = height + } + return arrived, height-arrived >= ttlNumBlocks +} + +// evict removes tx from the pool and encoder cache together, so the cache never +// outlives its pool entry. +func (s *recheckScheduler) evict(tx sdk.Tx) { + _ = s.mpool.Remove(tx) + s.exec.encCache.Evict(tx) +} + +// firstSigner returns the signer the mempool orders by, with its nonce, and +// whether tx has more than one signer. An unknown signer only costs the +// cascade optimization, not the recheck itself. A multi-signer tx must also +// disable the cascade: a secondary signer's nonce isn't visible to the group +// keyed on the first signer, so a gap in that group may really be filled by +// a multi-signer tx grouped elsewhere. +func (s *recheckScheduler) firstSigner(tx sdk.Tx) (key string, seq uint64, known, multiSigner bool) { + if s.signer == nil { + return "", 0, false, false + } + sigs, err := s.signer.GetSigners(tx) + if err != nil || len(sigs) == 0 { + return "", 0, false, false + } + return sigs[0].Signer.String(), sigs[0].Sequence, true, len(sigs) > 1 +} + +func (s *recheckScheduler) signers(tx sdk.Tx) []string { + if s.signer == nil { + return nil + } + sigs, err := s.signer.GetSigners(tx) + if err != nil { + return nil + } + keys := make([]string, len(sigs)) + for i, sg := range sigs { + keys[i] = sg.Signer.String() + } + return keys +} From 51663b1f91983756972c736c00773c5e4c29db64 Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Thu, 30 Jul 2026 11:33:53 -0400 Subject: [PATCH 08/12] chore(mempool): remove stray scheduler.go.bak2 --- app/mempool/scheduler.go.bak2 | 632 ---------------------------------- 1 file changed, 632 deletions(-) delete mode 100644 app/mempool/scheduler.go.bak2 diff --git a/app/mempool/scheduler.go.bak2 b/app/mempool/scheduler.go.bak2 deleted file mode 100644 index e1ef8b29bd..0000000000 --- a/app/mempool/scheduler.go.bak2 +++ /dev/null @@ -1,632 +0,0 @@ -package mempool - -import ( - "cmp" - "context" - "slices" - "sync" - "time" - - errorsmod "cosmossdk.io/errors" - - "github.com/cosmos/cosmos-sdk/telemetry" - sdk "github.com/cosmos/cosmos-sdk/types" - sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" - sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" -) - -// recheckScheduler is the recheck half of the app mempool: it stages the senders -// each block touched, picks which pending txs to re-validate, and evicts the ones -// the new state invalidated. -type recheckScheduler struct { - exec *txExec - mpool sdkmempool.Mempool - signer sdkmempool.SignerExtractionAdapter - // maxRecheckBatch softly caps RunTx(ReCheck) calls per Commit cycle: it splits - // only at group boundaries, so a signer's whole nonce chain always runs - // together even if that group alone exceeds the cap. 0 = unlimited. - maxRecheckBatch int - // stagingMu guards the staging fields (recheckSenders, deferred, lastCommittedHeight). - // Separate from the admission mutex so FinalizeBlock staging never blocks behind a recheck batch. - stagingMu sync.Mutex - // recheckSenders accumulates senders of committed blocks awaiting recheck; merged - // (not overwritten) across blocks so an un-drained block's senders aren't lost. - recheckSenders map[string]struct{} - // deferred is an ordering hint only: it front-loads capRecheckGroups' - // overflow ahead of fresh candidates next cycle. The actual guarantee that - // those senders get re-picked comes from capRecheckGroups also merging - // their senders into recheckSenders — deferred alone would miss a carried - // tx whose pool identity changed, e.g. a fee bump replacing it at the same - // nonce. - deferred []sdk.Tx - lastCommittedHeight int64 - // arrival maps each pooled tx to the height RecheckTxs first observed it, for - // ttlNumBlocks eviction. Rebuilt from the snapshot each cycle; recheckMu keeps it single-writer. - arrival map[sdk.Tx]int64 - // ttlNumBlocks evicts txs older than this many blocks by arrival height; 0 = off. - ttlNumBlocks int64 - - recheckMu sync.Mutex // serializes RecheckTxs; always acquired before the admission mutex and stagingMu, never after - // Zero-value (trigger nil) when built via the newManager() test constructor; - // TriggerRecheck then runs RecheckTxs inline instead of async. - worker recheckWorker - // recheckDisabled mirrors mempool.recheck=false: skips all rechecking, - // including TTL/expiry eviction - recheckDisabled bool -} - -// recheckDecodingEnabled reports whether sender decoding/bookkeeping should run. -func (s *recheckScheduler) recheckDecodingEnabled() bool { - return !s.recheckDisabled && s.signer != nil && s.exec.decoder != nil -} - -// stageSkippedSenders merges the senders of proposal-gate-rejected txs into -// recheckSenders without touching lastCommittedHeight -func (s *recheckScheduler) stageSkippedSenders(txs [][]byte) { - if !s.recheckDecodingEnabled() || len(txs) == 0 { - return - } - senders := make(map[string]struct{}, len(txs)) - for _, bz := range txs { - tx, err := s.exec.decoder(bz) - if err != nil { - continue - } - for _, sg := range s.signers(tx) { - senders[sg] = struct{}{} - } - } - if len(senders) == 0 { - return - } - s.stagingMu.Lock() - s.mergeRecheckSenders(senders) - s.stagingMu.Unlock() -} - -func (s *recheckScheduler) mergeRecheckSenders(senders map[string]struct{}) { - // mergeRecheckSenders folds senders into recheckSenders without overwriting, so a - // block whose Commit skipped RecheckTxs doesn't lose its staged senders. - if s.recheckSenders == nil { - s.recheckSenders = senders - } else { - for sg := range senders { - s.recheckSenders[sg] = struct{}{} - } - } -} - -// stageRecheckSenders records the senders of the just-committed block's txs so -// RecheckTxs can re-validate only their remaining pending txs, and stages the -// committed height. -func (s *recheckScheduler) stageRecheckSenders(height int64, txs [][]byte) { - // Decode + extract signers unlocked (the expensive part), then publish height - // and recheckSenders in one critical section so a reader never sees a torn update. - var senders map[string]struct{} - if s.recheckDecodingEnabled() { - senders = make(map[string]struct{}, len(txs)) - for _, bz := range txs { - tx, err := s.exec.decoder(bz) - if err != nil { - continue // non-sdk txs (e.g. vote extensions) have no mempool entry - } - for _, sg := range s.signers(tx) { - senders[sg] = struct{}{} - } - } - } - - s.stagingMu.Lock() - s.lastCommittedHeight = height - s.mergeRecheckSenders(senders) - s.stagingMu.Unlock() -} - -// triggerRecheck schedules an async recheck. -// Call only from the consensus path (App.Commit). -func (s *recheckScheduler) triggerRecheck() { - if s.worker.trigger == nil { - s.RecheckTxs() - return - } - s.worker.recheck() -} - -// RecheckTxs evicts pool txs invalidated by the last block. -func (s *recheckScheduler) RecheckTxs() { - if s.mpool == nil || s.recheckDisabled { - return - } - s.recheckMu.Lock() // lock order: see the recheckMu field comment - defer s.recheckMu.Unlock() - recheckSenders, height, deferred := s.drainStaging() - // Before the first block (height 0) with no senders/carry there's nothing to scan. - if len(recheckSenders) == 0 && len(deferred) == 0 && height == 0 { - return - } - - snapshot := PoolSnapshot(context.Background(), s.mpool) - candidates := s.selectTxs(snapshot, recheckSenders, height, deferred) - groups := s.capRecheckGroups(s.groupCandidates(candidates)) - // Read gen only now: it must cover the RunTx phase below, not the O(pool) - // scan/grouping above, or a Commit landing during the scan would abort the - // whole pass before a single group runs. - gen := s.exec.gen.Load() - s.runRecheck(groups, gen) - - telemetry.SetGauge(float32(s.mpool.CountTx()), "cronos", "mempool", "pool", "size") -} - -// drainStaging atomically takes and clears the staged senders, height, and carry. -func (s *recheckScheduler) drainStaging() (recheckSenders map[string]struct{}, height int64, deferred []sdk.Tx) { - s.stagingMu.Lock() - defer s.stagingMu.Unlock() - recheckSenders, height, deferred = s.recheckSenders, s.lastCommittedHeight, s.deferred - s.recheckSenders = nil - s.deferred = nil - return recheckSenders, height, deferred -} - -// selectTxs scans the pool to retrieve txs for recheck. Caller (RecheckTxs) -// only invokes this when recheck is enabled. -func (s *recheckScheduler) selectTxs(snapshot []sdk.Tx, recheckSenders map[string]struct{}, height int64, deferred []sdk.Tx) []sdk.Tx { - // deferredLive: carried-over tx -> still in pool. Sized to the small carry; nil if none. - var deferredLive map[sdk.Tx]bool - if len(deferred) > 0 { - deferredLive = make(map[sdk.Tx]bool, len(deferred)) - for _, tx := range deferred { - deferredLive[tx] = false - } - } - - var ( - expiredEvicted float32 - ttlEvicted float32 - ) - // Rebuild arrival from this cycle's snapshot so txs gone from the pool fall out. - var newArrival map[sdk.Tx]int64 - if s.ttlNumBlocks > 0 { - newArrival = make(map[sdk.Tx]int64, len(snapshot)) - } - - // 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() - for _, tx := range snapshot { - if txTimedout(tx, height, now) { - evictedSet, recheckSenders = s.evictForRecheck(tx, evictedSet, recheckSenders) - expiredEvicted++ - continue - } - if s.ttlNumBlocks > 0 { - arrived, expired := txTTLExpired(s.arrival, tx, height, s.ttlNumBlocks) - if expired { - evictedSet, recheckSenders = s.evictForRecheck(tx, evictedSet, recheckSenders) - ttlEvicted++ - continue - } - newArrival[tx] = arrived - } - } - s.arrival = newArrival - if expiredEvicted > 0 { - telemetry.IncrCounter(expiredEvicted, "cronos", "mempool", "recheck", "expired") - } - if ttlEvicted > 0 { - telemetry.IncrCounter(ttlEvicted, "cronos", "mempool", "recheck", "ttl_expired") - } - - // Pass 2: candidate selection over surviving (non-evicted) txs. - var candidates []sdk.Tx - for _, tx := range snapshot { - if _, wasEvicted := evictedSet[tx]; wasEvicted { - continue - } - if deferredLive != nil { - if _, isDeferred := deferredLive[tx]; isDeferred { - deferredLive[tx] = true - } - } - if len(recheckSenders) == 0 { - continue - } - for _, sg := range s.signers(tx) { - if _, ok := recheckSenders[sg]; ok { - candidates = append(candidates, tx) - break - } - } - } - - if len(deferred) == 0 { - return candidates - } - // Front-load surviving deferred ahead of fresh candidates: the snapshot is - // priority-ordered, so otherwise capRecheckGroups re-takes the same prefix and starves the tail. - ordered := make([]sdk.Tx, 0, len(deferred)+len(candidates)) - for _, tx := range deferred { - if deferredLive[tx] { - ordered = append(ordered, tx) // skip txs included/evicted since carry - } - } - for _, tx := range candidates { - if _, isDeferred := deferredLive[tx]; isDeferred { - continue // this tx is already in the deferred carry; avoid double recheck - } - ordered = append(ordered, tx) - } - return ordered -} - -// evictForRecheck evicts tx and folds its signers into recheckSenders, allocating -// evictedSet/recheckSenders lazily so a no-eviction cycle stays alloc-free. -func (s *recheckScheduler) evictForRecheck(tx sdk.Tx, evictedSet map[sdk.Tx]struct{}, recheckSenders map[string]struct{}) (map[sdk.Tx]struct{}, map[string]struct{}) { - s.evict(tx) - if evictedSet == nil { - evictedSet = make(map[sdk.Tx]struct{}) - } - evictedSet[tx] = struct{}{} - sigs := s.signers(tx) - if len(sigs) > 0 && recheckSenders == nil { - recheckSenders = make(map[string]struct{}) - } - for _, sg := range sigs { - recheckSenders[sg] = struct{}{} - } - return evictedSet, recheckSenders -} - -// capRecheckGroups bounds RunTx(ReCheck) calls per cycle without ever -// splitting a signer's group: the first group always runs in full regardless -// of size, and once the running total would exceed maxRecheckBatch the -// remaining groups carry forward whole into deferred. Their senders are also -// merged into recheckSenders (the recoverSenders path), because deferred is -// keyed on tx identity: a fee bump replacing a carried tx at the same nonce -// would otherwise vanish from deferredLive next cycle and take its whole live -// tail down as a false wrong-sequence failure. Re-selecting by sender instead -// picks up whatever the pool holds for that (sender, nonce) key now. -func (s *recheckScheduler) capRecheckGroups(groups []recheckGroup) []recheckGroup { - if s.maxRecheckBatch <= 0 { - return groups - } - count := 0 - for i, g := range groups { - if count > 0 && count+len(g.txs) > s.maxRecheckBatch { - carry := unreachedTxs(groups[i:]) - s.stagingMu.Lock() - s.deferred = carry - s.stagingMu.Unlock() - s.recoverSenders(carry) - return groups[:i] - } - count += len(g.txs) - } - return groups -} - -// recheckCandidate carries the signer nonce alongside the tx: telling a nonce -// gap from a merely stale nonce is what makes cascade eviction safe. -type recheckCandidate struct { - tx sdk.Tx - bz []byte - seq uint64 -} - -// recheckGroup holds one signer's candidates sorted ascending by seq. -// cascadable is false when the group is not that signer's contiguous -// ascending-nonce view — an unknown signer, a signer named by a multi-signer tx -// (that tx is grouped elsewhere, so it can fill a nonce this group can't see), a -// duplicate seq, or a tx dropped on encode error — because the cascade rule -// reasons about the next expected nonce. -type recheckGroup struct { - key string - txs []recheckCandidate - cascadable bool -} - -// runRecheck re-validates candidates via RunTx(ReCheck), one signer group at a -// time so a sender's nonce chain advances atomically with respect to other -// senders' admissions. The pass is abandoned once gen advances mid-flight: the -// remaining candidates would be validated against a base a concurrent Commit -// has already superseded. drainStaging already cleared recheckSenders for this -// cycle, so the unreached candidates' senders are re-merged into staging here — -// otherwise a sender that isn't touched again by a later block would never be -// rechecked until TTL. -func (s *recheckScheduler) runRecheck(groups []recheckGroup, gen uint64) { - var evicted, cascaded, superseded float32 - for i, g := range groups { - if len(g.txs) == 0 { - continue - } - e, c, unreachedFrom := s.runGroup(g, gen) - evicted += e - cascaded += c - if unreachedFrom != -1 { - unreached := make([]sdk.Tx, 0, len(g.txs)-unreachedFrom) - for _, cand := range g.txs[unreachedFrom:] { - unreached = append(unreached, cand.tx) - } - unreached = append(unreached, unreachedTxs(groups[i+1:])...) - superseded += float32(len(unreached)) - s.recoverSenders(unreached) - break - } - } - if evicted > 0 { - telemetry.IncrCounter(evicted, "cronos", "mempool", "recheck", "evicted") - } - if cascaded > 0 { - telemetry.IncrCounter(cascaded, "cronos", "mempool", "recheck", "cascade_evicted") - } - if superseded > 0 { - telemetry.IncrCounter(superseded, "cronos", "mempool", "recheck", "superseded") - } -} - -// groupCandidates buckets candidates by first signer — the one the mempool -// orders by — keeping first-appearance order across groups. Encoding happens -// here, outside the admission mutex, to keep the per-group hold to RunTx. -// Within a group, candidates are sorted ascending by seq: deferred front- -// loading can hand candidates out of nonce order, and running them out of -// order would fail wrong-sequence against a nonce that a later candidate in -// the same group would have satisfied. -func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { - var groups []recheckGroup - index := make(map[string]int) - // Every signer named by a multi-signer tx: that tx is grouped under its first - // signer only, so it can advance a co-signer's nonce from outside that - // co-signer's group, making a gap there unprovable. - var coSigned map[string]struct{} - for _, tx := range candidates { - key, seq, known, multiSigner := s.firstSigner(tx) - gi, seen := index[key] - if !seen { - groups = append(groups, recheckGroup{key: key, cascadable: known}) - gi = len(groups) - 1 - index[key] = gi - } - g := &groups[gi] - if multiSigner { - if coSigned == nil { - coSigned = make(map[string]struct{}) - } - for _, sg := range s.signers(tx) { - coSigned[sg] = struct{}{} - } - } - bz, _, err := EncodeTx(s.exec.encCache, s.exec.txEncoder, tx) - if err != nil { - g.cascadable = false - continue - } - g.txs = append(g.txs, recheckCandidate{tx: tx, bz: bz, seq: seq}) - } - for i := range groups { - g := &groups[i] - if _, ok := coSigned[g.key]; ok { - g.cascadable = false - } - slices.SortStableFunc(g.txs, func(a, b recheckCandidate) int { - return cmp.Compare(a.seq, b.seq) - }) - // A duplicate seq can only appear as adjacent equal entries once sorted; - // it still means the group isn't a clean ascending-nonce view. - for j := 1; j < len(g.txs); j++ { - if g.txs[j].seq <= g.txs[j-1].seq { - g.cascadable = false - break - } - } - } - return groups -} - -// recheckChunkSize bounds how many candidates one signer group runs under a -// single hold of exec.mu. Without this, a group's size is bounded only by one -// sender's pool depth, and App.Commit — which blocks on the same mutex — would -// stall behind an arbitrarily deep queue. -const recheckChunkSize = 256 - -// nonceCursor is the account's next-expected-nonce view, carried across a -// group's chunks so cascade detection in a later chunk can still reason about -// a candidate accepted in an earlier one. -type nonceCursor struct { - last uint64 - ok bool -} - -// runGroup re-validates one signer's candidates in bounded chunks, so a deep -// queue for one sender can't hold the admission mutex indefinitely. Nonce -// contiguity holds within and across chunks via the returned cursor; an -// admission of the same sender landing between chunks is the same residual -// interleaving the design doc already accepts between groups. unreachedFrom -// is -1 once every candidate has either run or been cascade-evicted; otherwise -// it is the index where the aborting chunk would have started, leaving the -// group untouched from there on. On a nonce gap the remaining higher-nonce -// siblings — including any in later chunks — are evicted without spending a -// RunTx on each: nothing can fill the gap while they sit in the pool. Any -// other failure evicts only the failing tx, since a later sibling may still be -// the account's next expected nonce. -func (s *recheckScheduler) runGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, unreachedFrom int) { - cursor := nonceCursor{} - gapFound := false - for start := 0; start < len(g.txs); start += recheckChunkSize { - end := min(start+recheckChunkSize, len(g.txs)) - if gapFound { - // A gap was already proven in an earlier chunk: every candidate from - // here on is unreachable, so just cascade-evict this chunk under the - // same bounded lock hold rather than spend a RunTx on any of them. - c, ok := s.cascadeChunkLocked(g, start, end, gen) - cascaded += c - if !ok { - return evicted, cascaded, start - } - continue - } - e, c, next, gap, ok := s.recheckChunkLocked(g, start, end, gen, cursor) - evicted += e - cascaded += c - if !ok { - return evicted, cascaded, start - } - cursor = next - gapFound = gap - } - return evicted, cascaded, -1 -} - -// recheckChunkLocked runs g.txs[start:end] under one hold of exec.mu. Returns -// ok=false if gen advanced before the chunk started, meaning nothing in -// [start, len(g.txs)) ran. gapFound reports a proven nonce gap discovered in -// this chunk: the cascade for the rest of this chunk already ran here, under -// the same lock as the admissions it must stay atomic with respect to. -func (s *recheckScheduler) recheckChunkLocked(g recheckGroup, start, end int, gen uint64, cursor nonceCursor) (evicted, cascaded float32, next nonceCursor, gapFound, ok bool) { - s.exec.mu.Lock() - defer s.exec.mu.Unlock() - // gen only advances under the same mutex, so it cannot change once this chunk starts. - if s.exec.gen.Load() != gen { - return 0, 0, cursor, false, false - } - - for i := start; i < end; i++ { - c := g.txs[i] - _, _, _, err := s.exec.runTxLocked(sdk.ExecModeReCheck, c.bz, c.tx) - if err == nil { - cursor = nonceCursor{last: c.seq, ok: true} - continue - } - s.evict(c.tx) - evicted++ - // A gap is only provable relative to a nonce this pass just accepted; - // without one the failure may be a stale nonce, whose successor is valid. - if g.cascadable && cursor.ok && c.seq > cursor.last+1 && isNonceErr(err) { - for _, rest := range g.txs[i+1 : end] { - s.evict(rest.tx) - cascaded++ - } - return evicted, cascaded, cursor, true, true - } - } - return evicted, cascaded, cursor, false, true -} - -// cascadeChunkLocked evicts g.txs[start:end] under one hold of exec.mu, for a -// chunk that starts after a gap was already proven in an earlier chunk. -// Chunked the same as recheckChunkLocked so a long cascaded tail can't hold -// the admission mutex in one unbounded stretch. -func (s *recheckScheduler) cascadeChunkLocked(g recheckGroup, start, end int, gen uint64) (cascaded float32, ok bool) { - s.exec.mu.Lock() - defer s.exec.mu.Unlock() - if s.exec.gen.Load() != gen { - return 0, false - } - for _, c := range g.txs[start:end] { - s.evict(c.tx) - cascaded++ - } - return cascaded, true -} - -// isNonceErr matches both ante paths: cosmos sig verification reports -// ErrWrongSequence, the EVM nonce check reports ErrInvalidSequence. -func isNonceErr(err error) bool { - return errorsmod.IsOf(err, sdkerrors.ErrWrongSequence, sdkerrors.ErrInvalidSequence) -} - -func unreachedTxs(groups []recheckGroup) []sdk.Tx { - var txs []sdk.Tx - for _, g := range groups { - for _, c := range g.txs { - txs = append(txs, c.tx) - } - } - return txs -} - -// recoverSenders folds txs' senders back into staged recheckSenders without -// touching deferred, which capRecheckGroups may have already set this cycle. A -// candidate whose signer can't be extracted is silently dropped here, so it -// waits for TTL eviction instead of being re-covered. -func (s *recheckScheduler) recoverSenders(txs []sdk.Tx) { - if len(txs) == 0 { - return - } - senders := make(map[string]struct{}) - for _, tx := range txs { - for _, sg := range s.signers(tx) { - senders[sg] = struct{}{} - } - } - if len(senders) == 0 { - return - } - s.stagingMu.Lock() - s.mergeRecheckSenders(senders) - s.stagingMu.Unlock() -} - -// txTimedout reports whether tx should be evicted by its own declared timeout: -func txTimedout(tx sdk.Tx, height int64, now time.Time) bool { - if t, ok := tx.(sdk.TxWithTimeoutHeight); ok { - th := t.GetTimeoutHeight() - if th > 0 && uint64(height) >= th { - return true - } - } - if t, ok := tx.(sdk.TxWithTimeoutTimeStamp); ok { - ts := t.GetTimeoutTimeStamp() - if !ts.IsZero() && !now.Before(ts) { - return true - } - } - return false -} - -// txTTLExpired reports whether tx has aged past ttlNumBlocks since first seen. -func txTTLExpired(arrival map[sdk.Tx]int64, tx sdk.Tx, height, ttlNumBlocks int64) (int64, bool) { - arrived, ok := arrival[tx] - if !ok { - arrived = height - } - return arrived, height-arrived >= ttlNumBlocks -} - -// evict removes tx from the pool and encoder cache together, so the cache never -// outlives its pool entry. -func (s *recheckScheduler) evict(tx sdk.Tx) { - _ = s.mpool.Remove(tx) - s.exec.encCache.Evict(tx) -} - -// firstSigner returns the signer the mempool orders by, with its nonce, and -// whether tx has more than one signer. An unknown signer only costs the -// cascade optimization, not the recheck itself. A multi-signer tx must also -// disable the cascade: a secondary signer's nonce isn't visible to the group -// keyed on the first signer, so a gap in that group may really be filled by -// a multi-signer tx grouped elsewhere. -func (s *recheckScheduler) firstSigner(tx sdk.Tx) (key string, seq uint64, known, multiSigner bool) { - if s.signer == nil { - return "", 0, false, false - } - sigs, err := s.signer.GetSigners(tx) - if err != nil || len(sigs) == 0 { - return "", 0, false, false - } - return sigs[0].Signer.String(), sigs[0].Sequence, true, len(sigs) > 1 -} - -func (s *recheckScheduler) signers(tx sdk.Tx) []string { - if s.signer == nil { - return nil - } - sigs, err := s.signer.GetSigners(tx) - if err != nil { - return nil - } - keys := make([]string, len(sigs)) - for i, sg := range sigs { - keys[i] = sg.Signer.String() - } - return keys -} From 040720d1be6a8712d99cbb7cf9530f445c34b863 Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Thu, 30 Jul 2026 11:53:20 -0400 Subject: [PATCH 09/12] fix(mempool): reverify chunk-boundary gaps before blind cascade eviction 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. --- app/mempool/manager_test.go | 8 +- app/mempool/recheck_test.go | 134 ++++++++- app/mempool/scheduler.go | 128 +++++--- .../mempool-branched-recheck-context.md | 278 ++++++++++++++++++ 4 files changed, 494 insertions(+), 54 deletions(-) create mode 100644 docs/architecture/mempool-branched-recheck-context.md diff --git a/app/mempool/manager_test.go b/app/mempool/manager_test.go index 6ebd07e7ee..edb5961f09 100644 --- a/app/mempool/manager_test.go +++ b/app/mempool/manager_test.go @@ -24,13 +24,17 @@ import ( // receiver is needed. The id field gives it non-zero size so distinct // allocations get distinct addresses (zero-size structs share runtime.zerobase). type ptrTx struct { - id int - timeout uint64 // GetTimeoutHeight; 0 = no timeout + id int + timeout uint64 // GetTimeoutHeight; 0 = no timeout + unordered bool // GetUnordered; implements sdk.TxWithUnordered + timeoutTS time.Time // GetTimeoutTimeStamp; ChooseNonce keys unordered txs by this } func (*ptrTx) GetMsgs() []sdk.Msg { return nil } func (*ptrTx) GetMsgsV2() ([]protov2.Message, error) { return nil, nil } func (t *ptrTx) GetTimeoutHeight() uint64 { return t.timeout } +func (t *ptrTx) GetTimeoutTimeStamp() time.Time { return t.timeoutTS } +func (t *ptrTx) GetUnordered() bool { return t.unordered } // noopEncoder is a non-nil txEncoder for tests that don't assert on bytes. var noopEncoder sdk.TxEncoder = func(sdk.Tx) ([]byte, error) { return nil, nil } diff --git a/app/mempool/recheck_test.go b/app/mempool/recheck_test.go index 5e262c8ae2..8707ebfea8 100644 --- a/app/mempool/recheck_test.go +++ b/app/mempool/recheck_test.go @@ -7,6 +7,7 @@ import ( "strconv" "sync" "testing" + "time" abci "github.com/cometbft/cometbft/abci/types" @@ -1083,9 +1084,9 @@ func TestRecheckTxs_GenerationBumpDoesNotSplitAChunk(t *testing.T) { // TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred covers // the two-sender abort case explicitly: the unreached sender must land in -// staging (not just its raw tx, which runRecheck never touches), and an -// already-set deferred carry from this same cycle's capRecheckGroups must survive -// untouched. +// staging (not just its raw tx, which runRecheck never touches), and the +// unreached tx must be appended to deferred (F3) alongside — not in place of +// — an already-set deferred carry from this same cycle's capRecheckGroups. func TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred(t *testing.T) { f := newRecheckFixture() aliceTx := f.add(1, "alice", 0, aliceSeq0Bytes) @@ -1111,8 +1112,8 @@ func TestRunRecheck_AbortRecoversUnreachedSendersWithoutClobberingDeferred(t *te if _, ok := f.a.sched.recheckSenders[sdk.AccAddress("bob").String()]; !ok { t.Fatal("bob must be re-covered in staging after its candidate was skipped") } - if len(f.a.sched.deferred) != 1 || f.a.sched.deferred[0] != carryTx { - t.Fatal("an already-set deferred carry from this cycle must not be clobbered") + if !slices.Equal(f.a.sched.deferred, []sdk.Tx{carryTx, bobTx}) { + t.Fatalf("expected the abort to append bob's tx after the untouched carry, got %v", f.a.sched.deferred) } // Next RecheckTxs cycle: bob (re-covered) and the carried carol tx must both @@ -1272,6 +1273,32 @@ func TestGroupCandidates_MultiSignerDisablesCascadeInCoSignerGroup(t *testing.T) } } +// F2: an unordered tx keys its SignerData.Sequence at 0 (ChooseNonce orders it +// by timeout, not sequence), so a group holding it alongside ordered seqs +// 6, 7, 8 has no duplicate seq and would otherwise look like a clean +// ascending-nonce view. groupCandidates must disable cascade for it directly, +// since the seq it carries can't be reasoned about by the gap rule. +func TestGroupCandidates_UnorderedTxDisablesCascade(t *testing.T) { + f := newRecheckFixture() + unordered := &ptrTx{id: 1, unordered: true, timeoutTS: time.Now().Add(time.Hour)} + f.signer.m[unordered] = []sdkmempool.SignerData{sdkmempool.NewSignerData(sdk.AccAddress("alice"), 0)} + if err := f.pool.Insert(sdk.Context{}, unordered); err != nil { + t.Fatal(err) + } + seq6 := f.insert(2, sdk.AccAddress("alice"), 6) + seq7 := f.insert(3, sdk.AccAddress("alice"), 7) + seq8 := f.insert(4, sdk.AccAddress("alice"), 8) + + groups := f.a.sched.groupCandidates([]sdk.Tx{unordered, seq6, seq7, seq8}) + + if len(groups) != 1 { + t.Fatalf("expected 1 group keyed on alice, got %d", len(groups)) + } + if groups[0].cascadable { + t.Fatal("an unordered tx in the group must disable cascade") + } +} + // Deferred front-loading can hand groupCandidates an out-of-nonce-order group // (e.g. alice-5 ahead of alice-3 and alice-4). Without sorting, alice-5 would // run first and fail wrong-sequence even though it becomes valid two txs later. @@ -1346,6 +1373,76 @@ func TestRunGroup_LargerThanChunkRunsEveryCandidate(t *testing.T) { } } +// F1 regression: a gap proven at the very last index of a chunk leaves the +// cascade range for that chunk empty (g.txs[i+1:end] has nothing in it), so +// nothing was actually evicted under the lock hold that proved the gap. If a +// same-sender admission fills the gap before the next chunk's turn, +// cascadeChunkLocked must discover that with a RunTx on the next chunk's own +// head rather than blind-evicting a nonce that is now valid. +func TestRunGroup_CascadeChunkHeadRunTxWhenGapProvenAtChunkBoundary(t *testing.T) { + const n = recheckChunkSize + const total = n + 2 // chunk 1 = [0, n); chunk 2 = [n, n+2) + f := newRecheckFixture() + f.runner.signer = f.signer + dave := sdk.AccAddress("dave").String() + f.runner.expectedNonce = map[string]uint64{dave: 0} + + seqOf := func(i int) uint64 { + switch { + case i < n-1: + return uint64(i) // 0..n-2: ascending, all valid + case i == n-1: + return uint64(n) + 3 // last of chunk 1: opens a gap (skips n-1, n, n+1, n+2) + default: + return uint64(n) + 3 + uint64(i-(n-1)) // chunk 2: continues ascending past the gap + } + } + bz := func(i int) string { return "dave-" + strconv.Itoa(i) } + txs := make([]sdk.Tx, total) + ptrTxs := make([]*ptrTx, total) + for i := 0; i < total; i++ { + ptrTxs[i] = f.add(i+1, "dave", seqOf(i), bz(i)) + txs[i] = ptrTxs[i] + } + + // A same-sender admission lands between chunk 1's lock release and chunk + // 2's cascadeChunkLocked call, filling every nonce the gap skipped — by + // the time chunk 2 runs, the account's expected nonce matches chunk 2's + // head exactly. + f.runner.onCall = func(b []byte) { + if string(b) == bz(n-1) { + f.runner.expectedNonce[dave] = seqOf(n) + } + } + + groups := f.a.sched.groupCandidates(txs) + if len(groups) != 1 || !groups[0].cascadable { + t.Fatalf("expected 1 cascadable group, got %+v", groups) + } + + evicted, cascaded, unreachedFrom := f.a.sched.runGroup(groups[0], f.a.exec.gen.Load()) + if unreachedFrom != -1 { + t.Fatalf("expected the whole group reached, got unreachedFrom=%d", unreachedFrom) + } + if evicted != 1 { + t.Fatalf("expected exactly 1 eviction (the originally gapped tx), got %v", evicted) + } + if cascaded != 0 { + t.Fatalf("expected no blind cascade eviction once the gap closed, got %v", cascaded) + } + for i := n; i < total; i++ { + if !f.runner.seen[bz(i)] { + t.Fatalf("candidate %d must have spent a RunTx, not been blind-evicted", i) + } + if !poolHas(f.pool, ptrTxs[i]) { + t.Fatalf("candidate %d is now valid and must not be evicted", i) + } + } + if poolHas(f.pool, ptrTxs[n-1]) { + t.Fatal("the originally gapped tx must still be evicted") + } +} + // A gen bump landing exactly at a chunk boundary must abort the group there: // the completed chunk stays rechecked, and the untouched tail's sender is // re-staged so the next cycle covers it — mirroring the same-generation @@ -1388,11 +1485,16 @@ func TestRunRecheck_GenBumpAtChunkBoundaryAbortsAndRestagesSender(t *testing.T) // A nonce gap discovered in a later chunk must still cascade-evict every // higher-nonce sibling, including ones that live in a chunk beyond the one -// where the gap was found — the cascade doesn't stop at a chunk boundary. +// where the gap was found — except each further chunk's own head now spends +// a RunTx (F1 fix) to confirm the gap actually survived the lock release at +// that boundary, so it isn't the same blind cascade past the first chunk. func TestRecheckGroup_CascadeEvictsAcrossChunkBoundary(t *testing.T) { const total = 3*recheckChunkSize - 88 // spans 3 chunks; boundaries at 256, 512 const gapIndex = 400 // inside chunk 2 ([256, 512)) + const chunk3Head = 2 * recheckChunkSize f := newRecheckFixture() + f.runner.signer = f.signer // real nonce tracking: the gap must hold on its own, not via failErrs + f.runner.expectedNonce = map[string]uint64{sdk.AccAddress("carl").String(): 0} txs := make([]sdk.Tx, total) ptrTxs := make([]*ptrTx, total) bz := func(i int) string { return "carl-" + strconv.Itoa(i) } @@ -1404,7 +1506,6 @@ func TestRecheckGroup_CascadeEvictsAcrossChunkBoundary(t *testing.T) { ptrTxs[i] = f.add(i+1, "carl", seq, bz(i)) txs[i] = ptrTxs[i] } - f.runner.failErrs = map[string]error{bz(gapIndex): errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} groups := f.a.sched.groupCandidates(txs) if len(groups) != 1 || !groups[0].cascadable { @@ -1415,13 +1516,18 @@ func TestRecheckGroup_CascadeEvictsAcrossChunkBoundary(t *testing.T) { if unreachedFrom != -1 { t.Fatalf("expected the whole group reached (run or cascade-evicted), got unreachedFrom=%d", unreachedFrom) } - if evicted != 1 { - t.Fatalf("expected 1 direct eviction (the gapped tx), got %v", evicted) + // Two real RunTx-driven evictions: the gapped candidate itself, and chunk + // 3's head re-checking whether the gap survived its own chunk boundary. + if evicted != 2 { + t.Fatalf("expected 2 direct evictions (the gapped tx and the next chunk's head), got %v", evicted) } - if want := float32(total - gapIndex - 1); cascaded != want { - t.Fatalf("expected %v cascade-evicted siblings spanning chunk 2 and chunk 3, got %v", want, cascaded) + if want := float32(total - gapIndex - 2); cascaded != want { + t.Fatalf("expected %v cascade-evicted siblings, got %v", want, cascaded) } for i := gapIndex + 1; i < total; i++ { + if i == chunk3Head { + continue + } if f.runner.seen[bz(i)] { t.Fatalf("sibling at index %d must be cascade-evicted without a RunTx", i) } @@ -1429,6 +1535,12 @@ func TestRecheckGroup_CascadeEvictsAcrossChunkBoundary(t *testing.T) { t.Fatalf("sibling at index %d must be evicted from the pool", i) } } + if !f.runner.seen[bz(chunk3Head)] { + t.Fatal("the next chunk's own head must spend a RunTx to check whether the gap survived to this chunk") + } + if poolHas(f.pool, ptrTxs[chunk3Head]) { + t.Fatal("the next chunk's head must still be evicted since the gap held") + } if !f.runner.seen[bz(gapIndex-1)] { t.Fatal("the last successful candidate before the gap must have run") } diff --git a/app/mempool/scheduler.go b/app/mempool/scheduler.go index e1ef8b29bd..579106c95b 100644 --- a/app/mempool/scheduler.go +++ b/app/mempool/scheduler.go @@ -317,8 +317,9 @@ type recheckCandidate struct { // cascadable is false when the group is not that signer's contiguous // ascending-nonce view — an unknown signer, a signer named by a multi-signer tx // (that tx is grouped elsewhere, so it can fill a nonce this group can't see), a -// duplicate seq, or a tx dropped on encode error — because the cascade rule -// reasons about the next expected nonce. +// duplicate seq, an unordered tx (keyed by timeout, not sequence), or a tx +// dropped on encode error — because the cascade rule reasons about the next +// expected nonce. type recheckGroup struct { key string txs []recheckCandidate @@ -332,7 +333,9 @@ type recheckGroup struct { // has already superseded. drainStaging already cleared recheckSenders for this // cycle, so the unreached candidates' senders are re-merged into staging here — // otherwise a sender that isn't touched again by a later block would never be -// rechecked until TTL. +// rechecked until TTL. They're also appended to deferred, so selectTxs front- +// loads them ahead of the priority-ordered snapshot's same old prefix next +// cycle, same as capRecheckGroups' overflow carry. func (s *recheckScheduler) runRecheck(groups []recheckGroup, gen uint64) { var evicted, cascaded, superseded float32 for i, g := range groups { @@ -350,6 +353,7 @@ func (s *recheckScheduler) runRecheck(groups []recheckGroup, gen uint64) { unreached = append(unreached, unreachedTxs(groups[i+1:])...) superseded += float32(len(unreached)) s.recoverSenders(unreached) + s.appendDeferred(unreached) break } } @@ -395,6 +399,9 @@ func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { coSigned[sg] = struct{}{} } } + if unordered, ok := tx.(sdk.TxWithUnordered); ok && unordered.GetUnordered() { + g.cascadable = false // unordered txs key by timeout, not sequence: seq here is meaningless for the gap rule + } bz, _, err := EncodeTx(s.exec.encCache, s.exec.txEncoder, tx) if err != nil { g.cascadable = false @@ -444,27 +451,29 @@ type nonceCursor struct { // is -1 once every candidate has either run or been cascade-evicted; otherwise // it is the index where the aborting chunk would have started, leaving the // group untouched from there on. On a nonce gap the remaining higher-nonce -// siblings — including any in later chunks — are evicted without spending a -// RunTx on each: nothing can fill the gap while they sit in the pool. Any -// other failure evicts only the failing tx, since a later sibling may still be -// the account's next expected nonce. +// siblings in the same chunk are evicted without spending a RunTx on each, +// since that eviction runs under the same lock hold as the gap proof. Each +// later chunk's own head is still verified with its own RunTx before any +// blind eviction there — the lock is released between chunks, so an admission +// of the same sender can legitimately fill the gap in the meantime. Any +// non-gap failure evicts only the failing tx, since a later sibling may still +// be the account's next expected nonce. func (s *recheckScheduler) runGroup(g recheckGroup, gen uint64) (evicted, cascaded float32, unreachedFrom int) { cursor := nonceCursor{} gapFound := false for start := 0; start < len(g.txs); start += recheckChunkSize { end := min(start+recheckChunkSize, len(g.txs)) + var ( + e, c float32 + next nonceCursor + gap bool + ok bool + ) if gapFound { - // A gap was already proven in an earlier chunk: every candidate from - // here on is unreachable, so just cascade-evict this chunk under the - // same bounded lock hold rather than spend a RunTx on any of them. - c, ok := s.cascadeChunkLocked(g, start, end, gen) - cascaded += c - if !ok { - return evicted, cascaded, start - } - continue + e, c, next, gap, ok = s.cascadeChunkLocked(g, start, end, gen) + } else { + e, c, next, gap, ok = s.recheckChunkLocked(g, start, end, gen, cursor) } - e, c, next, gap, ok := s.recheckChunkLocked(g, start, end, gen, cursor) evicted += e cascaded += c if !ok { @@ -476,19 +485,11 @@ func (s *recheckScheduler) runGroup(g recheckGroup, gen uint64) (evicted, cascad return evicted, cascaded, -1 } -// recheckChunkLocked runs g.txs[start:end] under one hold of exec.mu. Returns -// ok=false if gen advanced before the chunk started, meaning nothing in -// [start, len(g.txs)) ran. gapFound reports a proven nonce gap discovered in -// this chunk: the cascade for the rest of this chunk already ran here, under -// the same lock as the admissions it must stay atomic with respect to. -func (s *recheckScheduler) recheckChunkLocked(g recheckGroup, start, end int, gen uint64, cursor nonceCursor) (evicted, cascaded float32, next nonceCursor, gapFound, ok bool) { - s.exec.mu.Lock() - defer s.exec.mu.Unlock() - // gen only advances under the same mutex, so it cannot change once this chunk starts. - if s.exec.gen.Load() != gen { - return 0, 0, cursor, false, false - } - +// runCandidatesLocked runs g.txs[start:end] against the current base, +// evicting any candidate that fails and cascade-evicting the rest of the +// range once a nonce gap is proven. Precondition: caller holds exec.mu and +// has already confirmed gen is current. +func (s *recheckScheduler) runCandidatesLocked(g recheckGroup, start, end int, cursor nonceCursor) (evicted, cascaded float32, next nonceCursor, gapFound bool) { for i := start; i < end; i++ { c := g.txs[i] _, _, _, err := s.exec.runTxLocked(sdk.ExecModeReCheck, c.bz, c.tx) @@ -505,27 +506,59 @@ func (s *recheckScheduler) recheckChunkLocked(g recheckGroup, start, end int, ge s.evict(rest.tx) cascaded++ } - return evicted, cascaded, cursor, true, true + return evicted, cascaded, cursor, true } } - return evicted, cascaded, cursor, false, true + return evicted, cascaded, cursor, false } -// cascadeChunkLocked evicts g.txs[start:end] under one hold of exec.mu, for a -// chunk that starts after a gap was already proven in an earlier chunk. -// Chunked the same as recheckChunkLocked so a long cascaded tail can't hold -// the admission mutex in one unbounded stretch. -func (s *recheckScheduler) cascadeChunkLocked(g recheckGroup, start, end int, gen uint64) (cascaded float32, ok bool) { +// recheckChunkLocked runs g.txs[start:end] under one hold of exec.mu. Returns +// ok=false if gen advanced before the chunk started, meaning nothing in +// [start, len(g.txs)) ran. gapFound reports a proven nonce gap discovered in +// this chunk: the cascade for the rest of this chunk already ran here, under +// the same lock as the admissions it must stay atomic with respect to. +func (s *recheckScheduler) recheckChunkLocked(g recheckGroup, start, end int, gen uint64, cursor nonceCursor) (evicted, cascaded float32, next nonceCursor, gapFound, ok bool) { s.exec.mu.Lock() defer s.exec.mu.Unlock() + // gen only advances under the same mutex, so it cannot change once this chunk starts. if s.exec.gen.Load() != gen { - return 0, false + return 0, 0, cursor, false, false } - for _, c := range g.txs[start:end] { - s.evict(c.tx) + evicted, cascaded, next, gapFound = s.runCandidatesLocked(g, start, end, cursor) + return evicted, cascaded, next, gapFound, true +} + +// cascadeChunkLocked handles a chunk that starts after a gap was proven in an +// earlier chunk. The gap proof only covers evictions made under that earlier +// chunk's own lock hold; the lock is released between chunks, so an admission +// of the same sender can land in the gap before this chunk's turn and +// legitimately fill it. This chunk's own head is therefore verified with a +// RunTx before anything is blind-evicted: if it succeeds, the gap didn't +// survive to this chunk, and the remainder falls back to normal +// recheckChunkLocked semantics, seeded from the head's now-accepted nonce. If +// it fails — for a nonce reason or otherwise, any failure means the gap held +// — the head and the rest of the chunk are cascade-evicted as before, without +// a RunTx on the rest. +func (s *recheckScheduler) cascadeChunkLocked(g recheckGroup, start, end int, gen uint64) (evicted, cascaded float32, next nonceCursor, gapFound, ok bool) { + s.exec.mu.Lock() + defer s.exec.mu.Unlock() + if s.exec.gen.Load() != gen { + return 0, 0, nonceCursor{}, true, false + } + + head := g.txs[start] + _, _, _, err := s.exec.runTxLocked(sdk.ExecModeReCheck, head.bz, head.tx) + if err == nil { + evicted, cascaded, next, gapFound = s.runCandidatesLocked(g, start+1, end, nonceCursor{last: head.seq, ok: true}) + return evicted, cascaded, next, gapFound, true + } + + s.evict(head.tx) + for _, rest := range g.txs[start+1 : end] { + s.evict(rest.tx) cascaded++ } - return cascaded, true + return 1, cascaded, nonceCursor{}, true, true } // isNonceErr matches both ante paths: cosmos sig verification reports @@ -566,6 +599,19 @@ func (s *recheckScheduler) recoverSenders(txs []sdk.Tx) { s.stagingMu.Unlock() } +// appendDeferred appends txs to the deferred carry under stagingMu. Append, +// not overwrite: capRecheckGroups may have already set deferred to its own +// overflow carry earlier this same cycle, and that must survive alongside an +// abort's unreached tail. +func (s *recheckScheduler) appendDeferred(txs []sdk.Tx) { + if len(txs) == 0 { + return + } + s.stagingMu.Lock() + s.deferred = append(s.deferred, txs...) + s.stagingMu.Unlock() +} + // txTimedout reports whether tx should be evicted by its own declared timeout: func txTimedout(tx sdk.Tx, height int64, now time.Time) bool { if t, ok := tx.(sdk.TxWithTimeoutHeight); ok { diff --git a/docs/architecture/mempool-branched-recheck-context.md b/docs/architecture/mempool-branched-recheck-context.md new file mode 100644 index 0000000000..ba569b5f09 --- /dev/null +++ b/docs/architecture/mempool-branched-recheck-context.md @@ -0,0 +1,278 @@ +# Plan: mempool-owned branched context for recheck + admission + +Tracks the remaining open items of [#2109](https://github.com/crypto-org-chain/cronos/issues/2109). + +## Context + +PR #2118 moved the post-Commit mempool recheck off the consensus path onto an async +worker. In review, songgaoye flagged a remaining follow-up: + +> recheck uses the shared `checkState` and releases `a.mu` between candidates, so +> concurrent `InsertTx/CheckTx` can interleave and make evictions timing-dependent. +> Move recheck onto a dedicated branched context. cosmos/evm's `mempool/rechecker.go` +> runs the ante on a branched `CacheMultiStore` instead of the shared `checkState`. + +Today all three mempool `RunTx` call sites — `admit` (`app/mempool/manager.go:215`), the +RPC `CheckTxHandler` (`:257`), and `runRecheck` (`:498`) — pass a `nil` `txMultiStore` and +so run against baseapp's **shared** `checkState`. `RunTx(ExecModeReCheck)` writes nonce +bumps straight back into it (baseapp `mode != execModeCheck` → `msCache.Write()`), which is +*load-bearing*: `Commit` resets `checkState` to committed state, and recheck's ante writes +are what rebuild the pending-nonce view admission relies on. Because `a.mu` is released +between recheck candidates, admission interleaves and perturbs those reads/writes → +node-local, non-deterministic evictions (not a data race — every `RunTx` is individually +serialized by `a.mu`). + +Goal: give the app-mempool its **own** working state — a `CacheMultiStore` branched off the +committed store — that admission and recheck share, mirroring cosmos/evm's model where the +mempool owns its context rather than sharing baseapp `checkState`. + +## Key enabler + +The crypto-org-chain SDK fork's `RunTx` already takes a `txMultiStore` 5th arg +(`baseapp/baseapp.go:778`): when non-nil it overrides the ctx multistore +(`ctx.WithMultiStore(txMultiStore)`, `:791`) and its internal ante branch writes back into +that store on success (ReCheck `:918`, Check-after-Insert `:932`). So we do **not** +reimplement cosmos/evm's `GetContext()/write()` closure — we pass a mempool-owned +`CacheMultiStore` as the 5th arg. `getContextForTx` (`:610`) still sources +header/consensus/gas/`IsReCheckTx` from `checkState`, so **`checkState` must stay** as the +context source; we only displace its multistore's nonce-tracking role. + +## Two decisions that shape the design + +### 1. A shared `base` alone does not fix the determinism complaint + +Moving both paths from `checkState` to one shared `base` keeps exactly the interleaving +review objected to: `runRecheck` still drops the lock between candidates and admission +still lands in between. What the shared `base` *does* buy is that admission and recheck no +longer write into `checkState`, so queries and `Simulate` see committed state instead of a +pending-nonce view. + +Determinism is fixed separately, by **grouping recheck candidates per signer** and holding +the store lock per bounded chunk within each group (Phase B) — see below. + +### 2. A recheck-private branch is rejected + +Forking a private `CacheMultiStore` at batch start and `Write()`-ing it back at batch end +looks like stronger isolation but is worse: + +- `Write()` applies the whole write set, last-writer-wins per key. An admission that lands + mid-batch (alice nonce 7 → writes nonce 8) is silently rolled back by the batch's older + view (alice 5→6), so alice's next tx fails wrong-sequence. That trades a benign + timing-dependence for state loss. +- Discarding the batch instead of writing it back is not an option either: recheck's writes + are load-bearing for the pending-nonce view (see Context). + +So: one `base`, shared, plus per-sender lock grouping. + +### 3. All three `RunTx` call sites must move in the same change + +`base` has to be the *sole* nonce authority. If recheck writes to `base` while admission +still reads `checkState` (reset to committed state at every `Commit`), admission stops +seeing the pending-nonce view and rejects legitimate higher-nonce siblings. The same +divergence appears between peer `InsertTx` and RPC `CheckTx` if only one of them moves. +This corrects an earlier draft of this plan, which staged recheck ahead of admission as +"lowest risk" — that intermediate state is a functional regression, not a safe step. + +## Design + +### `mempoolState` + +`app/mempool/state.go` (new): + +```go +type mempoolState struct { + mu sync.RWMutex + base storetypes.CacheMultiStore + provider func() storetypes.CommitMultiStore +} +``` + +- `refreshLocked()` — `base = provider().CacheMultiStore()`; caller holds the store guard. +- `store() storetypes.MultiStore` — RLock, return `base`. Nil-safe: a nil `mempoolState` + (or nil `base`) returns nil, so `RunTx` falls back to `checkState` and the existing + `newManager()` unit tests stay green without a store. + +`Manager` gains `state *mempoolState` and `gen atomic.Uint64`. `NewManager` wires +`provider: app.CommitMultiStore` but does *not* refresh: it runs inside `baseAppOptions`, +before `LoadLatestVersion`, so a refresh there would branch off an unloaded store. `base` +stays nil (and `store()` falls back to `checkState`) until `App` calls +`RefreshMempoolStateLocked` right after `LoadLatestVersion` succeeds — the earliest point +the store is actually loaded. The `newManager()` test constructor also leaves `state` nil. + + +### Call sites + +Change the 5th `RunTx` arg `nil → a.state.store()` in `admit`, `runRecheck`, and +`CheckTxHandler`. + +`CheckTxHandler` receives a `runTx sdk.RunTx` closure from baseapp that hardcodes +`txMultiStore = nil` (`baseapp/abci.go:408`). The handler therefore calls +`a.runner.RunTx` directly and derives the exec mode from `req.Type` the same way baseapp +does (`New → ExecModeCheck`, `Recheck → ExecModeReCheck`, anything else is an error). That +bypasses baseapp's "avoid users overriding the execution mode" wrapper, so the mapping +must stay in sync with `BaseApp.CheckTx`. + +### Refresh at Commit + +`App.Commit` already holds `AdmissionMutex()` across `BaseApp.Commit()`. Refresh `base` and +bump `gen` inside that same critical section, right after `BaseApp.Commit()` returns and +before `TriggerRecheck()`. Every `base`-writing `RunTx` is serialized by the same mutex, so +the swap can never race a reader and the `CacheMultiStore`'s maps are never concurrently +mutated. + +### Lock model (no new mutex) + +`Manager.mu` — renamed `txExec.mu`, since it now guards `base` rather than `checkState` — is +held briefly around each `RunTx` (unchanged granularity) and around +`BaseApp.Commit()` + `refreshLocked` in `App.Commit`. Order `recheckMu > txExec.mu > +stagingMu` is preserved; `refreshLocked` is a leaf that assumes the caller holds `txExec.mu`, +so there is no re-entrant acquisition. + +### Cancellation + +`RecheckTxs` captures `gen` right before `runRecheck`, after `selectTxs`/grouping/capping — +not right after `drainStaging` — so a Commit landing during the O(pool) scan doesn't abort +the whole pass before a single group has run. `runRecheck` returns early once `gen` differs, +abandoning candidates validated against a superseded `base`. `drainStaging` already cleared +`recheckSenders` for this cycle, so those abandoned candidates' senders would otherwise +vanish — a sender not touched again by a later block would never get rechecked until TTL. +`runRecheck` re-merges the senders of the unreached candidates back into staging +(`mergeRecheckSenders` under `stagingMu`) before returning, so the next cycle's `selectTxs` +re-picks their live pool txs. It does not touch `deferred`, which `capRecheckGroups` may have +already set this cycle. + + +### What this does *not* fix + +`base` reads still fall through to the live memiavl tree that `Commit` mutates, so the +store guard must keep covering the whole `BaseApp.Commit()`. Item 1 of #2109 (narrow the +mutex to the `checkState` reset) stays blocked on making memiavl read-safe during commit. +Item 2 (lock-light `PoolSnapshot`) stays blocked on an SDK mempool change; PR #2156 only +collapses the RPC read fan-out. + +## Phases + +Each phase is a separate PR. + +### Phase A — mempool-owned branched state (item 3b) + +`mempoolState` + all three call sites + refresh at `Commit` + generation cancellation, as +described above. Atomic by necessity (decision 3). + +### Phase B — per-sender grouping in `runRecheck` + +Candidates are bucketed by first signer — the one `PriorityNonceMempool` orders by — keeping +first-appearance order across groups (so the front-loaded deferred prefix still runs first) +and pool order within a group. `maxRecheckBatch` is a soft cap applied at group boundaries +only, after grouping: once the running total of a cycle's group sizes would exceed the cap, +the remaining whole groups are deferred, never split mid-group — a sender's nonce chain always +runs to completion in one cycle even if it alone exceeds the cap. Encoding moved out of the +lock into the grouping pass. + +`stateMu` is no longer held for a group's full duration — the issue's "hold the mutex across +the whole batch" variant, which reintroduces the admission stall #2118 removed, applies just +as much to one oversized group. Instead a group runs in bounded chunks of `recheckChunkSize` +candidates: `stateMu` is taken, `gen` is re-checked, up to one chunk runs, and the mutex is +released before the next chunk starts. This keeps every mutex hold short regardless of how +deep any one sender's queue is. + +Cancellation granularity follows: `gen` is checked at the start of every chunk, not just once +per group, so a group can now abort mid-flight at a chunk boundary. An abort leaves the +unreached tail — from the aborted chunk's start onward — untouched for `recoverSenders`, the +same recovery path used for groups that never got a turn at all. + +Cascade eviction inside a group: on a nonce failure (`ErrWrongSequence` from cosmos sig +verification, `ErrInvalidSequence` from the EVM ante) the remaining higher-nonce siblings are +evicted without spending a `RunTx` on each. Guarded, because the naive rule is wrong: a +wrong-sequence failure can mean either a *gap* (nonce too high — siblings are unreachable) or +a *stale* nonce (already committed — the successor may be exactly the expected one, and +cascading would evict valid txs). The cascade fires only when the gap is provable: some +earlier tx in the same group passed recheck this pass (so `lastOK + 1` is the account's next +expected nonce) and the failing tx's nonce is strictly greater. It is also disabled for any +group that isn't the signer's contiguous ascending view — unknown signer, repeated or +descending nonce, or a tx dropped on encode error. Counter: +`cronos.mempool.recheck.cascade_evicted`. + +Residual, accepted, documented: an admission *of the same sender* can still interleave between +that sender's chunks, and between that sender's groups across cycles. It can bump the nonce in +`base` past the next chunk's first candidate, which then fails as stale and is evicted — but +because `PriorityNonceMempool.Remove` resolves by (sender, nonce) key rather than tx identity, +the tx actually dropped from the pool may be the freshly admitted replacement at that nonce, not +the stale candidate the recheck pass was validating (no cascade either way — a stale nonce is +exactly `lastOK + 1`, so the gap rule doesn't fire). This predates this change and is inherent to +key-based removal in the SDK pool — `BaseApp.RunTx` does the same key-based removal itself on a +ReCheck ante failure. Ordering there is inherently racy (the tx arrived concurrently), the effect +is node-local, and the client's resubmit resolves it. A cross-chunk cascade needs its own check +for the same reason: a gap proven in one chunk is only atomic with the admissions it must stay +consistent with for the duration of that chunk's own lock hold, not across the boundary into the +next one. `cascadeChunkLocked` therefore spends one `RunTx` on the next chunk's head before +blind-evicting anything in that chunk; if it now passes (the gap was filled by an admission that +landed between chunks), the chunk falls back to normal `recheckChunkLocked` semantics from +there, instead of evicting a now-valid nonce sight unseen. + +### Phase C — split `Manager` (item 5) + +Three files, split along the boundary A + B made real: + +- `exec.go` — `txExec`: the mutex, `mempoolState`, `gen`, and the codecs/`EncoderCache`. + Both halves execute through `runTxLocked`, so this state belongs to neither alone. +- `admitter.go` — `admitter`: `admit`, `insertTxHandler`, `checkTxHandler`, `cacheTx`. +- `scheduler.go` — `recheckScheduler`: staging (`stageRecheckSenders`, + `stageSkippedSenders`, `drainStaging`), `selectTxs`/`evictForRecheck`/TTL, + `groupCandidates`/`runGroup`/`runRecheck`, `recheckWorker`, deferred carry. + +`Manager` becomes a thin facade holding `{exec, adm, sched}` and forwarding the public API, +so `app.go` and `MempoolProposalHandler` call sites don't churn. Composition is explicit +rather than embedded: three embedded structs would make field promotion depend on nesting +depth, which is not the kind of thing a lock-order invariant should rest on. Lock order is +unchanged — `recheckMu > txExec.mu > stagingMu`, with `mempoolState.mu` innermost. + +### Phase D — differential tests + +`app/proposal_diff_test.go` seeds two identical pools and runs the fast path +(`MempoolProposalHandler` + `CacheProposalTxVerifier` over a warm encoder cache) against the +default full-ante handler, then pins down where they may disagree. The pooled txs are a +local `diffTx` carrying its own signer, nonce, fee, gas, and timeout height, so no account +keeper or real codec is needed; the ante is a per-case predicate standing in for +`RunTx(PrepareProposal)`. + +What the cases assert: + +- All-valid pool, and a same-sender nonce gap: identical selections and identical pools. The + gap guard lives in `DefaultProposalHandler`'s per-signer sequence tracking, which both + paths share, so a divergence there would be a bug in the wiring. +- Stale nonce, recheck backlog (a whole stale prefix), timeout height: the fast path proposes + txs the ante rejects, and leaves them pooled instead of evicting them mid-proposal — + eviction is recheck's job. The default path drops and evicts them during the proposal. +- baseFee drift: selections match, because the proposal gate replaces the ante's fee check; + only the pool differs, since a gated tx stays pooled for a later block. + +Each divergent case also runs the real `ProcessProposalHandler` over the fast path's +proposal (with a non-empty blocklist, so per-tx validation actually executes) and asserts +ACCEPT: cronos `ProcessProposal` is blocklist-only, so an ante-invalid tx cannot make peers +reject the block — `FinalizeBlock` records it as a failed tx result. + +## Verification (Phase A) + +`go test -tags objstore -mod=mod ./app/... -race`, plus `go build -tags objstore -mod=mod ./...`. + +- `manager_test.go` / `recheck_test.go`: assert `admit`, `CheckTxHandler`, and `runRecheck` + all pass a non-nil `base`; assert a ReCheck write to `base` is visible to a later `admit` + of a higher-nonce sibling (nonce continuity across the branch); assert refresh swaps + `base` identity and bumps `gen`. +- `recheck_async_test.go`: a pass superseded mid-flight by a newer commit skips its + remaining candidates and the next pass re-covers the staged senders; extend + `TestTriggerRecheck_ConcurrentCommits` to run `admit` concurrently with commit + recheck + under `-race`. +- Local integration: node with `mempool.type=app`, burst of same-sender sequential txs; + confirm nonce continuity across blocks and that committed txs leave the pool; watch + `cronos.mempool.recheck.*` for regressions. + +## Changelog + +``` +- `[mempool]` run recheck and admission on a mempool-owned branched context + ([#NNNN](https://github.com/crypto-org-chain/cronos/pull/NNNN)) +``` + +under `## UNRELEASED` IMPROVEMENTS. From aec5603029d4e14dcb39303dd5d2a7675596f45b Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Thu, 30 Jul 2026 12:12:54 -0400 Subject: [PATCH 10/12] fix(mempool): drop ante nonce-cache entries on cascade/TTL eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/app.go | 16 +++- app/mempool/manager.go | 9 +++ app/mempool/recheck_test.go | 142 +++++++++++++++++++++++++++++++++++- app/mempool/scheduler.go | 62 ++++++++++++---- 4 files changed, 213 insertions(+), 16 deletions(-) diff --git a/app/app.go b/app/app.go index ab873681ba..fab163da79 100644 --- a/app/app.go +++ b/app/app.go @@ -350,6 +350,12 @@ type App struct { senderCache *cache.SenderCache + // anteCache is the EVM ante's per-(sender, nonce) admission cache. Stored on + // App (not just local to setAnteHandler) so mempoolManager's eviction hook + // can share the same instance and delete a stale entry when the mempool + // evicts its tx without ever spending a RunTx on it (cascade/TTL eviction). + anteCache *cache.AnteCache + // unsafe to set for validator, used for testing dummyCheckTx bool } @@ -1296,6 +1302,14 @@ func (app *App) setAnteHandler(txConfig client.TxConfig, mempoolMaxTxs int, blac blockedMap[addr.String()] = struct{}{} } blockAddressDecorator := NewBlockAddressesDecorator(blockedMap, app.CronosKeeper.GetParams) + app.anteCache = cache.NewAnteCache(mempoolMaxTxs) + // mempoolManager (built earlier, inside the SetMempool baseAppOptions + // closure applied by NewBaseApp) is already set on app by this point, so + // wiring the eviction hook here can share app.anteCache with the ante + // options below rather than each holding a separate instance. + if app.mempoolManager != nil { + app.mempoolManager.SetEvictionHook(app.anteCache.Delete) + } options := evmante.HandlerOptions{ AccountKeeper: app.AccountKeeper, BankKeeper: app.BankKeeper, @@ -1315,7 +1329,7 @@ func (app *App) setAnteHandler(txConfig client.TxConfig, mempoolMaxTxs int, blac }, ExtraDecorators: []sdk.AnteDecorator{blockAddressDecorator}, PendingTxListener: app.onPendingTx, - AnteCache: cache.NewAnteCache(mempoolMaxTxs), + AnteCache: app.anteCache, SenderCache: app.senderCache, } diff --git a/app/mempool/manager.go b/app/mempool/manager.go index d44d0bbeb4..a2ef4b5351 100644 --- a/app/mempool/manager.go +++ b/app/mempool/manager.go @@ -84,6 +84,15 @@ func (a *Manager) SetPreVerify(fn func([]byte) error) { a.adm.preVerify = fn } +// SetEvictionHook registers a callback invoked with the (sender, nonce) of +// every pool eviction, including cascade and TTL evictions that never spend a +// RunTx on the evicted tx. Lets the caller drop App-level state keyed on the +// same pair (e.g. ethermint's ante nonce cache) that would otherwise outlive +// the pool entry it was tracking. +func (a *Manager) SetEvictionHook(fn func(sender string, nonce uint64)) { + a.sched.evictionHook = fn +} + func (a *Manager) InsertTxHandler() sdk.InsertTxHandler { return a.adm.insertTxHandler() } diff --git a/app/mempool/recheck_test.go b/app/mempool/recheck_test.go index 8707ebfea8..bca4a32287 100644 --- a/app/mempool/recheck_test.go +++ b/app/mempool/recheck_test.go @@ -1698,9 +1698,149 @@ func TestEvict_KeyBasedRemovalDropsReplacementNotStaleTx(t *testing.T) { t.Fatal("precondition: fee bump must replace the original nonce-0 entry") } - f.a.sched.evict(stale) + f.a.sched.evict(stale, "", 0, false) if poolHas(f.pool, replacement) { t.Fatal("key-based Remove must drop whatever occupies (alice, 0) now, i.e. the replacement") } } + +// evictionRecorder is a fake eviction hook recording every (sender, nonce) +// it's invoked with, for asserting the scheduler notifies eviction even when +// it never spends a RunTx on the evicted tx (cascade and TTL evictions). +type evictionRecorder struct { + mu sync.Mutex + calls []struct { + sender string + nonce uint64 + } +} + +func (r *evictionRecorder) hook(sender string, nonce uint64) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, struct { + sender string + nonce uint64 + }{sender, nonce}) +} + +func (r *evictionRecorder) has(sender string, nonce uint64) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, c := range r.calls { + if c.sender == sender && c.nonce == nonce { + return true + } + } + return false +} + +// F1: a cascade-evicted sibling never spends a RunTx, so the eviction hook is +// the only signal available for dropping its App-level ante state (e.g. +// ethermint's per-tx nonce cache). +func TestEvictionHook_InvokedOnCascadeEviction(t *testing.T) { + f := newRecheckFixture() + valid := f.add(1, "carl", 5, carlSeq5Bytes) + gapped := f.add(2, "carl", 7, carlSeq7Bytes) + higher := f.add(3, "carl", 8, carlSeq8Bytes) + f.runner.failErrs = map[string]error{carlSeq7Bytes: errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} + + rec := &evictionRecorder{} + f.a.sched.evictionHook = rec.hook + + f.a.sched.runRecheck(f.a.sched.groupCandidates([]sdk.Tx{valid, gapped, higher}), f.a.exec.gen.Load()) + + carl := sdk.AccAddress("carl").String() + if !rec.has(carl, 7) { + t.Fatal("eviction hook must fire for the gapped candidate's own eviction") + } + if !rec.has(carl, 8) { + t.Fatal("eviction hook must fire for a cascade-evicted sibling, which never spends a RunTx") + } + if rec.has(carl, 5) { + t.Fatal("eviction hook must not fire for a candidate that passed recheck") + } +} + +// F1: a TTL eviction never spends a RunTx either, so it needs the same hook. +func TestEvictionHook_InvokedOnTTLEviction(t *testing.T) { + f := newRecheckFixture() + f.a.sched.ttlNumBlocks = 5 + aged := f.add(1, "alice", 3, "alice-3") + + f.a.sched.lastCommittedHeight = 10 + f.a.sched.RecheckTxs() // first sighting: records arrival, tx survives + + rec := &evictionRecorder{} + f.a.sched.evictionHook = rec.hook + + f.a.sched.lastCommittedHeight = 15 // 15-10 == ttl -> evicted + f.a.sched.RecheckTxs() + + if poolHas(f.pool, aged) { + t.Fatal("precondition: TTL-aged tx must be evicted") + } + if !rec.has(sdk.AccAddress("alice").String(), 3) { + t.Fatal("eviction hook must fire for a TTL eviction, which never spends a RunTx") + } +} + +// F2: cascadeChunkLocked's chunk head can fail for a reason other than a +// nonce error (e.g. insufficient funds), which carries no information about +// whether the previous chunk's proven gap survived. Blindly cascading the +// rest of the chunk in that case could evict a candidate that is actually the +// account's next expected nonce, so a non-nonce head failure must fall +// through to a per-candidate recheck instead. +func TestRunGroup_CascadeChunkNonNonceHeadFailureFallsThroughToPerCandidateRecheck(t *testing.T) { + const n = recheckChunkSize + const total = n + 2 // chunk 1 = [0, n); chunk 2 = [n, n+2) + f := newRecheckFixture() + + seqOf := func(i int) uint64 { + switch { + case i < n-1: + return uint64(i) + case i == n-1: + return uint64(n) + 3 // opens the gap chunk 1 proves + default: + return uint64(n) + 3 + uint64(i-(n-1)) // chunk 2 continues ascending past the gap + } + } + bz := func(i int) string { return "dave-" + strconv.Itoa(i) } + txs := make([]sdk.Tx, total) + ptrTxs := make([]*ptrTx, total) + for i := 0; i < total; i++ { + ptrTxs[i] = f.add(i+1, "dave", seqOf(i), bz(i)) + txs[i] = ptrTxs[i] + } + f.runner.failErrs = map[string]error{ + bz(n - 1): errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap"), // chunk 1 proves the gap + bz(n): errorsmod.Wrap(sdkerrors.ErrInsufficientFunds, "no funds"), // chunk 2's head fails, but not on a nonce error + } + + groups := f.a.sched.groupCandidates(txs) + if len(groups) != 1 || !groups[0].cascadable { + t.Fatalf("expected 1 cascadable group, got %+v", groups) + } + + evicted, cascaded, unreachedFrom := f.a.sched.runGroup(groups[0], f.a.exec.gen.Load()) + if unreachedFrom != -1 { + t.Fatalf("expected the whole group reached, got unreachedFrom=%d", unreachedFrom) + } + if evicted != 2 { + t.Fatalf("expected 2 direct evictions (the gapped tx and chunk 2's failing head), got %v", evicted) + } + if cascaded != 0 { + t.Fatalf("a non-nonce head failure must not blind-cascade the rest of the chunk, got %v", cascaded) + } + if !f.runner.seen[bz(n+1)] { + t.Fatal("the candidate after a non-nonce head failure must still spend its own RunTx, not be blind-evicted") + } + if !poolHas(f.pool, ptrTxs[n+1]) { + t.Fatal("that candidate passed recheck and must survive") + } + if poolHas(f.pool, ptrTxs[n-1]) || poolHas(f.pool, ptrTxs[n]) { + t.Fatal("the originally gapped tx and chunk 2's failing head must both be evicted") + } +} diff --git a/app/mempool/scheduler.go b/app/mempool/scheduler.go index 579106c95b..8a52f3e98e 100644 --- a/app/mempool/scheduler.go +++ b/app/mempool/scheduler.go @@ -53,6 +53,10 @@ type recheckScheduler struct { // recheckDisabled mirrors mempool.recheck=false: skips all rechecking, // including TTL/expiry eviction recheckDisabled bool + // evictionHook, if set, is notified of every pool eviction's (sender, nonce) + // so App-level state keyed on the same pair (e.g. ethermint's ante nonce + // cache) can be dropped along with it. Nil-safe: a nil hook is a no-op. + evictionHook func(sender string, nonce uint64) } // recheckDecodingEnabled reports whether sender decoding/bookkeeping should run. @@ -262,12 +266,21 @@ func (s *recheckScheduler) selectTxs(snapshot []sdk.Tx, recheckSenders map[strin // evictForRecheck evicts tx and folds its signers into recheckSenders, allocating // evictedSet/recheckSenders lazily so a no-eviction cycle stays alloc-free. func (s *recheckScheduler) evictForRecheck(tx sdk.Tx, evictedSet map[sdk.Tx]struct{}, recheckSenders map[string]struct{}) (map[sdk.Tx]struct{}, map[string]struct{}) { - s.evict(tx) + // firstSigner already does the GetSigners lookup this needs for the eviction + // hook; reuse it for the single-signer case below instead of calling + // s.signers (a second GetSigners) just to get the same one key back. + key, seq, known, multiSigner := s.firstSigner(tx) + s.evict(tx, key, seq, known) if evictedSet == nil { evictedSet = make(map[sdk.Tx]struct{}) } evictedSet[tx] = struct{}{} - sigs := s.signers(tx) + var sigs []string + if multiSigner { + sigs = s.signers(tx) + } else if known { + sigs = []string{key} + } if len(sigs) > 0 && recheckSenders == nil { recheckSenders = make(map[string]struct{}) } @@ -321,8 +334,13 @@ type recheckCandidate struct { // dropped on encode error — because the cascade rule reasons about the next // expected nonce. type recheckGroup struct { - key string - txs []recheckCandidate + key string + txs []recheckCandidate + // known reports whether key identifies a real signer, set once at group + // creation and never flipped back — unlike cascadable, which also turns + // false for reasons unrelated to identity (multi-signer, unordered, + // duplicate seq). The eviction hook needs known, not cascadable. + known bool cascadable bool } @@ -386,7 +404,7 @@ func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { key, seq, known, multiSigner := s.firstSigner(tx) gi, seen := index[key] if !seen { - groups = append(groups, recheckGroup{key: key, cascadable: known}) + groups = append(groups, recheckGroup{key: key, known: known, cascadable: known}) gi = len(groups) - 1 index[key] = gi } @@ -497,13 +515,13 @@ func (s *recheckScheduler) runCandidatesLocked(g recheckGroup, start, end int, c cursor = nonceCursor{last: c.seq, ok: true} continue } - s.evict(c.tx) + s.evict(c.tx, g.key, c.seq, g.known) evicted++ // A gap is only provable relative to a nonce this pass just accepted; // without one the failure may be a stale nonce, whose successor is valid. if g.cascadable && cursor.ok && c.seq > cursor.last+1 && isNonceErr(err) { for _, rest := range g.txs[i+1 : end] { - s.evict(rest.tx) + s.evict(rest.tx, g.key, rest.seq, g.known) cascaded++ } return evicted, cascaded, cursor, true @@ -536,9 +554,13 @@ func (s *recheckScheduler) recheckChunkLocked(g recheckGroup, start, end int, ge // RunTx before anything is blind-evicted: if it succeeds, the gap didn't // survive to this chunk, and the remainder falls back to normal // recheckChunkLocked semantics, seeded from the head's now-accepted nonce. If -// it fails — for a nonce reason or otherwise, any failure means the gap held -// — the head and the rest of the chunk are cascade-evicted as before, without -// a RunTx on the rest. +// it fails on a nonce error, the gap held, and the head plus the rest of the +// chunk are cascade-evicted without a RunTx on the rest, same as before. Any +// other failure (e.g. insufficient funds) carries no information about +// whether the gap survived — the EVM ante checks balance/gas before nonce, so +// a funds failure at the head says nothing about the account's true nonce +// state — so that case falls through to recheckChunkLocked's normal per-tx +// semantics for the rest of the chunk instead of assuming the gap held. func (s *recheckScheduler) cascadeChunkLocked(g recheckGroup, start, end int, gen uint64) (evicted, cascaded float32, next nonceCursor, gapFound, ok bool) { s.exec.mu.Lock() defer s.exec.mu.Unlock() @@ -553,9 +575,17 @@ func (s *recheckScheduler) cascadeChunkLocked(g recheckGroup, start, end int, ge return evicted, cascaded, next, gapFound, true } - s.evict(head.tx) + s.evict(head.tx, g.key, head.seq, g.known) + if !isNonceErr(err) { + // No cursor context to carry over: we don't know the account's true + // 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 + } + for _, rest := range g.txs[start+1 : end] { - s.evict(rest.tx) + s.evict(rest.tx, g.key, rest.seq, g.known) cascaded++ } return 1, cascaded, nonceCursor{}, true, true @@ -639,10 +669,14 @@ func txTTLExpired(arrival map[sdk.Tx]int64, tx sdk.Tx, height, ttlNumBlocks int6 } // evict removes tx from the pool and encoder cache together, so the cache never -// outlives its pool entry. -func (s *recheckScheduler) evict(tx sdk.Tx) { +// outlives its pool entry, then notifies evictionHook (if set and sender is +// known) so App-level state keyed on (sender, nonce) is dropped along with it. +func (s *recheckScheduler) evict(tx sdk.Tx, sender string, nonce uint64, known bool) { _ = s.mpool.Remove(tx) s.exec.encCache.Evict(tx) + if known && s.evictionHook != nil { + s.evictionHook(sender, nonce) + } } // firstSigner returns the signer the mempool orders by, with its nonce, and From 4454f57ca9c33f6ade8e80a47a16a34abb3e176b Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Thu, 30 Jul 2026 12:28:20 -0400 Subject: [PATCH 11/12] fix(mempool): fire eviction hook for every signer a multi-signer tx names 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. --- app/mempool/manager.go | 11 +++--- app/mempool/recheck_test.go | 54 ++++++++++++++++++++++++++- app/mempool/scheduler.go | 73 +++++++++++++++++++++++++------------ 3 files changed, 108 insertions(+), 30 deletions(-) diff --git a/app/mempool/manager.go b/app/mempool/manager.go index a2ef4b5351..54b204d67e 100644 --- a/app/mempool/manager.go +++ b/app/mempool/manager.go @@ -84,11 +84,12 @@ func (a *Manager) SetPreVerify(fn func([]byte) error) { a.adm.preVerify = fn } -// SetEvictionHook registers a callback invoked with the (sender, nonce) of -// every pool eviction, including cascade and TTL evictions that never spend a -// RunTx on the evicted tx. Lets the caller drop App-level state keyed on the -// same pair (e.g. ethermint's ante nonce cache) that would otherwise outlive -// the pool entry it was tracking. +// SetEvictionHook registers a callback invoked once per (sender, nonce) named +// by every pool eviction — every signer of a multi-signer tx, not just one +// per tx — including cascade and TTL evictions that never spend a RunTx on +// the evicted tx. Lets the caller drop App-level state keyed on the same pair +// (e.g. ethermint's ante nonce cache) that would otherwise outlive the pool +// entry it was tracking. func (a *Manager) SetEvictionHook(fn func(sender string, nonce uint64)) { a.sched.evictionHook = fn } diff --git a/app/mempool/recheck_test.go b/app/mempool/recheck_test.go index bca4a32287..afbe5f29ab 100644 --- a/app/mempool/recheck_test.go +++ b/app/mempool/recheck_test.go @@ -1698,7 +1698,7 @@ func TestEvict_KeyBasedRemovalDropsReplacementNotStaleTx(t *testing.T) { t.Fatal("precondition: fee bump must replace the original nonce-0 entry") } - f.a.sched.evict(stale, "", 0, false) + f.a.sched.evict(stale, "", 0, false, false) if poolHas(f.pool, replacement) { t.Fatal("key-based Remove must drop whatever occupies (alice, 0) now, i.e. the replacement") @@ -1763,6 +1763,58 @@ func TestEvictionHook_InvokedOnCascadeEviction(t *testing.T) { } } +// M1: a multi-signer tx caches App-level ante state for every signer it +// names, not just the group's key signer, so a cascade-evicted multi-signer +// sibling (which never spends a RunTx) must fire the hook once per named +// signer. groupCandidates itself would force cascadable=false on any group +// holding a multi-signer candidate (see its coSigned handling), so the group +// here is built by hand to exercise the cascade-blind-eviction path directly. +func TestEvictionHook_CascadeEvictionFiresForEveryMultiSignerSigner(t *testing.T) { + f := newRecheckFixture() + bob := sdk.AccAddress("bob").String() + alice := sdk.AccAddress("alice").String() + + validTx := &ptrTx{id: 1} + gappedTx := &ptrTx{id: 2} + higherTx := &ptrTx{id: 3} // bob's tx also names alice as a co-signer at seq 3 + f.signer.m[validTx] = []sdkmempool.SignerData{sdkmempool.NewSignerData(sdk.AccAddress("bob"), 5)} + f.signer.m[gappedTx] = []sdkmempool.SignerData{sdkmempool.NewSignerData(sdk.AccAddress("bob"), 7)} + f.signer.m[higherTx] = []sdkmempool.SignerData{ + sdkmempool.NewSignerData(sdk.AccAddress("bob"), 8), + sdkmempool.NewSignerData(sdk.AccAddress("alice"), 3), + } + f.runner.failErrs = map[string]error{"gapped": errorsmod.Wrap(sdkerrors.ErrWrongSequence, "gap")} + + group := recheckGroup{ + key: bob, + known: true, + cascadable: true, + txs: []recheckCandidate{ + {tx: validTx, bz: []byte("valid"), seq: 5}, + {tx: gappedTx, bz: []byte("gapped"), seq: 7}, + {tx: higherTx, bz: []byte("higher"), seq: 8, multiSigner: true}, + }, + } + + rec := &evictionRecorder{} + f.a.sched.evictionHook = rec.hook + + f.a.sched.runRecheck([]recheckGroup{group}, f.a.exec.gen.Load()) + + if !rec.has(bob, 7) { + t.Fatal("eviction hook must fire for the gapped candidate's own eviction") + } + if !rec.has(bob, 8) { + t.Fatal("eviction hook must fire for the cascade-evicted multi-signer sibling's key signer") + } + if !rec.has(alice, 3) { + t.Fatal("eviction hook must also fire for the co-signer named by the cascade-evicted multi-signer sibling") + } + if rec.has(bob, 5) { + t.Fatal("eviction hook must not fire for a candidate that passed recheck") + } +} + // F1: a TTL eviction never spends a RunTx either, so it needs the same hook. func TestEvictionHook_InvokedOnTTLEviction(t *testing.T) { f := newRecheckFixture() diff --git a/app/mempool/scheduler.go b/app/mempool/scheduler.go index 8a52f3e98e..ef4bd1fa17 100644 --- a/app/mempool/scheduler.go +++ b/app/mempool/scheduler.go @@ -53,9 +53,10 @@ type recheckScheduler struct { // recheckDisabled mirrors mempool.recheck=false: skips all rechecking, // including TTL/expiry eviction recheckDisabled bool - // evictionHook, if set, is notified of every pool eviction's (sender, nonce) - // so App-level state keyed on the same pair (e.g. ethermint's ante nonce - // cache) can be dropped along with it. Nil-safe: a nil hook is a no-op. + // evictionHook, if set, is notified once per (sender, nonce) named by an + // evicted tx — every signer for a multi-signer tx, not just the group's key + // signer — so App-level state keyed on the same pair (e.g. ethermint's ante + // nonce cache) can be dropped along with it. Nil-safe: a nil hook is a no-op. evictionHook func(sender string, nonce uint64) } @@ -270,7 +271,7 @@ func (s *recheckScheduler) evictForRecheck(tx sdk.Tx, evictedSet map[sdk.Tx]stru // hook; reuse it for the single-signer case below instead of calling // s.signers (a second GetSigners) just to get the same one key back. key, seq, known, multiSigner := s.firstSigner(tx) - s.evict(tx, key, seq, known) + s.evict(tx, key, seq, known, multiSigner) if evictedSet == nil { evictedSet = make(map[sdk.Tx]struct{}) } @@ -324,6 +325,10 @@ type recheckCandidate struct { tx sdk.Tx bz []byte seq uint64 + // multiSigner mirrors firstSigner's flag from group build time, so evict + // knows to fire the hook for every named signer without a second GetSigners + // call on the hot recheck path. + multiSigner bool } // recheckGroup holds one signer's candidates sorted ascending by seq. @@ -425,7 +430,7 @@ func (s *recheckScheduler) groupCandidates(candidates []sdk.Tx) []recheckGroup { g.cascadable = false continue } - g.txs = append(g.txs, recheckCandidate{tx: tx, bz: bz, seq: seq}) + g.txs = append(g.txs, recheckCandidate{tx: tx, bz: bz, seq: seq, multiSigner: multiSigner}) } for i := range groups { g := &groups[i] @@ -515,13 +520,13 @@ func (s *recheckScheduler) runCandidatesLocked(g recheckGroup, start, end int, c cursor = nonceCursor{last: c.seq, ok: true} continue } - s.evict(c.tx, g.key, c.seq, g.known) + s.evict(c.tx, g.key, c.seq, g.known, c.multiSigner) evicted++ // A gap is only provable relative to a nonce this pass just accepted; // without one the failure may be a stale nonce, whose successor is valid. if g.cascadable && cursor.ok && c.seq > cursor.last+1 && isNonceErr(err) { for _, rest := range g.txs[i+1 : end] { - s.evict(rest.tx, g.key, rest.seq, g.known) + s.evict(rest.tx, g.key, rest.seq, g.known, rest.multiSigner) cascaded++ } return evicted, cascaded, cursor, true @@ -575,7 +580,7 @@ func (s *recheckScheduler) cascadeChunkLocked(g recheckGroup, start, end int, ge return evicted, cascaded, next, gapFound, true } - s.evict(head.tx, g.key, head.seq, g.known) + s.evict(head.tx, g.key, head.seq, g.known, head.multiSigner) if !isNonceErr(err) { // No cursor context to carry over: we don't know the account's true // nonce state, so run the rest of the chunk one RunTx at a time instead @@ -585,7 +590,7 @@ func (s *recheckScheduler) cascadeChunkLocked(g recheckGroup, start, end int, ge } for _, rest := range g.txs[start+1 : end] { - s.evict(rest.tx, g.key, rest.seq, g.known) + s.evict(rest.tx, g.key, rest.seq, g.known, rest.multiSigner) cascaded++ } return 1, cascaded, nonceCursor{}, true, true @@ -669,13 +674,26 @@ func txTTLExpired(arrival map[sdk.Tx]int64, tx sdk.Tx, height, ttlNumBlocks int6 } // evict removes tx from the pool and encoder cache together, so the cache never -// outlives its pool entry, then notifies evictionHook (if set and sender is -// known) so App-level state keyed on (sender, nonce) is dropped along with it. -func (s *recheckScheduler) evict(tx sdk.Tx, sender string, nonce uint64, known bool) { +// outlives its pool entry, then notifies evictionHook (if set) once per signer +// named by tx. A multi-signer tx caches App-level ante state per signer it +// names (e.g. ethermint stages one nonce-cache entry per msg), so the hook +// must fire once per named signer, not just the group's key signer — hence +// the extra GetSigners call here rather than reusing sender/nonce, but only +// on this eviction path, not the hot recheck pass. +func (s *recheckScheduler) evict(tx sdk.Tx, sender string, nonce uint64, known, multiSigner bool) { _ = s.mpool.Remove(tx) s.exec.encCache.Evict(tx) - if known && s.evictionHook != nil { - s.evictionHook(sender, nonce) + if s.evictionHook == nil { + return + } + if !multiSigner { + if known { + s.evictionHook(sender, nonce) + } + return + } + for _, sg := range s.allSigners(tx) { + s.evictionHook(sg.Signer.String(), sg.Sequence) } } @@ -686,22 +704,16 @@ func (s *recheckScheduler) evict(tx sdk.Tx, sender string, nonce uint64, known b // keyed on the first signer, so a gap in that group may really be filled by // a multi-signer tx grouped elsewhere. func (s *recheckScheduler) firstSigner(tx sdk.Tx) (key string, seq uint64, known, multiSigner bool) { - if s.signer == nil { - return "", 0, false, false - } - sigs, err := s.signer.GetSigners(tx) - if err != nil || len(sigs) == 0 { + sigs := s.allSigners(tx) + if len(sigs) == 0 { return "", 0, false, false } return sigs[0].Signer.String(), sigs[0].Sequence, true, len(sigs) > 1 } func (s *recheckScheduler) signers(tx sdk.Tx) []string { - if s.signer == nil { - return nil - } - sigs, err := s.signer.GetSigners(tx) - if err != nil { + sigs := s.allSigners(tx) + if len(sigs) == 0 { return nil } keys := make([]string, len(sigs)) @@ -710,3 +722,16 @@ func (s *recheckScheduler) signers(tx sdk.Tx) []string { } return keys } + +// allSigners returns every signer GetSigners names for tx, nil-safe on both a +// nil signer extractor and a lookup error. +func (s *recheckScheduler) allSigners(tx sdk.Tx) []sdkmempool.SignerData { + if s.signer == nil { + return nil + } + sigs, err := s.signer.GetSigners(tx) + if err != nil { + return nil + } + return sigs +} From 9c31f9ed7b6332d704086a92d501fa63c335c798 Mon Sep 17 00:00:00 2001 From: "jay.tseng" Date: Thu, 30 Jul 2026 13:36:12 -0400 Subject: [PATCH 12/12] docs(changelog): add entry for mempool-owned branched context PR #2159 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d77c64805..8bac61408b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ *Jul 16, 2026* +### Improvements + +* [#2159](https://github.com/crypto-org-chain/cronos/pull/2159) mempool-owned branched context for admission + recheck + ## v1.8.0-alpha ### Improvements