Skip to content

Tx submission V2 bounded sweep - #5458

Draft
karknu wants to merge 13 commits into
mainfrom
karknu/txsub-bounded-sweep
Draft

karknu wants to merge 13 commits into
mainfrom
karknu/txsub-bounded-sweep

Conversation

@karknu

@karknu karknu commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Description

TX-submission improvement based on leios testnet experience.

  • Limit pipelining by only requesting more txids when there is something to ack
  • Limit the work done during a sweep. Instead of sweeping the entire shared state in one STM transaction the work is split into different transactions with at most 1000 entries each.
  • Read tx counters in smaller STM transactions
  • Split inbound peers into two tiers. Fast peers are polled for fresh txid's at speed, slow peers are polled at most about two times per second.
  • Reject duplicate tx bodies in replies
  • A batch's accepted TXs shouldn't offset the batch's rejected TXs.
  • Park straight after a decision with nothing to do
  • More counters

Checklist

Quality

  • Commit sequence makes sense and have useful messages, see ref.
  • New tests are added and existing tests are updated.
  • Self-reviewed the PR.

Maintenance

  • Linked an issue or added the PR to the current sprint of ouroboros-network project.
  • Added labels.
  • Updated changelog files.
  • The documentation has been properly updated, see ref.

@github-project-automation github-project-automation Bot moved this to In Progress in Ouroboros Network Oct 19, 2026
@karknu karknu self-assigned this Oct 19, 2026
@karknu karknu added tx-submission Issues related to tx-submission protocol leios Issues / PRs related to Leios labels Oct 19, 2026
@karknu
karknu force-pushed the karknu/txsub-bounded-sweep branch 6 times, most recently from 063846c to a91a62b Compare October 24, 2026 00:20
@karknu
karknu force-pushed the karknu/txsub-bounded-sweep branch 5 times, most recently from 7b41d68 to 77e3e7f Compare November 2, 2026 07:25
@karknu
karknu force-pushed the karknu/txsub-bounded-sweep branch 8 times, most recently from dee0432 to 75d6384 Compare November 11, 2026 07:29
karknu and others added 6 commits November 11, 2026 16:37
V2 pipelined a txid request whenever the window had room, so a peer
whose txids were all unacked and unresolved was polled with ack=0,
req=N messages, and under load that polling cost a relay most of its
CPU. A pipelined txid request now both acks and requests: with nothing
to ack the peer waits for its unacked txids to resolve. This is stricter
than V1, which pipelines such a poll after a body request while the
window has room. The disablePipelinedTxIdRequests flag goes, and with
it the guard that held a pipelined request after an empty reply, which
the rule leaves nothing to fire on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The sim-benchmarks never built a large retention queue or swept one with
expired entries waiting, so nothing measured the sweep a relay runs when it
falls behind. Bench.TxSubmissionV2Sweep builds queues of 10k and 100k
retained transactions and times inserting them, and one sweep of 100k with
none, 1k, 10k and all of them past their deadline.

At this commit one sweep of 100k with all expired takes about 20 ms, against
0.7 ms with none: a single STM transaction that long rarely commits beside
peers writing the shared state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The counters thread swept the shared tx state in one STM transaction that
expired every retained transaction past its deadline. Under constant load
on a testnet relay that transaction ran longer than the gap between peers'
writes, so it never committed, nothing expired, and every retry had more
to do: the thread spun at 2-3 GB/s of allocation and stopped emitting
counters.

Each sweep now does at most 1000 units of work: it expires up to half of
them, earliest first, and reports whether expired entries remain; a
backlog drains in further transactions of that size, 10 ms apart so that
peers' transactions commit between them, for at most an interval before
the next sweep. The sweep no longer scans the tx table: it carries a
cursor and examines the rest of its budget as entries from there on,
round the table, and dooms those claimable for a grace of two sweep
intervals with nobody attempting or submitting them. The next tick reads
each live peer's advertised set, one peer per transaction, and drops the
doomed entries no peer holds in the sweep's transaction, its budget
included, sparing the rest. An advertisement or a claim of a doomed entry
clears the doom, so advertising an existing entry writes nothing shared,
as before, and the sweep reads nothing but the state. A lookup entry
lives with its residence: expiry drops it, a peer still holding the key
acknowledges it as resolved, and a body arriving for it counts as late.
The shared-state trace carries sharedRevision, which every write
advances.

Tests compare the bounded forms with the unbounded sweep and check the
cursor's rotation, the doom, and, in IOSim, the thread's cadence, drain
cap, review and budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The retained-tx queue was an IntPSQ keyed on Time, whose comparisons go
through Integer picoseconds. Profiling the sweep stalled under load put a
fifth of its CPU in bigNatCompare, integerCompare and __gmpn_cmp beside the
IntPSQ merges themselves.

The queue now keys on Word64 nanoseconds of monotonic time, 584 years of
range, and the RetainedTxs API takes and returns Word64. retainDeadline
converts a deadline rounding up and retainNow the current time rounding
down, so an entry never expires before its deadline and at most a
nanosecond after it; a negative time, as a non-positive lifetime gives, is
due at once, and a time past the range at its end, rather than wrapping.
The shared-state traces render deadlines as seconds, numbers where they
were strings.

Tests: deadlines over arbitrary picosecond times, through the end of the
range, never expire early and expire within a nanosecond; the existing
retention tests compare against the stored nanosecond deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The counters thread read the retired accumulator, the registry and every
live peer's counters cell in one STM transaction. Each peer writes its cell
on every protocol event, so with a hundred peers that transaction is
restarted by every write and can starve, as the sweep did.

