Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions sei-tendermint/internal/autobahn/producer/mempool.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,23 @@ func (s *State) getMempool(ctx context.Context) (*mempool, error) {
return mp, nil
}

// preReadEvmNonce reads the app nonce of addr outside the mempool lock, returning None
// when the mempool already tracks addr. It also returns the lane's first block at the
// time of the check, which insertTx uses to detect prunes racing the read.
func (s *State) preReadEvmNonce(mp *mempool, addr common.Address) (utils.Option[uint64], types.BlockNumber, error) {
var first types.BlockNumber
for m := range mp.inner.Lock() {
if m.closed {
return utils.None[uint64](), 0, ErrNotProducing
}
first = m.first
if _, tracked := m.evmNonces[addr]; tracked {
return utils.None[uint64](), first, nil
}
}
return utils.Some(s.evmNonce(addr)), first, nil
}

// checkTx runs the app CheckTx for tx, holding one of cfg.MaxConcurrentCheckTx permits
// for the duration of the call. Waiting for a permit is cancelled with ctx.
func (s *State) checkTx(ctx context.Context, tx tmtypes.Tx) (*abci.ResponseCheckTxV2, error) {
Expand Down Expand Up @@ -376,6 +393,15 @@ func (s *State) doInsertTx(ctx context.Context, tx tmtypes.Tx, waitIfFull bool)
return nil, errTooLarge
}

appNonce := utils.None[uint64]()
var first types.BlockNumber
if resp.IsEVM {
appNonce, first, err = s.preReadEvmNonce(mp, resp.EVMSenderAddress)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The pre-read now runs before the mempool-full check, so TryInsertTx (waitIfFull == false) pays for an app state view on every tx it is about to reject with errMempoolFull. Previously if m.IsFull() && !waitIfFull (line 352) short-circuited before any EvmNonce call, so a saturated mempool rejected cheaply — which is exactly the state the node is in under the load this PR targets.

preReadEvmNonce already holds the lock and can see m.IsFull(), so it could return errMempoolFull for the non-blocking caller and skip the read entirely. That stays correct because IsFull is re-checked under the main lock anyway, so the early answer is only a fast path for a condition that is already best-effort.

if err != nil {
return nil, err
}
}

admitStart := time.Now()
var waited time.Duration
var wakeups int64
Expand Down Expand Up @@ -429,7 +455,7 @@ func (s *State) doInsertTx(ctx context.Context, tx tmtypes.Tx, waitIfFull bool)
}
continue
}
err := s.appendTx(m, ctrl, tx, resp, gasWanted, gasEstimated)
err := s.appendTx(m, ctrl, tx, resp, gasWanted, gasEstimated, appNonce, first)
if t, ok := ticket.Get(); ok {
mp.dequeue(m, t)
}
Expand All @@ -443,12 +469,23 @@ func (s *State) doInsertTx(ctx context.Context, tx tmtypes.Tx, waitIfFull bool)

