xOnePy runs SQLite in WAL mode with synchronous=FULL. It writes every BEGIN and COMMIT itself, and it keeps slow work off the asyncio loop: one thread owns the database, and a process pool recovers senders. The xOnePy repository is private, so its links need access.
For install and sync steps, read how to run xOnePy. For the datadir and the publication order of all four clients, read how xOne stores chain data. This post covers the Python parts only.
Which SQLite settings does xOnePy use?
xOnePy opens chain.sqlite with journal_mode=WAL, synchronous=FULL and isolation_level=None. It reads the settings back and refuses to start on any other value (ADR-005 Python addendum, status: proposed).
- The open code raises
PragmaErrorif the mode is notwal,synchronousis not 2, or the connection has implicit transactions (segment.py). - Python's
sqlite3module opens a transaction before the first write by default. That moves the commit point without an error, so the store does not use it.
What does a durable commit cost?
One commit costs about one device flush. The spike measured 22.40 ms at p50 for synchronous=FULL and 0.51 ms for NORMAL. The ADR rejects NORMAL as the publication point, because it is not durable for the last commits after a power loss.
The spike wrote 2,000 simulated blocks of 56.6 KB, one commit per block, on 2026-11-12 (load average about 40):
| Mode | p50 ms | p95 ms | p99 ms | max ms | syncs per block |
|---|---|---|---|---|---|
sqlite3 WAL synchronous=FULL |
22.40 | 45.93 | 71.32 | 149.91 | 1.08 |
sqlite3 WAL synchronous=NORMAL |
0.51 | 5.65 | 42.68 | 165.74 | 0.07 |
The full publication order needs about four sequential flushes per block, about 80 ms on that disk. The ADR calls this fine for a 2 s slot, not for bulk replay.
How does the group commit work?
A follower shares one write transaction between up to 128 blocks. A producer commits every block durably.
Config.batchis 0 for a producer and 1 to 128 for a follower. The node sets 128 unless--mineis set (main.py).- Inside a batch, each block is a
SAVEPOINTin oneBEGIN IMMEDIATEtransaction. steady()syncs the segment chunks the batch wrote, then the directories, then runs oneCOMMIT. That is one WAL fsync for the whole batch.- SQLite never holds a block whose segment bytes were not synced first. A crash loses the open batch, and
reconcile()cuts it (D12, D13). - While a batch is open,
durability()reportsDegraded(BATCH). The signer signs only onDurable.
The follow loop calls store.steady after each step (store.py).
How does the node keep its asyncio loop free?
One chain thread owns the SQLite connection. The follower runs on its own thread. A task on the loop measures how late it wakes, and a watchdog thread reads its heartbeat.
- Chain thread. Every database call runs on the thread
xonepy-chain, so RPC, metrics and block serving do not wait behind a step that waits on the network. - Loop-lag probe. A task sleeps 50 ms and measures the delay. Above 100 ms it logs
event loop stall <ms>. The metrics endpoint exports the last value as the gaugexone_loop_lag_ms(limits.py, metrics.py). - Watchdog. A thread reads the heartbeat. At 100 ms late: warning and load shedding. At 2 s: one error. At 10 s: exit with a non-zero code (ADR-007 Python addendum). Today the heartbeat comes from the loop probe.
- Read deadlines. A long SQLite read stops WAL checkpoints, so every RPC read has a deadline.
Why does sender recovery use processes?
With the GIL, threads do not speed up signature recovery, and processes do. The ADR records this for block 21,970, which has 5,032 senders.
| Case (CPython 3.13.12, 2026-11-12) | Time |
|---|---|
| Recovery, 1 thread | 818 ms |
| Recovery, 8 threads | 826 ms |
| Recovery, 8 processes | 183 ms |
Source: ADR-P01 (medians, load average about 40). The ADR rejects free-threading and PyPy because coincurve has no wheel there.
The node's RecoverPool is a forkserver pool of cores minus 2 processes (recovery.py). It starts once, and each worker warms up with one real signature. A batch under 64 signatures runs in the calling process.
The ADR also describes one execution worker process (Pools.execute). On main, only tests call it.
What do the recorded numbers show?
The loop stays under its 100 ms line when work leaves it. The hosts were shared.
| Record | Result | Load |
|---|---|---|
| Block 21,970 in a worker process, loop lag (ADR-007) | p99 3.7-4.8 ms | average about 33 |
| Same block in the execution worker (py-performance) | p99 4.5 ms, 16 samples | 1.28 per core |
| Same block on the loop, control (py-performance) | one stall of 1,013 ms | 1.28 per core |
| Recovery of 5,032 senders, pool of 18 processes | 60-125 ms, budget 250 ms | 1.19-1.51 per core |
The ADR-007 run used a proxy load, not the full py-evm run. It also logged an injected 150 ms stall as event loop stall 145 ms.
The live sync record is py-performance.md, net2040 blocks 13,626 to 13,702 (76 blocks, 286,224 transfers, host load average 34 to 50 on 20 cores):
| Build | tx/s | ms per tx | Longest loop stall |
|---|---|---|---|
| geth-XDC v1.17.10-xdc.21, same peers | 12,521 | 0.080 | not given |
xOnePy main 2b27e06 |
944 | 1.060 | 5,043 ms |
| xOnePy after the change | 1,833-2,057 | 0.486-0.546 | 257 ms |
Before the change, the head read hashed every transaction of the head block each second (36.0 % of chain-thread samples). Sender recovery ran on the chain thread (12.4 %). The change fixed both. The pure-Python CRC-32C then took 17 %, and the ADR records a CRC-32C extension in the dependency allowlist on 2026-11-13 (xOnePy#81).
How does this compare with other clients?
Our research files hold one related claim, on execution.
| Client | What its documentation says | xOnePy |
|---|---|---|
| reth | Execution is single-threaded, as stated in reth system requirements. | Execution runs on one chain thread. Sender recovery uses cores minus 2 processes. |
What is not measured?
From the CHANGELOG:
- Contract transactions take 61-63 ms each. The follow budget is 2 ms.
- Restart to lag 0, RSS growth over a 6 h follow, and peers and ethstats during heavy blocks are not measured at the tip.
- A node may not appear on SkyNet: ethstats closes with code 1006, and block import and the socket may share a loop (#66).
Frequently asked questions
Which SQLite settings does xOnePy use?
WAL mode, synchronous=FULL and isolation_level=None, with its own BEGIN and COMMIT. The store refuses any other value at open.
Does xOnePy use threads or processes for sender recovery?
Processes. A forkserver pool of cores minus 2 recovers them. Threads gave no speed-up with the GIL.
Sources
- ADR-005 Python addendum
- ADR-007 Python addendum
- ADR-P01
- Code under packages:
db(store.py, segment.py) andnode(pools.py, recovery.py, limits.py, main.py) - py-performance.md
- CHANGELOG.md
- reth system requirements
This article describes the source and evidence at its review date. Check the linked repository for current implementation and qualification status.
Explore the engineering evidenceEdit this page on GitHub opens the Markdown source (content/blog/inside-xonepy/index.md). GitHub may ask you to sign in. Report a correction opens a prefilled GitHub issue for this page.