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 PragmaError if the mode is not wal, synchronous is not 2, or the connection has implicit transactions (segment.py).
  • Python's sqlite3 module 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):

REFERENCE TABLEScroll to explore all columns →
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.batch is 0 for a producer and 1 to 128 for a follower. The node sets 128 unless --mine is set (main.py).
  • Inside a batch, each block is a SAVEPOINT in one BEGIN IMMEDIATE transaction.
  • steady() syncs the segment chunks the batch wrote, then the directories, then runs one COMMIT. 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() reports Degraded(BATCH). The signer signs only on Durable.

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 gauge xone_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.

REFERENCE TABLEScroll to explore all columns →
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.

REFERENCE TABLEScroll to explore all columns →
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):

REFERENCE TABLEScroll to explore all columns →
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.

REFERENCE TABLEScroll to explore all columns →
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