// appendTx adds an admitted tx to the next lane block, sealing the current one first when the
// tx would exceed one of its limits. Must be called with the mempool locked and not full.
func (s *State) appendTx(m *mempoolInner, ctrl *utils.WatchCtrl, tx tmtypes.Tx, resp *abci.ResponseCheckTxV2, gasWanted, gasEstimated uint64) error {
// appendTx admits tx into the next block. appNonce is the sender's app nonce pre-read
// outside the lock while the lane's first block was first; see preReadEvmNonce.
func (s *State) appendTx(m *mempoolInner, ctrl *utils.WatchCtrl, tx tmtypes.Tx, resp *abci.ResponseCheckTxV2, gasWanted, gasEstimated uint64, appNonce utils.Option[uint64], first types.BlockNumber) error {
if resp.IsEVM {
addr := resp.EVMSenderAddress
nonce, ok := m.evmNonces[addr]
if !ok {
nonce = s.evmNonce(addr)
// The tracked entry, when present, is authoritative: it covers txs already
// sequenced but not yet executed. The pre-read app nonce is used only when
// there is no entry and no block was pruned since it was taken (m.first
// unchanged), since pruning may delete this sender's entry and advance the
// app nonce.
if pre, ok := appNonce.Get(); ok && m.first == first {
nonce = pre
} else {
nonce = s.evmNonce(addr)
}
}
if nonce != resp.EVMNonce {
return fmt.Errorf("%w: got %v, want %v", errBadNonce, resp.EVMNonce, nonce)
Expand Down
77 changes: 77 additions & 0 deletions sei-tendermint/internal/autobahn/producer/mempool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,83 @@ func TestMempool_BadNonce(t *testing.T) {
require.NoError(t, err)
}

func TestInsertTx_NewSenderUsesAppNonce(t *testing.T) {
ctx := t.Context()
rng := utils.TestRng()
app := newTestApp()
env := newTestEnv(rng, app.Cfg(), app.Proxy())
env.alignLocalMempool()
addr, nonce := app.NewAccount(rng)

for _, txNonce := range []uint64{nonce, nonce + 1} {
_, err := env.state.InsertTx(ctx, env.genTx(rng, addr, txNonce).encode())
require.NoError(t, err)
}
require.Equal(t, nonce+2, env.state.EvmNextPendingNonce(addr))
}

func TestInsertTx_ConcurrentSequentialNonces(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The new tests cover the two branches that were already reachable (fresh sender uses the pre-read, tracked entry wins) but not the branch this change actually adds: the m.first != first re-read. With alignLocalMempool and no State.Run, nothing seals or prunes blocks here, and the five senders are disjoint, so the guard is never exercised and a regression that dropped the m.first == first condition would still pass.

Two additions would pin the new behavior: force a prune between the pre-read and the lock (a test app whose EvmNonce blocks until pruneMempool has advanced m.first and bumped the app nonce) and assert the refreshed value is used; and assert the actual goal of the PR by holding EvmNonce open and checking another InsertTx/EvmNextPendingNonce still completes.

Also note TestInsertTx_BadNonceRejected duplicates TestMempool_BadNonce (line 348) except for the replay case — folding the replay assertions into the existing test would avoid two tests to keep in sync.

ctx := t.Context()
rng := utils.TestRng()
app := newTestApp()
env := newTestEnv(rng, app.Cfg(), app.Proxy())
env.alignLocalMempool()

const (
accountCount = 5
txCount = 20
)
type account struct {
addr common.Address
start uint64
rng utils.Rng
}
accounts := make([]account, accountCount)
for i := range accounts {
accounts[i] = account{rng: rng.Split()}
accounts[i].addr, accounts[i].start = app.NewAccount(rng)
}

require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error {
for _, account := range accounts {
s.Spawn(func() error {
for nonce := account.start; nonce < account.start+txCount; nonce++ {
if _, err := env.state.InsertTx(ctx, env.genTx(account.rng, account.addr, nonce).encode()); err != nil {
return fmt.Errorf("InsertTx(): %w", err)
}
}
return nil
})
}
return nil
}))

for _, account := range accounts {
require.Equal(t, account.start+txCount, env.state.EvmNextPendingNonce(account.addr))
}
}

func TestInsertTx_BadNonceRejected(t *testing.T) {
ctx := t.Context()
rng := utils.TestRng()
app := newTestApp()
env := newTestEnv(rng, app.Cfg(), app.Proxy())
env.alignLocalMempool()
addr, nonce := app.NewAccount(rng)

for _, txNonce := range []uint64{nonce - 1, nonce + 1} {
_, err := env.state.InsertTx(ctx, env.genTx(rng, addr, txNonce).encode())
require.ErrorIs(t, err, errBadNonce)
}
_, err := env.state.InsertTx(ctx, env.genTx(rng, addr, nonce).encode())
require.NoError(t, err)

for _, txNonce := range []uint64{nonce, nonce + 2} {
_, err := env.state.InsertTx(ctx, env.genTx(rng, addr, txNonce).encode())
require.ErrorIs(t, err, errBadNonce)
}
}

type blockStats struct {
count uint64
sizeBytes uint64
Expand Down
Loading