@@ -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.
765769func (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