In situ: the advertise path's corpus scan takes a node down for good after ~22 h
Filed from the CIRIS canonical (ciris-canonical-1, 2 vCPU / 3.9 GB, seven
containers). Captured while the node was hung, not reconstructed afterwards.
Companion to CIRISServer#506; this is the edge-side half.
What the node looks like right now
started 2026-08-28T22:22:41Z restarts=0
now 2026-08-29T20:41:21Z → 22.3 h uptime
:4243 Recv-Q=1 health=000 after 6.0 s (from inside the netns)
disk 35 MB/s sustained read, continuous
majflt 3,146,406 cumulative
Thread table at the moment of the hang — the runtime is not CPU-starved:
675252 ciris-node state=D wchan=wait_on_page_bit_common ← the holder
675254 ciris-node state=S wchan=futex_wait_queue_me
675256 ciris-node state=S wchan=futex_wait_queue_me
675259 ciris-node state=S wchan=futex_wait_queue_me
323269 ciris-node state=S wchan=futex_wait_queue_me
324450 ciris-node state=S wchan=futex_wait_queue_me
454223 ciris-node state=S wchan=futex_wait_queue_me
One thread in uninterruptible page-wait. Six idle. HTTP still dead.
The mechanism is a lock convoy, not scheduling starvation
This distinction matters for which fix works, and we got it wrong once already.
CIRISServer#501 was scheduling starvation — one worker pegged at 99.9%, cured by a
worker-count floor (MIN_WORKER_THREADS = 4, shipped in server 0.5.194). That
floor is live here — you can count four ciris-node workers above — and this
node is down anyway.
Because the contention is not CPU. SqliteBackend holds
conn: Arc<Mutex<Connection>> — one connection for the entire node — and any
large scan takes conn.lock() and holds it across the read. While the holder is
in wait_on_page_bit_common, every other DB-touching task parks in
futex_wait_queue_me behind that mutex. Adding workers adds queue positions, not
throughput. That is exactly the shape above: one D, six S, nothing served.
/v1/health reads the store, so the node's own liveness surface is inside the
convoy. That is why this ran 22 hours unnoticed.
Why the advertise path is the producer
list_attestations_since(None, u32::MAX) per plane, per peer, per round is an
unbounded corpus scan on a cadence. The arithmetic on this host makes the outcome
inevitable rather than unlucky:
corpus (server db) 1.3 GB
page cache (whole box) 808 MB ← already smaller than one node's corpus
free 138 MB
transport_destinations 11,034 rows (against 748 federation keys)
primed peers 430
Every round re-reads a corpus that cannot be cached, so every round page-faults
from disk, and each fault happens holding the single connection mutex.
Escalation is visible against your own earlier measurement: #480 recorded
40.8 MB/s; this node has been observed at 81–118 MB/s and is at 35 MB/s while
otherwise idle. The delta tracks directory growth, so this gets worse on its own.
Corroborating: edge logged 1 round and 1 deliver in ten minutes — the
replication plane is barely progressing while the disk runs flat out — alongside
28 retry/backpressure warnings in 30 minutes.
Impact
- A canonical goes permanently unreachable after ~22 h and does not self-recover.
Only a restart clears it, and the clock starts again.
- It is silent: no crash, no panic,
restarts=0, and the health endpoint that
would report it is itself blocked.
- It scales the wrong way — the bigger the directory, the sooner it arrives.
Ranked asks
- Bound the advertise scan (CIRISEdge#531's predicate hoist). A scan whose
cost is O(corpus) per peer per round cannot be made safe by anything downstream.
- Do not hold the connection guard across a large read. Even bounded, a scan
that holds the only connection while faulting will convoy. Chunking with the
lock released between pages would cap the convoy at one page rather than one
scan.
- A liveness surface that survives a blocked runtime — this is the reason for
the 22 hours. Any health path that touches the store is inside the failure it is
meant to report.
Our side
Not clean either, and it compounds this: the server runs storage_summary()
(SUM(pgsize) FROM dbstat — a walk of every page) from trace_plane_watch every
15 min and retention hourly, on the same single connection. On this box
ciris-status embeds the server too and is thrashing on its own 374 MB store
(majflt=768,585), so roughly ten full-page walks an hour across two nodes keep
the cache cold between your rounds. Tracked as CIRISPersist#775, and we are moving
our own reads off the runtime workers.
That is a compounding contributor, not the cause: our walks are periodic, the
81–118 MB/s is continuous.
Repro
Uptime. Leave a canonical-sized node running ~22 h with a live peer set; watch
/proc/<pid>/task/*/wchan for wait_on_page_bit_common and Recv-Q on the read
API port climb while CPU sits at zero.
Happy to give access to the affected host — ask and we will arrange it rather than
publishing connection details here.
Related: CIRISServer#506 · CIRISServer#501 (the other mechanism, already fixed) ·
CIRISEdge#531 · CIRISEdge#480 · CIRISPersist#775
In situ: the advertise path's corpus scan takes a node down for good after ~22 h
Filed from the CIRIS canonical (
ciris-canonical-1, 2 vCPU / 3.9 GB, sevencontainers). Captured while the node was hung, not reconstructed afterwards.
Companion to CIRISServer#506; this is the edge-side half.
What the node looks like right now
Thread table at the moment of the hang — the runtime is not CPU-starved:
One thread in uninterruptible page-wait. Six idle. HTTP still dead.
The mechanism is a lock convoy, not scheduling starvation
This distinction matters for which fix works, and we got it wrong once already.
CIRISServer#501 was scheduling starvation — one worker pegged at 99.9%, cured by a
worker-count floor (
MIN_WORKER_THREADS = 4, shipped in server 0.5.194). Thatfloor is live here — you can count four
ciris-nodeworkers above — and thisnode is down anyway.
Because the contention is not CPU.
SqliteBackendholdsconn: Arc<Mutex<Connection>>— one connection for the entire node — and anylarge scan takes
conn.lock()and holds it across the read. While the holder isin
wait_on_page_bit_common, every other DB-touching task parks infutex_wait_queue_mebehind that mutex. Adding workers adds queue positions, notthroughput. That is exactly the shape above: one
D, sixS, nothing served./v1/healthreads the store, so the node's own liveness surface is inside theconvoy. That is why this ran 22 hours unnoticed.
Why the advertise path is the producer
list_attestations_since(None, u32::MAX)per plane, per peer, per round is anunbounded corpus scan on a cadence. The arithmetic on this host makes the outcome
inevitable rather than unlucky:
Every round re-reads a corpus that cannot be cached, so every round page-faults
from disk, and each fault happens holding the single connection mutex.
Escalation is visible against your own earlier measurement: #480 recorded
40.8 MB/s; this node has been observed at 81–118 MB/s and is at 35 MB/s while
otherwise idle. The delta tracks directory growth, so this gets worse on its own.
Corroborating: edge logged 1 round and 1 deliver in ten minutes — the
replication plane is barely progressing while the disk runs flat out — alongside
28 retry/backpressure warnings in 30 minutes.
Impact
Only a restart clears it, and the clock starts again.
restarts=0, and the health endpoint thatwould report it is itself blocked.
Ranked asks
cost is O(corpus) per peer per round cannot be made safe by anything downstream.
that holds the only connection while faulting will convoy. Chunking with the
lock released between pages would cap the convoy at one page rather than one
scan.
the 22 hours. Any health path that touches the store is inside the failure it is
meant to report.
Our side
Not clean either, and it compounds this: the server runs
storage_summary()(
SUM(pgsize) FROM dbstat— a walk of every page) fromtrace_plane_watchevery15 min and retention hourly, on the same single connection. On this box
ciris-statusembeds the server too and is thrashing on its own 374 MB store(
majflt=768,585), so roughly ten full-page walks an hour across two nodes keepthe cache cold between your rounds. Tracked as CIRISPersist#775, and we are moving
our own reads off the runtime workers.
That is a compounding contributor, not the cause: our walks are periodic, the
81–118 MB/s is continuous.
Repro
Uptime. Leave a canonical-sized node running ~22 h with a live peer set; watch
/proc/<pid>/task/*/wchanforwait_on_page_bit_commonandRecv-Qon the readAPI port climb while CPU sits at zero.
Happy to give access to the affected host — ask and we will arrange it rather than
publishing connection details here.
Related: CIRISServer#506 · CIRISServer#501 (the other mechanism, already fixed) ·
CIRISEdge#531 · CIRISEdge#480 · CIRISPersist#775