Skip to content

Rewrite the LeiosFetch decision logic - #2237

Merged
ch1bo merged 49 commits into
nfrisby/leios-first-txcache-incrementfrom
nfrisby/leios-first-LeiosFetch-increment
Aug 29, 2026
Merged

Rewrite the LeiosFetch decision logic#2237
ch1bo merged 49 commits into
nfrisby/leios-first-txcache-incrementfrom
nfrisby/leios-first-LeiosFetch-increment

Conversation

@nfrisby

@nfrisby nfrisby commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

PR #2188 should merge before this one; this PR's branch extends that PR's branch.

TODO better description, link to correct Issue, etc

Major differences:

  • does not avoid redundant requests (eg we immediately send full hedge requests)
  • limits outstanding bytes for normal peers, but ~60 MB allowed for each BigLedgerPeer
  • pulls from LeiosTxCache
  • pulls from Mempool
  • two-tier EB prioritization (FreshestFirst except within the EBs that are younger than L=3L_hdr+L_vote+L_diff those young EBs are prioritized FreshestLast)
  • each FetchLogic iteration only reads in-memory data structures to decide → bounded decision latency
  • those in-memory data structures are well-pruned → bounded memory usage
  • randomizes the order in which it fetches still-missing txs → when its limits are saturated, they won't all be saturated on the same requests
  • on restart, it'll refetch all of each EB it hadn't completely acquired before shutting down

@nfrisby nfrisby added the Leios label Aug 25, 2026
@nfrisby nfrisby moved this to 🏗 In progress in Consensus Team Backlog Aug 25, 2026
@ch1bo
ch1bo changed the base branch from leios-prototype to nfrisby/leios-first-txcache-increment August 25, 2026 19:52
@ch1bo
ch1bo marked this pull request as ready for review August 28, 2026 08:21
@ch1bo
ch1bo self-requested a review August 28, 2026 08:21

@ch1bo ch1bo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I have not read this code! However, I was using the API and running this a lot while working on #2235, including several night long runs and stress tests. I might be able to review this today, but would also be comfortable with merging to the leios-prototype as-is.

@ch1bo
ch1bo force-pushed the nfrisby/leios-first-LeiosFetch-increment branch from 80e244a to 798ff41 Compare August 28, 2026 08:59
@ch1bo
ch1bo force-pushed the nfrisby/leios-first-LeiosFetch-increment branch from 7d7b201 to 9f4ba52 Compare August 28, 2026 13:18
@nfrisby nfrisby changed the title DONOTMERGE Rewrite the LeiosFetch decision logic Rewrite the LeiosFetch decision logic Aug 28, 2026
nfrisby added 18 commits August 28, 2026 21:06
…EbBodies

If we already have the EB body _and it's still marked as `BodyAlreadyInserted`
in the LeiosTxCache_, don't add it to `missingEbBodies`.
…EbTxs

If we already have the tx _and it's still marked as `TxAlreadyInserted` in the
LeiosTxCache_, don't add it to `missingEbTxs`.
The LeiosTxCache supplants the need for this functions. The corresponding checks
now has a (tunable) false miss rate, but it also now has in-memory latency
instead of requiring trips to the SQLite backing store.

Prior to this commit, the protototype's LeiosFetch decision loop reconciled its
outstanding set against the on-disk LeiosDb once per iteration (the function was
named `filterMissingWork`) — a synchronous by-hash membership probe sitting
directly on the path from "a peer offers an EB body/closure" to "we issue the
fetch request(s)." The `leios-txcache-bench` microbenchmark quantifies that
probe: for just a single (full) EB's transactions, the SQLite lookup runs about
10× slower than the in-memory index when its pages are warm and about 190×
slower when they're cold — hundreds of milliseconds versus on the order of a
millisecond.  Because that probe sat on the decision path, its execution time
was added to per-hop fetch latency on every hop of an EB body/closure: a steady
latency tax in the warm case, and — if an adversary can force the page cache
cold, which we cannot prove they cannot — a hundreds-of-milliseconds latency
spike in the cold case (multiple by the number of (full) in-flight EBs in whole
outstanding set).

