Status: investigated (2026-08-01), fix PR in progress
Origin: the "XRPL falls over at 20–25M accounts" stress campaign. On a real 5-validator perf
net under sustained live account creation, consensus lost liveness — LoadMonitor:WRN Job: AcceptLedger run: 19s, validators fell behind, dropped quorum, and the network deep-forked
with no self-heal (only a genesis reset recovered it). Masked operationally by resizing
validators 8→16 vCPU; the protocol weaknesses remain.
Three read-only investigations (code refs against Transia develop). Weakness #0 is not one bug
— it is two independent, complementary defects plus one correct behavior that must NOT be
"fixed".
File: src/xrpld/app/main/LoadManager.cpp, LoadManager::run().
The per-tick load-feedback block — if (getJobQueue().isOverloaded()) raiseLocalFee(); else lowerLocalFee(); if (change) reportFeeChange(); — sits outside the while (true) loop, at
function scope after the loop's closing brace. The loop's only exit is break when stop_ is
set (shutdown). So the fee adjustment runs exactly once, at server shutdown, where it is
useless. During live operation the local load fee is never raised under load.
Consequence: localTxnLoadFee_ stays pinned at normal (LoadFeeTrack.cpp), so scaleFeeLoad
never escalates the open-ledger cost. The node keeps advertising near-normal fees while its job
queue saturates — there is no fee-based backpressure telling clients/relays to back off. The
raiseLocalFee/lowerLocalFee implementations are correct but orphaned (their only callers are
this dead block).
Regression: commit d36024394d ("Report job queue data if a deadlock is detected", Nik
Bougalis, 2021-11-16) — a logging refactor that moved the loop's closing brace before the fee
block. Latent since Nov 2021; every subsequent rename/tidy preserved the broken structure. This
is upstream rippled, not Transia-specific — mainnet nodes have had dead local-load-fee escalation
for ~4 years.
Fix (PR2, dangell7/load-fee-escalation): relocate the fee block back inside the loop so it
runs every ~1 s tick; the break still exits at shutdown. One-block move.
Validation: the rig. On the unpatched build, saturate the queue and watch server_info
load_factor/local fee stay pinned; on the patched build it rises and sheds load. This is the
campaign's "break it, then show the fix holds" phase — not a unit test (the loop is a 1 s-tick
threaded, Application-coupled path; forcing isOverloaded() in a unit test is decay-timing
fragile and would test LoadMonitor plumbing, not the placement fix).
Even with backpressure restored, scheduling can still starve consensus under a transient flood.
- Fixed worker pool, no preemption. ~6 threads (down to ~4 on small boxes;
Application.cpp:330-353). A worker runs one job to completion (JobQueue.cpp:342-390). - Priority is selection-order only (
Job.cpp:83-93,getNextJobJobQueue.cpp:296):JtAccept=30 outranks transaction/client/ledger-data work — but only when a thread is free. - Flood types have unlimited concurrency.
JtTransaction,JtBatch,JtClient*,JtRpc,JtValidationT,JtWritearemaxLimit(INT_MAX) in theJobTypestable (JobTypes.h:54-100). With no per-type cap and no reserved thread, a flood can occupy every worker on long low-priority jobs; a freshly-enqueuedAcceptLedgerwaits behind them (the wait component). JtAccepthas 0 ms / 0 ms latency targets (JobTypes.h:81);isOverTargetreturns false when targets are 0 (LoadMonitor.cpp:133-139). So AcceptLedger's own 19 s never tripsisOverloaded()and never raises the fee — the overload signal is structurally blind to it.
Fix (PR3): (A) reserve worker slots for consensus-critical jobs in getNextJob — don't let
low-priority work consume the last 1–2 threads; and/or (B) cap flood-type concurrency (finite
limit reusing the existing deferred/finishJob path). Complementary one-liner: give JtAccept
non-zero targets so a slow AcceptLedger feeds the (now-live) fee signal. Higher risk than PR2 —
needs rig tuning of the reserve count. Separate PR so the trivially-correct PR2 regression fix
isn't held hostage to a behavioral scheduling change.
Note: the AcceptLedger body itself (applying the full canonical set synchronously,
RCLConsensus.cpp:520-527) can take many seconds on a large set — that's a distinct
apply-throughput problem, out of scope here.
Deliberately not pursuing auto-recovery from a deep fork. Findings:
- The quorum stall is correct. 5 validators → quorum 4 (
ValidatorList.cpp:1888,max(ceil(0.8·eff), ceil(0.6·unl))). Lose 2 → <4 → no ledger fully validates →validLedgerSeq_freezes. This is the intended 80% Byzantine-safety / halt-rather-than-diverge tradeoff. Negative UNL is a slow preventive tool (one change per 256-ledger flag ledger, needs validation to commit) and structurally cannot rescue an already-stalled net. - Non-reconvergence is downstream of that. "Preferred ledger by branch" (
getPreferred,LedgerTrie) only counts branches whose ledgers were acquired (Validations.h:461-477); a stalled/partitioned node can't fetch the majority chain (theRCLValidations.cpp:131"Need validated ledger" log), so the trie never points it home. Compounded byonline_deletepruning the intervening ledgers on peers. areCompatibleis a genuine finality invariant (View.cpp:134-189, gated inNetworkOPs.cpp:1983-1991). A node that fully-validated a ledger past the divergence point will refuse to follow a supermajority — correctly. An automatic "abandon my validated ledger" path would trade away XRPL's finality guarantee. Do not build it.
Remedy is operational/observability: quorum-loss + fork alerting, adequate validator count, and a documented (optionally admin-RPC-gated) operator resync/reset. Fits the monitoring roadmap, not a consensus code change.
| # | Defect | Correct? | Action |
|---|---|---|---|
| 1 | LoadManager fee-escalation dead (outside loop, since d36024394d) |
bug | PR2 — move block into loop |
| 2 | JobQueue: no consensus-thread reservation; flood types unlimited; JtAccept 0 ms targets |
bug | PR3 — reserve threads / cap floods / non-zero targets |
| — | Deep-fork non-self-heal | correct (finality + quorum) | operational alerting only; do NOT auto-abandon validated ledgers |
Related: [[project_capacity_hardening_prs]], the generational-GC PR1
(proposals/online-delete-generational-gc.md), and the observability roadmap
(proposals/network-capacity-6-month-roadmap.md).