xOneTS keeps state and indexes in one bun:sqlite file in WAL mode, and headers, bodies and receipts in append-only segment files. A follower can group commits to cut fsync calls. Pull request #76 removed per-block costs from full sync on dense blocks. The xOneTS repository is private, so its links need access. For run steps, read how to run and verify xOneTS. For the datadir and commit order, read how xOne stores chain data. Operators can start at the storage docs.
Why does xOneTS use bun:sqlite?
bun:sqlite is built into Bun, so it adds no dependency. The decision is in the ADR-005 TS addendum, status proposed. Its spike used 2,000 simulated blocks of 56.6 KB, one commit per block, on a loaded host (2026-11-12).
sqlite WAL synchronous=FULL gave p50 20.73 ms and p95 40.23 ms, 1.08 fsync per block. lmdb-js 3.5.9 gave 21.56 ms and 29.54 ms. LMDB is the recorded alternative: it is an N-API addon with its own dependencies, and it was not faster. synchronous=NORMAL ran at 0.34 ms p50, but the addendum rejects it as the publication point. It is not durable for the last commits after power loss.
What is in chain.sqlite?
One table per data kind, each k BLOB PRIMARY KEY, v BLOB and WITHOUT ROWID: Meta, SegIdx, HashedAccounts, HashedStorages, the changesets and others (db README). By default the node loads the state rows into memory at start. With --state.disk it reads them from the tables behind a cache (200,000 accounts, 1,000,000 slots, 512 codes) and keeps trie nodes in TrieNodes, keyed by position as in geth's pathdb (disk state). The CHANGELOG lists this under Unreleased.
How do segments work?
A segment file holds 100,000 blocks of one kind: static/{headers,bodies,receipts}/<first block, 10 digits>.seg. A file has a 20-byte XSEG header. A frame holds a length, the block number, the payload and a CRC-32C. The manifest is XMF1, 257 bytes, and readers never read past its length. The retention window is 4,096 blocks. It must cover the gap-pool roots that a snap client reads below its pivot, 3,149 blocks on Apothem and mainnet.
How does group commit work?
Group commit is a batch of blocks that share one open sqlite write transaction. It is not a weaker sync mode. A producer leaves groupCommitMs unset, so each commit is durable. A follower sets it. Each block is a savepoint, published in memory. At the steady point the store syncs the chunks and directories that the batch wrote, then commits with one WAL fsync. While a batch is open, durability() reports degraded/group-commit.
A crash loses the open batch, at most groupCommitMaxBlocks blocks (default 100). Reconcile cuts it, as rows D12 and D13. The Rust follower differs: with MDBX SafeNoSync a kill -9 loses nothing. With sqlite NORMAL, a later commit can survive power loss while the segment bytes under it are lost, so the package does not use NORMAL (db README).
Measured with the package on net2040 blocks 0..899 (2026-11-12, counted with strace):
| Mode | fsync total | per block | commit p50 ms | p99 ms |
|---|---|---|---|---|
| durable (producer) | 9,058 | 10.1 | 148.6 | 249.3 |
| group commit, 1 s steady point | 123 | 0.14 | 1.19 | 108.8 |
The open item is to fold the three segment directory syncs of the durable path into one when no chunk file was created.
What did the hashed-key state migration change?
State is keyed by keccak(address) and keccak(slot), as in geth's snapshot layer. Snap sync then needs any snap/1 peer (hashed-key state). State root, block hash and receipts root do not change.
Meta key statekeys = hashed-v1 marks the layout. A datadir with a chain and no marker is rewritten when the store opens, in one SQLite transaction. A crash leaves the old layout whole. The tables stream in pages of 50,000 rows, so memory stays bounded.
FORMAT_VERSIONstays 1. Segment files and the manifest do not change.- An older binary refuses a migrated datadir. There is no reverse migration, so keep a copy.
- The open transaction lives in the SQLite journal on disk: keep free space of about the state size.
| Project | What its documentation says | xOneTS |
|---|---|---|
| geth | Path scheme: trie nodes keyed by path (Geth v1.13.0) | TrieNodes uses the pathdb layout with --state.disk |
| ethrex | Forward-only migrations (ethrex databases) | One migration, hashed-v1, no reverse |
What did dense sync change?
PR #76 removed per-block costs that geth-XDC does not have. It merged on 2026-11-13 and closed issue #75 (PR #76, issue #75).
The issue records geth-XDC at 22.2 blocks/s and 12,521 tx/s on net2040 13,000..45,974 (measured 2026-11-13). xOneTS averaged 28 blocks/s over the whole chain and less on the dense range. The PR profiled the import path and removed these costs:
- One state root per block. Before: apply, revert, apply again (three trie updates).
- Changesets out of the heap. The read model keeps the newest 128 changesets in memory. Before, 4,096 dense changesets held the heap at 3.5 GB.
- Senders ahead. The worker pool recovers senders of the next batch while the current batch executes, as geth does with
SenderCacher. - Smaller costs. Bodies come in parts of 32, and the import checks them once.
Figures from PR #76 (shared host, load 29 to 61, about 10 % between runs):
| Run (net2040) | Build | tx/s | Longest loop gap |
|---|---|---|---|
| import path, no network, 13,051..13,800 (449,106 tx) | before | 2,004 | 23.3 s |
| same | after | 2,998 | 13.9 s |
| follower, 10 min, two runs | before | 1,132 / 885 | 33.0 / 33.9 s |
| same | after | 918 / 910 | 11.0 / 14.7 s |
On the follower, blocks per run did not change (2,374 to 2,415): the PR says the live path is bound by the peers' answers and by devp2p decode on the same loop. Peer set-asides fell from 13 or 14 to 0. The geth figure and these figures come from different ranges and loads, so do not compare them.
Open items in the PR: execution, roots and SQLite still run on the Bun loop, and the follower fetches from one peer at a time.
How is the change checked?
Every block root is compared before commit. After the PR, xonets-replay run --to 21600 covered 21,600 blocks and 4,205,493 transactions with 0 mismatches in 1,610 s (PR #76). RUNS.md lists the earlier run of the same range on 2026-11-12. The method is in the replay gate post.
Frequently asked questions
Is group commit durable?
No. A crash loses at most groupCommitMaxBlocks blocks (default 100), and reconcile cuts them. Producers do not use group commit.
Can I roll back the hashed-key migration?
No. There is no reverse migration, and an older binary refuses the migrated datadir. Keep a copy first.
Sources
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-xonets/index.md). GitHub may ask you to sign in. Report a correction opens a prefilled GitHub issue for this page.