Skip to content

Commit 534d484

Browse files
refactor(requester): decompose collectDueBatches into focused helpers
Audit finding M1: collectDueBatches was ~145 lines spanning five responsibilities (idle eviction, due/spacing gating, frontier refresh + stale prune, consecutive-prefix collection, TTL-expiry collection, metrics). Split it into: - collectDueBatches: lock + iterate + reportSize - collectQueue: the per-EOA gating + at-most-one-batch decision - collectPrefix: the consecutive-prefix path - collectExpired: the TTL submit-anyway path - reportSize: the memory-footprint metric The prefix-vs-TTL asymmetry (prefix marks submitting + re-arms windows; TTL does neither) is now visible as two sibling functions instead of being implied by a continue 80 lines down. Pure behavior-preserving refactor; unit + timing tests pass unchanged, race-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 573312f commit 534d484

1 file changed

Lines changed: 152 additions & 118 deletions

File tree

services/requester/tx_mempool.go

Lines changed: 152 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -759,145 +759,179 @@ func (t *TxMemPool) reconcileSubmission(w flushWork, submitErr error) {
759759
q.nonces.markSubmitted(highNonce)
760760
}
761761

762-
// collectDueBatches selects, under the queue lock, every batch that is due
763-
// for submission, updates the queue state optimistically, and returns the
764-
// detached work items.
762+
// collectDueBatches selects, under the queue lock, every batch that is due for
763+
// submission, updates the queue state optimistically, and returns the detached
764+
// work items. The per-EOA decision lives in collectQueue.
765+
//
766+
// Each due EOA's nonce is read via GetNonce; the provider caches the block view
767+
// by indexed height, so all reads in this pass (and across ticks at the same
768+
// height) reuse one built view rather than rebuilding per address.
765769
func (t *TxMemPool) collectDueBatches() []flushWork {
766770
t.queueMux.Lock()
767771
defer t.queueMux.Unlock()
768772

769773
now := t.now()
770774
work := make([]flushWork, 0, len(t.queues))
771-
772-
// Each due EOA's nonce is read via GetNonce; the provider caches the block
773-
// view by indexed height, so all reads in this pass (and across ticks at the
774-
// same height) reuse one built view rather than rebuilding per address.
775775
for from, q := range t.queues {
776-
if q.isEmpty() {
777-
// Bound memory: drop queues with no held txs and no activity past
778-
// the retention period. Any in-flight submission has long since
779-
// resolved on-chain after this window, so discarding a lingering
780-
// in-flight marker here is safe — a later transaction for the EOA
781-
// creates a fresh queue and re-reads the index nonce.
782-
if now.Sub(q.lastActivity) > idleQueueRetention {
783-
delete(t.queues, from)
784-
}
785-
continue
776+
if w, ok := t.collectQueue(from, q, now); ok {
777+
work = append(work, w)
786778
}
779+
}
780+
t.reportSize()
781+
return work
782+
}
787783

788-
// Not due yet: both the sliding window and the flush deadline are
789-
// still in the future.
790-
if now.Before(q.collectionWindowEndsAt) && now.Before(q.flushDeadline) {
791-
continue
784+
// collectQueue evaluates one EOA's queue and returns its due batch, if any. It
785+
// applies the gating checks (idle eviction, due-time, submission spacing, a
786+
// fresh frontier read + stale prune) and then collects at most one batch:
787+
// the consecutive prefix takes precedence, and only when a gap at the head
788+
// blocks it does the TTL-expiry path run. The post-gap / over-cap remainder is
789+
// left in the queue for a later tick — never merged with this batch, since a
790+
// head gap would fail the whole Flow transaction. Callers must hold queueMux;
791+
// it may evict an idle empty queue as a side effect.
792+
func (t *TxMemPool) collectQueue(
793+
from gethCommon.Address,
794+
q *eoaQueue,
795+
now time.Time,
796+
) (flushWork, bool) {
797+
if q.isEmpty() {
798+
// Bound memory: drop queues with no held txs and no activity past the
799+
// retention period. Any in-flight submission has long since resolved
800+
// on-chain after this window, so discarding a lingering in-flight marker
801+
// here is safe — a later transaction for the EOA creates a fresh queue
802+
// and re-reads the index nonce.
803+
if now.Sub(q.lastActivity) > idleQueueRetention {
804+
delete(t.queues, from)
792805
}
806+
return flushWork{}, false
807+
}
793808

794-
// Safety gap since the previous submission not yet elapsed.
795-
if !t.spacingElapsed(q, now) {
796-
continue
797-
}
809+
// Not due yet: both the sliding window and the flush deadline are still in
810+
// the future.
811+
if now.Before(q.collectionWindowEndsAt) && now.Before(q.flushDeadline) {
812+
return flushWork{}, false
813+
}
798814

799-
indexNonce, err := t.nonceProvider.GetNonce(from)
800-
if err != nil {
801-
// Exception: a local state-index nonce read should not fail
802-
// under normal operation. This is a background loop with no
803-
// caller to reject the tx to, so skip this EOA for the current
804-
// tick (its batch is deferred until the read succeeds) without
805-
// aborting the whole flush for other EOAs.
806-
t.logger.Error().Err(err).Str("eoa", from.Hex()).
807-
Msg("unexpected failure reading nonce from local index, skipping EOA this tick")
808-
continue
809-
}
815+
// Safety gap since the previous submission not yet elapsed.
816+
if !t.spacingElapsed(q, now) {
817+
return flushWork{}, false
818+
}
810819

811-
q.nonces.refreshIndexed(indexNonce)
812-
813-
// Prune transactions that can never execute: their nonce is already
814-
// used on-chain (e.g. filled via another gateway). They would only
815-
// burn fees at TTL expiry.
816-
t.pruneStaleTxs(q, from, indexNonce)
817-
818-
// At most one batch is collected per EOA per tick. The consecutive
819-
// prefix below takes precedence and `continue`s; only when there is no
820-
// eligible prefix (a gap at the head) do we consider the TTL-expiry
821-
// path. The post-gap / over-cap remainder is left in the queue and
822-
// drained on a later tick, gated by submission spacing — it is never
823-
// merged with this batch, since a head gap would make the whole Flow
824-
// transaction fail.
825-
prefix := selectConsecutivePrefix(q.txs, q.nonces.expectedNonce(), t.config.TxMaxBatchSize)
826-
if len(prefix) > 0 {
827-
deleteByNonce(q.txs, prefix)
828-
// Optimistically mark the batch submitting; reconcileSubmission
829-
// advances submitted on success or clears it on failure.
830-
q.nonces.markSubmitting(prefix[len(prefix)-1].nonce)
831-
q.lastSubmittedAt = now
832-
q.lastActivity = now
833-
if !q.isEmpty() {
834-
// Re-arm for the remaining (post-gap or over-cap) txs.
835-
q.collectionWindowEndsAt = now.Add(t.config.TxCollectionWindow)
836-
q.flushDeadline = now.Add(t.config.TxSubmissionSpacing)
837-
}
838-
work = append(work, flushWork{
839-
from: from,
840-
txs: prefix,
841-
inFlight: true,
842-
reason: flushReasonPrefix,
843-
localIndexedNonce: indexNonce,
844-
})
845-
continue
846-
}
820+
indexNonce, err := t.nonceProvider.GetNonce(from)
821+
if err != nil {
822+
// Exception: a local state-index nonce read should not fail under normal
823+
// operation. This is a background loop with no caller to reject the tx
824+
// to, so skip this EOA for the current tick (its batch is deferred until
825+
// the read succeeds) without aborting the whole flush for other EOAs.
826+
t.logger.Error().Err(err).Str("eoa", from.Hex()).
827+
Msg("unexpected failure reading nonce from local index, skipping EOA this tick")
828+
return flushWork{}, false
829+
}
847830

848-
// No eligible prefix (gap at the head). Submit transactions held
849-
// past their TTL anyway instead of dropping them.
850-
//
851-
// Rationale (no silent drops): submitting an unexecutable transaction
852-
// produces a real, observable on-chain failure (operators can see the
853-
// failed Flow transaction and its nonce-mismatch), whereas silently
854-
// dropping it leaves no trace. Avoiding silent drops is the whole reason
855-
// this pool exists, so an observable failure is strictly preferable to an
856-
// invisible drop.
857-
//
858-
// Tradeoff: a tx whose nonce is far ahead of the index is still
859-
// submitted at TTL and burns fees on a guaranteed failure. How far
860-
// ahead a nonce may be is bounded instead at Add time by TxMaxNonceGap
861-
// (see classify); when that is unset (0), far-ahead nonces reach here.
862-
//
863-
// Cap the batch at TxMaxBatchSize so a long-lived gap cannot produce an
864-
// unbounded Flow transaction; the remainder drains on later ticks,
865-
// gated by submission spacing.
866-
expired := selectExpired(q.txs, now, t.config.TxPoolTTL)
867-
if len(expired) > t.config.TxMaxBatchSize {
868-
expired = expired[:t.config.TxMaxBatchSize]
869-
}
870-
if len(expired) > 0 {
871-
deleteByNonce(q.txs, expired)
872-
// Deliberately do NOT mark the nonceTracker submitting here: these
873-
// nonces are out of order (past a gap), and marking them in flight
874-
// would corrupt the expected-nonce computation for future flushes.
875-
q.lastSubmittedAt = now
876-
q.lastActivity = now
877-
t.logger.Warn().Strs("tx-hashes", txHashHexes(expired)).Str("eoa", from.Hex()).
878-
Uint64("local-indexed-nonce", indexNonce).
879-
Uint64("expected-nonce", q.nonces.expectedNonce()).
880-
Msg("nonce gap never filled within TTL, submitting held transactions anyway")
881-
work = append(work, flushWork{
882-
from: from,
883-
txs: expired,
884-
reason: flushReasonTTL,
885-
localIndexedNonce: indexNonce,
886-
})
887-
}
831+
q.nonces.refreshIndexed(indexNonce)
832+
833+
// Prune transactions that can never execute: their nonce is already used
834+
// on-chain (e.g. filled via another gateway). They would only burn fees at
835+
// TTL expiry.
836+
t.pruneStaleTxs(q, from, indexNonce)
837+
838+
if w, ok := t.collectPrefix(from, q, now, indexNonce); ok {
839+
return w, true
840+
}
841+
return t.collectExpired(from, q, now, indexNonce)
842+
}
843+
844+
// collectPrefix detaches the longest consecutive nonce run starting at the
845+
// expected nonce (capped at TxMaxBatchSize), marks it in flight, and re-arms the
846+
// queue for any remainder. Returns false when a gap at the head leaves no
847+
// eligible prefix. Callers must hold queueMux.
848+
func (t *TxMemPool) collectPrefix(
849+
from gethCommon.Address,
850+
q *eoaQueue,
851+
now time.Time,
852+
indexNonce uint64,
853+
) (flushWork, bool) {
854+
prefix := selectConsecutivePrefix(q.txs, q.nonces.expectedNonce(), t.config.TxMaxBatchSize)
855+
if len(prefix) == 0 {
856+
return flushWork{}, false
857+
}
858+
859+
deleteByNonce(q.txs, prefix)
860+
// Optimistically mark the batch submitting; reconcileSubmission advances
861+
// submitted on success or clears it on failure.
862+
q.nonces.markSubmitting(prefix[len(prefix)-1].nonce)
863+
q.lastSubmittedAt = now
864+
q.lastActivity = now
865+
if !q.isEmpty() {
866+
// Re-arm for the remaining (post-gap or over-cap) txs.
867+
q.collectionWindowEndsAt = now.Add(t.config.TxCollectionWindow)
868+
q.flushDeadline = now.Add(t.config.TxSubmissionSpacing)
869+
}
870+
return flushWork{
871+
from: from,
872+
txs: prefix,
873+
inFlight: true,
874+
reason: flushReasonPrefix,
875+
localIndexedNonce: indexNonce,
876+
}, true
877+
}
878+
879+
// collectExpired detaches transactions held past their TTL when a head gap
880+
// blocks the prefix path, submitting them anyway (capped at TxMaxBatchSize)
881+
// rather than dropping them. Returns false when nothing has expired. Callers
882+
// must hold queueMux.
883+
//
884+
// Rationale (no silent drops): submitting an unexecutable transaction produces a
885+
// real, observable on-chain failure (operators can see the failed Flow
886+
// transaction and its nonce-mismatch), whereas silently dropping it leaves no
887+
// trace. Avoiding silent drops is the whole reason this pool exists, so an
888+
// observable failure is strictly preferable to an invisible drop. The batch is
889+
// deliberately NOT marked in flight: these nonces are out of order (past a gap),
890+
// and marking them would corrupt the expected-nonce computation for future
891+
// flushes.
892+
//
893+
// Tradeoff: a tx whose nonce is far ahead of the index is still submitted at TTL
894+
// and burns fees on a guaranteed failure. How far ahead a nonce may be is
895+
// bounded instead at Add time by TxMaxNonceGap (see classify); when that is
896+
// unset (0), far-ahead nonces reach here.
897+
func (t *TxMemPool) collectExpired(
898+
from gethCommon.Address,
899+
q *eoaQueue,
900+
now time.Time,
901+
indexNonce uint64,
902+
) (flushWork, bool) {
903+
expired := selectExpired(q.txs, now, t.config.TxPoolTTL)
904+
if len(expired) > t.config.TxMaxBatchSize {
905+
expired = expired[:t.config.TxMaxBatchSize]
888906
}
907+
if len(expired) == 0 {
908+
return flushWork{}, false
909+
}
910+
911+
deleteByNonce(q.txs, expired)
912+
q.lastSubmittedAt = now
913+
q.lastActivity = now
914+
t.logger.Warn().Strs("tx-hashes", txHashHexes(expired)).Str("eoa", from.Hex()).
915+
Uint64("local-indexed-nonce", indexNonce).
916+
Uint64("expected-nonce", q.nonces.expectedNonce()).
917+
Msg("nonce gap never filled within TTL, submitting held transactions anyway")
918+
return flushWork{
919+
from: from,
920+
txs: expired,
921+
reason: flushReasonTTL,
922+
localIndexedNonce: indexNonce,
923+
}, true
924+
}
889925

890-
// Report the pool's memory footprint while still holding queueMux: the
891-
// number of per-EOA queues and the total number of held transactions.
892-
// Counting must happen under the lock since t.queues is mutated
893-
// concurrently (and idle queues are pruned above).
926+
// reportSize emits the pool's memory footprint: the number of per-EOA queues and
927+
// the total number of held transactions. Callers must hold queueMux, since it
928+
// reads t.queues (mutated concurrently, and pruned during collection).
929+
func (t *TxMemPool) reportSize() {
894930
queuedTxs := 0
895931
for _, q := range t.queues {
896932
queuedTxs += q.size()
897933
}
898934
t.collector.TxPoolSize(len(t.queues), queuedTxs)
899-
900-
return work
901935
}
902936

903937
// pruneStaleTxs removes queued transactions whose nonce is below the current

0 commit comments

Comments
 (0)