This patch replaces the probe with the in-memory LeiosTxCache, so the decision
consults an always-ready in-memory index (about a millisecond, independent of DB
size and page-cache state) instead of waiting on the database. That shaving of
per-hop latency directly benefits Leios diffusion, which the security argument
depends on — and because the DB probe was the one per-iteration cost that could
spike ~unboundedly with DB size and cache state, removing it is a necessary step
toward running the fetch loop more often than its current meager 2 Hz.
The msgLeiosBlock handler always looks up the txs immediately after inserting
the body, so fuse those lookups with the body's ref count bumping pass (when
it's not a no-op).
`packedRequest` was the function that could violate it, but now the
`LeiosFetchDecisions` data type that carries the fetch decisions to
`packedRequest` no longer provides that degree of freedom: it's provides one
SlotNo-EbHash pair per tx, and a new test requires its real pair.

The LeiosFetch code was inherited from the exploratory burst demo in October
2025. One of the things that demo tried out was issuing requests for EBs where
the EbSlot accompanying the EbHash was not necessarily the slot of an
announcement of that hash, but instead the _potentially greater_ slot of a
_potentially different_ EB that shared that tx. In order to get a
higher-priority EB's tx, we could fetch it from a peer who hadn't even (yet)
offered that EB but had offered an EB that we know shares txs with it.

Subsequent LeiosFetch design ruled this optimization out; it's a DoS vector. If
the higher-priority EB is adversarial and withheld, then the victim might never
fetch the lower-priority honest EB's txs from its peers that would actually
serve them. If the peer had also offered the higher-priority EB, then the
request could simply list that hash instead.

Thus, it's not an actual loss to eliminate this optimization. This commit does
so because this complexity was muddling a bug hunt (next couple commits fix that
bug; `filterMissingWork` was masking it). When the reply arrives, the priority
was being interpreted as the EbHash's actual slot, which was arming the bug. As
of the bugfix, that wouldn't actually cause any problems anymore, but it's still
preferable to just remove the ultimately undesired feature's complexity.
Prior to this commit, the msgLeiosBlockTxs handler was only removing its arrived
tx hashs from `missingEbTxs` _for the slotNo_ carried by the
MsgLeiosBlockTxsRequest that incurred this reply. That has two consequences.

- First, it was causing a crash, since it resulted in `missingEbTxs` and
  `reverseEbIndexByTx` falling out of sync. (The tx was _completely_ deleted
  from `reverseEbIndexByTx`.)

- Second, the persistence in `missingEbTxs` would lead to additional
  requests. (Were it not for `filterMissingWork`, see below.)

But the Leios prototype's current LeiosDb and current LeiosFetch logic dedup EB
closures, so the arrival of a tx for one EB discharges it for all other EBs as
well. Additional requests for that tx shouldn't be redundantly sent on behalf of
those EBs.

If the LeiosDb didn't dedup EB closures, then this augmentation of the deletion
logic would still be correct, but something (eg the msgLeiosBlockTxs, the next
decision logic iteration, or etc) would need to _also_ store the arrived tx to
the closure of other EBs that reference it.

In the imminent LeiosFetch logic rewrite, we'll no longer be tracking the
overlap between EB closures, and this bugfix will be trampled. But until that
rewrite, this is a correct bugfix for the Leios prototype's current LeiosFetch
and its current LeiosDb.

Notes:

- This bug (both the crash and the redundant tx fetches) surfaced now because
  the recently removed `filterMissingWork` was masking it.
- For EB bodies, the LeiosFetch decisions are simple: they don't try to dedup.
  So, announcements of the same EB from multiple slots will arise in redundant
  fetches of the body. We consider that harmless for bodies.
    - Honest nodes will very rarely announce the same EB body, so this is not a
      worthwhile optimization---it's not part of the work-preservation argument.
    - An announcement is entirely free to pick which EB it announces, so this
      would be nothing more than an optimization, one the adversary could always
      trivially choose to avoid.
- In some sense, this commit supplants `95bff62b5c0ab0937dc03e964c9e24bc731b1399
  LeiosFetch: bugfix, only emit MsgLeiosBlockTxsRequest with a real LeiosPoint`,
  but in another sense, that other commit wasn't merely avoiding the crash.
I erroneously removed acquiredEbBodies alongside filterMissingWork, replacing
_both_ with the LeiosTxCache. That was a mistake for at least these reasons:

- acquiredEbBodies had important relationships to data under the outstanding
  lock, and the LeiosTxCache is not under that lock, so using it instead
  requires multi-lock coordination. Unsurprisingly, the first attempt at that
  coordination was wrong/under-appreciated the challenge, and so had a bug: the
  missingEbBodies and LeiosTxCache could get out of sync in a way that resulted
  in the node constantly fetching an EB body over and over.

- acquiredEbBodies merely contains the points, so it's OK for it to include
  _all_ volatile EBs---we don't _need_ to accept the false negatives that the
  cache would bring.

However, replacing acquiredEbBodies with LeiosTxCache provided a couple of
improvements. So this commit not only reintroduces acquiredEbBodies but also
improves it in those same ways:

- It's now pruned as the immutable tip advances.
- It's directly updated by the forge (the filterMissingWork sledgehammer had
  been compensating for the old acquiredEbBodies not doing that).

Now that it's being pruned, some of its uses needed to be enriched to correctly
handle events (eg late message arrivals) related to data that has already been
pruned out; hence the addition of acquiredEbBodiesPrunedSlot.

-----

There were also some minor improvements done in passing.
With this commit, the relevant regression test fails.

```
      a concurrent offer and body arrival never leave a held EB body listed (IOSimPOR): FAIL (0.04s)
        *** Failed! Falsified (after 1 test):
        Schedule control: ControlAwait [ScheduleMod (Thread {2,2}.14) ControlDefault [Thread {2}.7,Thread {2}.8,Thread {1}.0,Thread {1}.1]]
        Thread {2,2} delayed at time Time 0s
          until after:
            Thread {1}
            Thread {2}

        held EB body still listed for fetching: [47f6c6404a56ea658d0b40e31c47eba73beaad3d3d71a5e61fc0e90ea48d6d99]
        Use --quickcheck-replay="(SMGen 17010102127566570440 17528764287311168251,0)" to reproduce.
        Use -p '/LeiosDemoLogic.Invariants/&&/a concurrent offer and body arrival never leave a held EB body listed (IOSimPOR)/' to rerun this test only.
```

That's because this commit (temorarily!) re-introduces the bug that was by
reintroducing acquireEbBodies.
…est"

This reverts commit 60345472159e4ac0e0569ac4368539a7a634fd50.
nfrisby and others added 23 commits August 28, 2026 21:06
Main idea: send fetch requests very aggressively.  We're on a very tight
deadline and cannot trust our peers. Thus, tail latency is our primary
concern. We cannot afford to deduplicate _requests_ because any time we spend
waiting on a withholding peer harms the tail latency. Our latency budget is so
tight that we don't have an patience, and so any affordably-aggressive timeout
would be so tight that it would cause false alarms from honest peers that are
hiccuping for otherwise-tolerable reasons.

Next refinement, in a subsequent commit: treat BigLedgerPeers specially, request
_all_ jobs from them at once (they're our only source of truth and they might be
intercontinental, so we need maximum utilization---again, for the sake of tail
latency).
- Prior to this commit, we retained each EB body in memory until its closure
  fully arrived.

- For up to 10,000 EBs with up to 512 kB per body, that's up to ~5
  gigabytes---unjustifiable.

- We could retrieve them from disk, but then there's extra disk IO and/or caches
  to maintain, etc etc.

- Instead, we're storing at most one 32 byte hash per job, with at most 184 jobs
  per EB body, which is up to ~60 megabytes since there are up to 10000
  bodies. Affordable.

And since that's affordable, we have no need to _directly_ limit how many EBs
are inflight at any given time (neither globally nor with each peer); a nice
simplification.

A future version of the LeiosDb might require some the tx's byte-offsets within
the EB for storage. That might reintroduce the need for in-memory storage and
hence bounding the inflight EB count---but we don't already have that need.
The node was crashing shortly after announcing an EB. The error was raised
because the node was voting for the same announcement twice (which causes a
crash, at least currently). The node votes once per AcquiredEbTxs, and there
were two of those for EBs the node announces: one caused by the forge inserting
the body and the closure and the other by LeiosFetch also fetching the body a
second time, and seeing that its entire closure was present upon its
arrival. (LeiosDb.InMemory actually catches this and suppresses it, but SQLite
doesn't. Even so, it shouldn't be the responsibility of the LeiosDb to do so.)

The forge wasn't updating the LeiosOutstanding bookkeeping to record that the
body was already present. This commit fixes that.
It's grossly FreshestFirst, but the truly fresh EBs --- those younger than L
slots --- are prioritized StalestFirst.

So it's FreshestFirst except StalestFirst among the VeryFreshest.

Another way to specifiy it is that we have two tiers. The high-priority tier
contains the EBs younger than L, and, within that tier, the _stale_ EBs are
prioritized. The low-priority tier contains the EBs older than L, and, within
the tier, the _fresh_ EBs are prioritized.

So the the priority of the high tier over the low tier respects
FreshestFirst. And the priority within the low tier respects FreshestFirst. But
the priority within the high tier is instead StalestFirst.
Remove maxRequestedBytesSizePerBigLedgerPeer and redefine it in terms of
maxLeiosFetchIngressQueue. They're not independent, and I couldn't see any use
to have the scheduling bound be tighter than the downstream buffer bound.
Before this commit, the initial LeiosOutstanding state after startup completely
ignored the initial contents of the (on-disk, persisted) LeiosDb. That's _sound_
but it might lead to a lot of unnecessary refetching on startup.

On the other hand, healthy (important) nodes shouldn't be restarting often.

This commit is a compromise with a high power-to-weight ratio: we initialize the
LeiosOutstanding state to not re-fetch/re-process the EBs we already
_completely_ processed.

We could do more, eg reprocess the EB bodies whose _entire_ closure we don't
have. And we could warm-up the LeiosTxCache. But that's all extra complexity
that's not obviously _necessary_ or even worthwhile yet.
…n stats

The data include wall-clock duration of the iteration, bytes/counts about the
newly decided requests, and bytes/counts about the new LeiosOutstanding state.
Key difference is that it also summarizes the Mempool hits not just the
LeiosTxCache hits.
Now that LeiosFetch also pulls from the Mempool, it's important for the name to
be less vague/surprising.
Neither test suite compiled after the age metrics were added.

'recordAnnouncedEb' gained an announcement onset, so its two call sites in the
fetch invariants pass SNothing: those invariants are about the fetch bookkeeping,
which never reads the onset.

'TraceLeiosBlockTxsAcquired' gained the age, so the ThreadNet pattern that
collects acquired points ignores it. The ambiguous-type error two hundred lines
below was a cascade of that one -- with the pattern ill-typed, the element type
of 'acquiredPoints' could not be inferred.
Output of scripts/ci/run-fourmolu.sh and scripts/ci/run-cabal-gild.sh, which CI
enforces. Mostly the layout of multi-line SPECIALISE pragmas, which fourmolu also
spells SPECIALIZE, plus stanza spacing in the cabal file.
@ch1bo
ch1bo force-pushed the nfrisby/leios-first-LeiosFetch-increment branch from ff3907c to 07fae70 Compare August 28, 2026 19:06
@ch1bo
ch1bo merged commit f6f4650 into leios-prototype Aug 29, 2026
6 of 26 checks passed
@ch1bo
ch1bo deleted the nfrisby/leios-first-LeiosFetch-increment branch August 29, 2026 14:47
@github-project-automation github-project-automation Bot moved this from 🏗 In progress to ✅ Done in Consensus Team Backlog Aug 29, 2026
ch1bo added a commit that referenced this pull request Aug 29, 2026
…#2235, #2245)

Merges the stacked series, bottom to top:

* #2188 Leios: add LeiosTxCache, but don't rely on it at all yet
* #2237 Rewrite the LeiosFetch decision logic
* #2235 Leios prototype: EB validation in vote logic
* #2245 LeiosDb: bound the WAL, remove the write hotspot, stop dying on
  lock contention

IMPORTANT: the schema change in #2245 has no migration. An existing
database is not readable by this version and requires a wipe & resync.
@ch1bo ch1bo mentioned this pull request Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

Prototype the TxCache to avoid fetches and revalidation

2 participants