The snapshot now reads like the mux counters: one transaction for the
retired accumulator and the list of live cells, then each cell in its own.
A peer retiring in between is counted once: retirePeerCounters moves its
counts into the accumulator in the transaction that unregisters it, and
the cell keeps its final counts. The registry is keyed by the peer's
address and a registration number, so a release removes its own entry
even when a reconnect has registered the address again.
snapshotCountersWith takes the transaction runner, so a test can run peers
between a snapshot's transactions and check that every count is taken
exactly once; registerPeerCounters is the one place a registration is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The counters thread traces the shared tx state every seven seconds when it
changed, and since the bounded sweep that trace carries the numbers that
say how the sweep is doing: the revision, whose growth is the rate of
writes to the shared state, and the residences. They were readable only
with the trace at Debug, because the TraceTxLogic instance had no metrics,
where TxSubmissionCounters has them and so reaches Prometheus with no
configuration.

TraceSharedTxState now carries metrics: txSubmission.sharedState.revision
and .generation, and one gauge per count of sharedTxStateCounts, named by
its field. They are emitted at the trace's cadence whatever its severity,
and documented with the namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
karknu and others added 7 commits November 13, 2026 10:56
A relay polls every inbound peer for txids at about one exchange per
round trip, and under load that polling is most of its CPU while nearly
every txid it carries is a duplicate. The cost is per exchange, not per
txid, and it grows with every inbound peer.

The server now keeps a bounded set of peers polled at full speed without
judging peers on what they deliver: local roots by standing, tx sources
while they produce, and the first txFastPeers of the inbound peers in the
salted order of their addresses, the salt drawn afresh every rotation, so
a departure moves the next peer up at once. Everyone else waits a
jittered cadence between txid requests, parked meanwhile on a counter
that moves only when an entry becomes claimable, a lease is released or
a stuck cap raised, unless it holds a body it could not submit yet. The
knobs live on the decision policy, which the counters thread checks at
start; that thread runs the rotation and asks the governor once a sweep
which peers are local roots, so that no decision reads its state. The
txid request cap is now the window, since a peer on a cadence
accumulates more than six ids between requests, and the counters tell
slow-tier requests apart and give a census of the peers by standing.

Properties check the spacing, the source rule, the placement against the
sorted order and the census; the simulation runs with every peer slow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The mean txids per request could not tell empty replies, which are
wasted round trips, from full windows, which say the window size
binds.  The inbound tx-submission counters gain five reply-size counts,
none, one, two to four, five to nine and ten or more, a partition of the
replies to requests for txids, published as metrics.  A registry
property checks each reply lands in its bucket for every size and
boundary, and that a reply to a request for acknowledgements alone
lands in none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A decision's keys were resolved to txids in a later read of the shared
state. A key leased to this peer whose tx another peer had submitted
meanwhile lost its lookup entry when the retention expired, and the
resolution then failed the peer. The registry now resolves the action in
the deciding transaction: the run functions return the action with the
txids and sizes to request or the bodies to submit, and the two resolve
calls leave the API. A park is resolved there too: the decision names
the counter to wait on and the registry reads its value, so the pure
layer carries no counter values of its own. A slow peer whose gate holds,
with no body buffered and none of its advertised keys in the table, parks
on its timer alone: nothing the claimable counter announces is its to
claim before the gate opens.
The check that every body answers a requested txid went through a map,
so a reply could carry one body twice. The second copy answered no
pending key and passed for a late body, which cancelled the omission of
a key the peer had left out of the same reply. A reply with more bodies
than distinct txids is now a protocol error, traced like the other body
errors. An impairment in the simulation sends such replies, and a
property checks that the inbound side rejects them.
A peer's score, which delays its claims, netted a batch's rejections
against the accepted txs of the same batch, so a batch mixing invalid
bodies with valid ones cost the peer nothing up to seven invalid beside
three valid at the default decrement. The accepts now come off the score
the peer had and the batch's rejections are added after. A unit test
covers a mixed batch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A peer whose decision found nothing to do ran the scheduler once more
when it had just come from a request or a submission, in case the idle
peer could now claim what the busy one could not. Nothing in a decision
reads that phase, so the second run could only repeat the first: the
counter a park waits on is read in the deciding transaction, and the
park's delay is the earliest timed wake. Every episode paid a decision
for nothing.

The server parks at once. The phase, written and never read, goes with
the re-run.

A property drives the server to a park after a txid reply and counts its
decisions: one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every wake of a park registered the gate's delay again, and a registered
delay could not be cancelled: each claimable bump left one dead timer per
parked slow peer until its gate passed, all of them in the timer manager.

The idle loop keeps the timer it holds while the park's deadline, the
gate's time or the next timed wake, is unchanged, and cancels it when
the deadline changes or the park ends. RegisteredDelay is built on the
cancellable delay the timer library offers and gains cancel; a
non-positive delay is fired at birth.

A property counts the timers a gated peer registers across claimable
bumps from the simulation's trace: one, one more for the lease's claim
time once the gate has passed, one swapped for an earlier deadline, and
the one it leaves cancelled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@karknu
karknu force-pushed the karknu/txsub-bounded-sweep branch from 75d6384 to ade2c7e Compare November 13, 2026 10:00

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

leios Issues / PRs related to Leios tx-submission Issues related to tx-submission protocol

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant