Configure a node
Leani reads strict, versioned TOML. Unknown fields are errors. Relative paths
in a configuration file, such as data_dir or an archive source’s
manifest, are relative to that file; path flags on the command line stay
relative to the working directory. Version 1 has two first-class document
shapes: a compact processor-oriented setup with sane network defaults, and
the fully explicit advanced schema. Both become the same validated runtime
configuration. The machine-readable contract is rendered in
the configuration field reference; six
complete advanced lifecycle profiles are checked under config/modes.
For a continuous block-following demo, generate the compact form and a current trusted checkpoint together:
leani init blocksleani doctorFor a local Uniswap node, select one or more markets instead:
leani init uniswap-v3 ETH/USDCleani doctorinit requires a strict majority of the configured checkpoint providers, at
least two of the three defaults, to agree on one finalized slot and root, and
fails closed if a responding provider reports another root at that slot. It
shows every provider’s answer, asks before accepting the checkpoint, writes
leani.toml, and never overwrites an existing file. A global --config PATH selects another output
path. The node keeps its state in data, beside the configuration file,
unless --data-dir names another directory; like any path flag, a relative
--data-dir is relative to the working directory. The generated document
contains only the choices an operator normally needs:
config_version = 1network = "ethereum-mainnet"data_dir = "./data"
[finality]checkpoint = "0x..."checkpoint_slot = 15100000endpoints = [ "https://ethereum-beacon-api.publicnode.com/", "https://lodestar-mainnet.chainsafe.io/",]
[blocks]
[api]bind = "127.0.0.1:18080"The checkpoint and slot above are illustrative; the generator writes the live
quorum result. The checkpoint is the trust root. PublicNode
(https://ethereum-beacon-api.publicnode.com/) and Lodestar
(https://lodestar-mainnet.chainsafe.io/) are ordinary, untrusted Beacon API
transports in the compact profile’s managed transport pool; they do not provide
execution blocks or Uniswap data. init writes that pool plus every responding
checkpoint provider that also serves the Beacon light-client API, without
duplicates. Leani queries the pool concurrently and verifies every response
locally from the pinned checkpoint. After the first verified response it waits
about two seconds for the others, then follows the highest finalized slot that
minimum_agreement endpoints have reached or passed. With the compact
profile’s default agreement of one, the most recent verified finality wins,
and a stalled transport does not hold up node startup. Listing endpoints in
the compact document replaces the managed defaults, which lets operators use
only self-hosted transports.
Compact Ethereum Mainnet configurations expand to:
| Concern | Default |
|---|---|
| execution | native P2P, one-peer availability floor, 16-peer healthy target, 100-peer cap |
| history | public Xatu source, on-demand processor scheduling |
| processor | selected built-in feed, unattested publication, checkpointed state, 256-block undo safety |
| resources | 512 MiB memory, 2 GiB temporary disk, four source and mapper tasks |
| retention | full query output; 64 MiB or 24 hours of change delivery |
| listeners | ephemeral P2P ports, RPC 127.0.0.1:18545/18546, API 127.0.0.1:18080 |
The reviewed expansion is checked in as
config/defaults/ethereum-mainnet.toml.
Use the advanced schema below when any expanded field needs an override.
Validate a file without opening its database or any network connection:
cp config/modes/windowed.toml leani.tomlleani doctor --jsonWithout --config, Leani uses ./leani.toml. Set LEANI_CONFIG when a service
or shell should consistently use a configuration elsewhere. An explicit
--config remains the highest-precedence override. Compact documents
default operational tuning but never the chain identity, checkpoint trust
root, or selected processor targets. Advanced documents keep every choice explicit.
Advanced root fields
| Field | Type | Meaning |
|---|---|---|
config_version |
integer | Must be 1. |
data_dir |
path | Writable node state. Use a distinct directory per deployment or test. A relative path is relative to the directory of the configuration file, not the working directory. |
chain |
table | Portable name and non-zero EIP-155 chain_id. |
artifact_storage |
table | Physical backend and bounded compaction policy for retained processor artifacts. |
budgets |
table | Hard memory, temporary disk, pending-delta, recent-input, and concurrency limits. |
sources |
table | Priority-ordered history sources and the optional live source. |
finality |
table | consensus_p2p, beacon_api, or disabled. |
processors |
array | One immutable processor kind/instance contract per entry. |
rpc |
table | HTTP/WS binds, browser origins, request and response limits, and bounded historical lookup behavior. |
api |
table | Native API bind, bearer-token environment variable, allowed hosts and browser origins, and delivery batching. |
budgets.recent_raw_soft_bytes must not exceed
budgets.recent_raw_hard_bytes. All byte budgets and concurrency values are
positive. Each finality advance prunes finalized recent frames toward the soft
limit. Live ingestion enforces the hard limit as it retains each frame: at the
limit it waits, with live readiness down, until finality prunes (see the
operations runbook’s storage pressure section).
Two budgets bound a history source read’s input. budgets.temporary_disk_bytes
bounds what one read may acquire over its life: the objects, byte ranges,
and responses it fetches, and the frames it builds from them. A read that
would acquire more fails with an input_bytes budget error, and a local
archive object larger than it is refused before it is read.
budgets.memory_bytes bounds the raw input one read holds in memory at once:
one line of a local archive object, which it verifies whole and then reads a
line at a time; the selected columns of one Xatu Parquet row group; one batch
of EraE byte ranges, including a prefetched window when both fit the budget;
one window of execution P2P frames; or one retained raw-history record.
EraE normally targets 16 MiB compressed windows independently of the decoded
frame buffer, and shrinks them when the resident budget is smaller.
Its frames may be at most memory_bytes, or 32 MiB when that is less. A read
that would hold more fails with a resident_bytes budget error naming
budgets.memory_bytes, and a backfill then tries its next configured source.
A historical RPC request, which holds everything it returns, may acquire and
hold at most memory_bytes divided by budgets.source_concurrency.
Raw-history jobs acquire one segment’s worth per read,
raw_history.maximum_segment_logical_bytes.
The bound is per read, not for the node. At once, the node runs up to
budgets.history_pipeline.maximum_active_chunks backfill reads, one archive
reconciliation read, the live lane, and one read per running raw-history
job, and each may hold up to memory_bytes. What a read decodes is not
counted here: decoded frames and mapped deltas have the
budgets.history_material and pipeline budgets below, and memory_bytes is
not a process RSS limit. On mainnet, a Xatu read of a 2023–24 transaction
object, 1,000 blocks around blocks 18,000,000 to 19,430,000, holds about
80–110 MiB of compressed columns and decodes about 230–340 MiB. Keep
memory_bytes above the largest of those, as the shipped 512 MiB is, and
plan RAM for about maximum_active_chunks × 450 MiB during Xatu backfills,
3.6 GiB at the default 8.
The allocator keeps memory a finished read freed for the next, so resident
memory after a burst of reads stays near its peak: set a container’s memory
limit for that peak, not for the budgets. On Linux, glibc gives each thread
that allocates its own 64 MiB arena and returns little of what they free, so
a long-running node grows: an rc.4 node on a 20-thread host held 5.7 GB, 4.7
GB of it in 57 such arenas. Over a 100,000-block blobs-money backfill from
Xatu, glibc reached 1.5 GiB and kept it. The container image preloads
jemalloc, which returns freed memory: the same backfill held 711 MiB and fell
to 130 MiB when it finished. deploy/leani.service sets MALLOC_ARENA_MAX=2,
which held it at 0.8–1.0 GiB. Wherever you run the binary yourself, set
MALLOC_ARENA_MAX=2 or preload jemalloc (Debian’s libjemalloc2 with
LD_PRELOAD=libjemalloc.so.2).
[budgets.history_material] is the sole budget for shared immutable source
frames and acquisition reorder buffers. Chunks read ahead of the one a job is
reading reserve memory only up to half of its memory_bytes, so size it to at
least twice the largest expected frame. The half is checked as each frame is
reserved: a chunk that another job joins, or that its reading job pauses and
leaves while others wait for it, becomes read-ahead with the frames it already
holds, so read-ahead can hold more than half. [budgets.history_pipeline] is
separate: maximum_active_chunks caps physical history reads node-wide and
maximum_mapped_bytes caps processor-owned mapped deltas across every active
job. A job reads only its own planned chunks, so a backfill subscription
smaller than maximum_active_chunks source chunks leaves read slots idle; the
node logs a hint with a size that fills them, once per processor, and
leani_history_material_active_acquisitions against
leani_history_material_active_acquisitions_limit shows the slots in use. Its nested commit table flushes a contiguous SQLite transaction at the
first configured block, domain-change, stable-encoded-byte, or elapsed-time
limit: by default 128 blocks, 10,000 changes, 16 MiB, or 50 ms. A batch
publishes its blocks’ delivery changes in the same transaction. Blocks not
yet finalized, or that the live lane already applied or holds, commit one at a
time, as do the blocks of a batch whose reducer reads windowed output or
rewrites it unchanged. A source error or invalid frame ends a batch after the
blocks before it commit.
target_writer_hold, 250 ms by default, controls the adaptive block limit:
the runtime halves the current limit when observed p95 historical writer hold
time exceeds the target and cautiously grows it again below half the target.
A hold includes the commit’s WAL sync, which a smaller batch does not shorten,
so on a disk whose syncs are slow a low target shrinks batches to a block or
two, and every commit rewrites the same pages for little data. On a Hetzner
EX44 (RAID 1 over two NVMe drives, about 8 ms per sync), the former 20 ms
target ran a rollup-txs backfill from Xatu at 53 blocks/s and wrote 104 KiB
per block; the 250 ms target ran it at 799 blocks/s and wrote 11 KiB per
block. A job holds at most maximum_buffered_frames_per_acquisition frames
of [budgets.history_material], 128 by default, that it has not committed,
so a longer maximum_delay makes acquisition wait instead of filling larger
batches: a one-second delay ran the same backfill at 127 blocks/s. A single
mapped delta or single-block commit that exceeds its byte limit fails
immediately because waiting cannot make that item fit. A backfill that reuses
retained recent frames reads them in batches of at most commit.maximum_blocks
blocks and maximum_mapped_bytes encoded bytes, each holding one active-chunk
slot like a physical history read.
[budgets.store].maximum_physical_bytes is the emergency node-store admission
bound shared by SQLite, its WAL, and configured processor-artifact segments.
It is deliberately not nested under delivery: artifacts, materialized views,
correctness metadata, and delivery share the same physical capacity. It
measures allocated file bytes. Stores use SQLite incremental auto-vacuum, so
pruning returns pages to the filesystem and admission recovers; a store created
before that needs one leani db compact (see the operations runbook).
[budgets.artifacts] independently caps immutable finalized artifact bytes and
unfinalized-block candidate bytes awaiting finality. A transaction that would exceed
either logical limit rolls back without advancing processor coverage. Finality
is the exception: when the retained limit is full, finality still advances,
the finalized candidates stay pending with a logged warning, and a later
finality advance promotes them once there is room. A processor’s window
policy may prune its own instance below a tighter bound; the node-wide
artifact budget still protects against the aggregate of many processors.
[artifact_storage] backend = "sqlite" retains those immutable artifacts as
KV rows. The opt-in tiered_segments backend uses SQLite as the durable write
buffer and continuously compacts complete segment_target_blocks ranges into
checksummed seekable files under data_dir/processor-artifacts. Compression,
single-item/segment hard limits, compaction cadence, and maximum segments per
cycle are explicit. Only processors using artifacts.mode = "full" are
eligible in the first tiered implementation; rolling windows remain in
SQLite. Segment bytes do not receive a second budget: they count against the
same [budgets.store].maximum_physical_bytes ceiling.
[raw_history] keeps finalized source frames in checksummed segment files
under data_dir/raw-history, beside their own catalog. maximum_logical_bytes
caps the frames retained, and maximum_physical_bytes the bytes on disk:
segments, the partial files of segments being written, the catalog and its
write-ahead log, and quarantined files. Before a segment is written, its
maximum size and the catalog rows it will add are reserved: a fixed 64 KiB,
and 512 bytes per locator row, one per block with block-hash locators and 256
per block with transaction-hash locators. A segment with more transactions
still publishes its extra rows, so the catalog can exceed
maximum_physical_bytes by that one segment’s extra rows, which the next
segment’s admission counts. Reads of maximum_segment_logical_bytes, one
segment’s worth, feed a raw-history job. A store already over either budget,
for example after lowering it, still opens and serves its history, but
admits no new segment until it is back under: its jobs pause at the storage
limit, or fail when created with segment.onLimit: fail. A segment that
fails verification moves to data_dir/raw-history/quarantine, which keeps
at most 1 GiB, or one segment of maximum_segment_physical_bytes when that
is larger: past that, the longest-quarantined files are deleted first, but
never the newest (see the operations runbook).
[budgets.delivery] supplies the shared delivery envelope above independent
per-stream limits. maximum_retained_bytes caps exact delivery payload bytes
across live and history streams. maximum_history_retained_bytes is a lower,
non-borrowable history ceiling so stalled backfills cannot consume live-lane
headroom. The two values must satisfy history <= retained. Reaching a shared
bound backpressures only the committing lane; ACK, pruning, queries,
networking, and unrelated processors remain available. The backfill
subscriptions of a replaced processor instance keep counting against the
history ceiling until they are deleted (see the runbook’s
Processor rebuild or rollback).
Sources and finality
[[sources.history]] requires id, kind, priority, and trust.
Supported kinds are xatu, era_e, and archive. xatu reads public
Parquet history; validation refuses parquet, which no source reads. archive
requires manifest; era_e alone accepts an https or file endpoint
ending in /. A plain http endpoint is accepted on loopback, or elsewhere
only with allow_insecure_http = true on that source, which accepts a mirror
whose catalog and archive bytes anyone on the path can replace. xatu
sources are Ethereum mainnet only: another chain.chain_id fails
validation.
History source order
A backfill splits its range into gaps of missing coverage and tries the
configured history sources for each gap in priority order, lowest first. A
source that fails on part of a gap passes only those blocks to the next
source: a dataset hole, from its first to its last block in the chunk read, or
the chunk whose read failed. It keeps the blocks before them, and the first
source takes the gap back after them. A source that has not published the
blocks, whose schema changed, or that fails again before committing a block
passes the rest of the gap instead, and one that cannot fill the processor’s
field groups is skipped for the job.
Each backfill retries up to three times the number of sources, at least five
times, before it fails. When every history source fails and the live source is execution
P2P, P2P can serve the gap within sources.live.history_fallback_blocks.
The shipped profiles list eraE at priority 10 and Xatu at priority 20:
| Source | Serves | Checks each block for | Notes |
|---|---|---|---|
era_e |
Whole blocks and receipts up to its mirror’s last catalog update | Transactions and receipts roots against the header | The public mirror’s catalog ended at block 24,518,655 (2026-02-26) when this was written |
xatu |
Columnar projections, about one day behind the chain | Transaction rows against the beacon payload count; log rows without a gap | Cannot prove a block’s last logs complete; Ethereum mainnet only |
| P2P (live source) | Recent and live blocks from peers | Commitments of every header, body, and receipt | Slowest for old history; peers may no longer serve very old blocks |
So for blocks the eraE mirror covers, a backfill reads verified complete blocks; for later blocks, Xatu; and P2P fills what neither serves. Put Xatu first when bandwidth, not completeness, limits a backfill: its projections transfer fewer bytes per block for processors that read a few columns.
Sizing for bandwidth, memory, and disk
Measured with this release on a Hetzner EX44 (Intel i5-13500, 20 threads,
64 GB, RAID 1 over two NVMe drives, 1 Gbit/s) that other workloads share,
25,000-block backfills with one consumer and MALLOC_ARENA_MAX=2:
| Backfill | Source | Downloaded per block | 4 reads, 2 chunks | 8 reads, 4 chunks |
|---|---|---|---|---|
rollup-txs, Feb 2026 |
eraE | 122 KiB | 250–300 blocks/s, 170 MiB | 250–330 blocks/s, 280 MiB |
blobs-money, Feb 2026 |
eraE | 122 KiB | 280 blocks/s, 160 MiB | 250–300 blocks/s, 250 MiB |
rollup-txs, Feb 2026 |
Xatu | 61 KiB | 550–560 blocks/s, 230 MiB | 730–780 blocks/s, 340 MiB |
blobs-money, Feb 2026 |
Xatu | 72 KiB | 410–420 blocks/s, 560 MiB | 500–570 blocks/s, 730 MiB |
rollup-txs, Oct 2026 |
Xatu | 56 KiB | 490 blocks/s, 290 MiB | 620–890 blocks/s, 420 MiB |
blobs-money, Oct 2026 |
Xatu | 46 KiB | 570 blocks/s, 420 MiB | 470–650 blocks/s, 620 MiB |
“4 reads, 2 chunks” is source_concurrency = 4 and maximum_active_chunks = 2, as deploy/container.toml ships; the other shipped profiles read with 8
and keep the built-in 8 chunks. Each cell gives the speed and the peak
memory. Ranges span repeated runs, which differed by up to a third with the
archives’ throughput and the shared host’s load. With jemalloc, which the
container image preloads, peak memory stayed within a fifth of these and fell
back once a backfill finished. Execution P2P served 5,000 recent blocks at
43–55 blocks/s.
More reads and chunks made most Xatu backfills faster, at more memory, and
left eraE unchanged. An eraE backfill is limited by the frames it may hold
uncommitted: with maximum_buffered_frames_per_acquisition = 512 and
memory_bytes = "512MiB" in [budgets.history_material], eraE backfills ran
at 380–410 blocks/s, a quarter to a half faster, at the same peak memory.
Profiles:
- Default: the shipped profiles, which try eraE before Xatu and read up to 8 chunks at once with 8 reads each.
- Faster eraE backfills: set those two
[budgets.history_material]values. Concurrent backfills share that budget, so each can then hold more of it. - Less memory: set
maximum_active_chunks = 4, the “8 reads, 4 chunks” column. Xatu backfills then hold about half as much, and execution P2P backfills fetch about half as much at once. - Limited memory, such as a container under 1 GiB: use 4 reads and 2 chunks. eraE needs the least memory.
- Limited bandwidth: a backfill downloads blocks per second times bytes
per block, about 35 MiB/s from eraE at 300 blocks/s and 25–50 MiB/s from
Xatu at the speeds above. On a slower link, the link sets the speed: at
100 Mbit/s, about 12 MiB/s, eraE reaches about 100 blocks/s and Xatu
170–270. There, list Xatu first for processors that read only
transactions, such as
rollup-txsandtransaction-stats, and keep eraE first for processors that read logs, which Xatu cannot prove complete. - Slow disk syncs, such as network-attached volumes: keep
target_writer_holdat 250 ms or raise it. A lower target shrinks batches (see Advanced root fields).
Xatu additionally accepts three acquisition-tuning controls. chunk_blocks
sets the generic header, transaction, and log span, while
blobs_chunk_blocks sets the wider span used by the five-table blobs join.
Both must be non-zero multiples of Xatu’s physical 1,000-block execution
partition. batch_rows controls the bounded Arrow/Parquet decode batch and is
independent of history-source concurrency. The measured defaults are 1,000,
8,000, and 8,192 respectively. Increasing a span can reduce repeated daily
object reads, but it also increases per-chunk memory and is not universally
faster; tune it per projection rather than as a global block-size knob.
[sources.live] kind = "p2p" enables the shared persistent Reth peer manager.
It requires verified beacon_api or consensus_p2p finality. Stable
production ports normally use listener_port = 30303,
discovery_port = 30303, and discv5_port = 30304. Discv4 and Discv5
require distinct fixed UDP ports; zero selects an isolated ephemeral port for
that protocol. stall_timeout_seconds, ten minutes by default and six minutes
to 30 days, bounds how long the live lane may stay not ready while verified
finality is ready, within one run of the network lanes: after it, the node
exits with status 1 so that its process supervisor restarts it. A wait at the
recent-frame hard limit does not count.
Reth owns the bounded dial queue and receives TCP/RLPx failures directly. A
terminal failure immediately releases its slot to another eligible peer, while
Reth’s per-peer backoff prevents rapid redials. Newly authenticated DNS/Discv
candidates are added to that same queue. max_concurrent_dials remains the
hard simultaneous dial ceiling; peer_refill_interval_ms is only a short
safety-net poll for newly added candidates, not a network request rate.
minimum_peers is the connected-session startup gate. A peer need not finish
capability qualification before requests may use it: every response is still
commitment-checked, and qualification ranks proven peers first. Background
qualification continues concurrently until
body_serving_peer_target distinct peers have served a
commitment-valid body at the current target; the default target is four.
Request health is tracked independently for headers, bodies, and receipts. A
timeout or incomplete response cools only that material lane, so a peer that
cannot serve one processor’s body request remains available to processors that
can use its headers or receipts. Commitment-invalid data still bans the peer.
bootstrap_dns_tree may point at an optional EIP-1459 enrtree:// peer-hint
tree. The public key embedded in that URL authenticates its ENRs. It does not
make those peers trusted sources: Leani still commitment-checks every header,
body, and receipt. This is the deployment hook for a future Leani/community
pool of execution nodes prepared to accept indexing traffic; compact and
advanced configurations leave it unset by default.
Finality checkpoints are bootstrap trust anchors, not evergreen config
defaults. The first start needs a checkpoint at most 14 days old; later starts
bootstrap from the newest verified anchor the node persisted (see the
runbook’s checkpoint lifecycle).
Enabled finality requires a recent 32-byte checkpoint root other than all
zeros. consensus_p2p also requires its non-zero finalized beacon slot and a
finality.minimum_peers of at most 24, the consensus peers dialed at once.
beacon_api requires one or more endpoints, each transport listed once, and
minimum_agreement within their count. Disabling finality is suitable for bounded finalized-dataset
backfills, not verified head following: the live lane includes a block only
once its ancestry reaches an execution header the sync committee attested,
which the finality source verifies each slot, so validation refuses
sources.live.kind = "p2p" with finality.kind = "disabled".
Processor identity and publication
Every modern entry declares:
[[processors]]id = "evm-events" # registered processor kindinstance = "weth-transfer-v1" # stable operator-selected identityversion = "1.1.0"start_block = 12965000Processor code, semantic version, settings, start point, and source identity
are part of an instance’s immutable identity. The node refuses a change to any
of them under an instance its store holds (processor instance … conflicts with its stored descriptor), before it upgrades an older store, so it never
mutates an existing cursor namespace. Give the changed processor a new
instance ID: it indexes from its start_block beside the old instance,
whose rows stay in the store. Settle the old instance’s backfill subscriptions
before and after the switch, as the runbook’s
Processor rebuild or rollback
describes. Lifecycle policies, such as retention and delivery limits, are not
part of the identity and can change in place.
Publication values are:
unattested(default): publish apply/undo/finality transitions from the first level the live lane delivers, and keep an unfinalized undo window.attested: publish apply/undo/finality transitions fromattestedon, and keep an unfinalized undo window.finalized: publish only finalizedapplyrecords. Unfinalized changes wait in the store for verified finality, count against the delivery budget meanwhile, and undo is never published.
[processors.delivery] emit = "job_result" replaces publish for a bounded
backfill: it publishes its terminal aggregate and then becomes
complete/reclaimable. Deletion remains explicit.
[processors.settings] belongs to the selected factory and is decoded
strictly. doctor constructs every configured processor, so malformed ABI,
address, pool, or custom settings fail before source or store I/O.
Live gap fill
After downtime the live lane resumes at the newest verified finalized anchor,
so a block-local processor skips the blocks between its cursor and that
anchor. Automatic history refills them. With history_mode = "on_demand" the
lane only announces them, in a system.live_gap.put change, and whoever
requests history fills them. [processors.live_gap_fill] lets the lane fill a
short range itself:
[[processors]]id = "blobs-money"instance = "blobs-production"history_control = "application_subscriptions"history_mode = "on_demand"# ...
[processors.delivery]mode = "until_acknowledged"on_limit = "pause" # the default, and the only value a fill accepts# ...
[processors.live_gap_fill]max_blocks = 7200 # fill a skipped range of up to about a day; announce longer onesBefore the lane applies the block that follows a skipped range, live or
replayed at startup, it recovers the range from the processor’s history
sources, 128 blocks at a time, and applies each block, oldest first, as a
finalized apply with originKind: "live_recovery". Then it applies the
block itself. The live stream stays contiguous and in block order, announces
nothing for the range, and the processor’s coverage holds the filled blocks as
finalized. The
change protocol
describes what a consumer sees, and
Keep your application in sync
what an application still does.
max_blocks is required and takes 1 to 7,200, about a day of slots. The table
has no other setting. Validation refuses it for a processor that is not
history_mode = "on_demand", because automatic history refills skipped
blocks, and for an ordered processor, such as transaction-stats or
uniswap-latest, which holds later blocks instead of skipping them. It also
requires [processors.delivery] on_limit = "pause", which is the default:
validation refuses fail and expire_and_reset, because a fill’s burst of
blocks could fail the lane, or reset its consumers, where announcing the
blocks would not. It is allowed under both history_control values.
The lane announces a range as it does without the table, and fetches nothing,
when the range is longer than max_blocks, when no history source serves
finalized blocks, or when the range is not finalized yet. The limit is all or
nothing, because blocks filled at the top of a range could not link to the
cursor. A fill that starts stops at the first block it cannot fill, and the
lane announces from there. That happens when history fails or serves too few
blocks; when a block is not finalized, does not link by parent hash to the one
before it, is not the canonical block at its height, or lacks material the
processor requires; and when the time limit passes. The limit is five
minutes and is fixed. The fill starts no request after it and gives up a
request still running at it, so a fill runs for at most five minutes plus
applying the blocks of its last request. A filled block is as trusted as the
same block delivered by a fill_missing subscription, which uses the same
sources and verification policy. The filled blocks link by parent hash from
the cursor’s block through the range and, when the fill completes, to the
block that follows.
The sources are the processor’s history sources, in the order a history
request tries them: retained raw history ([raw_history], when enabled), the
configured [[sources.history]] by priority, and last the execution P2P
bridge, within sources.live.history_fallback_blocks. The bridge is available
to a fill during the replay that follows a restart, too. Bandwidth depends on
the source. A dataset source such as Xatu serves only the material the processor
needs, about a kilobyte per block for blobs-money. Execution P2P transfers
whole bodies and receipts, on the order of a megabyte per block on Mainnet
today, so a few thousand blocks are gigabytes. Configure a dataset source for
a processor that sets the table. Requests run one after another and do not
coalesce, so a fill is slower than a pipelined backfill subscription over the
same range. sources.live.kind = "disabled" leaves no live lane, so the table
does nothing, and require_retained_input = true limits a fill to retained
raw history, which usually does not hold the range: the lane announces it.
The live lane is shared. While a fill runs, it takes no other block: every
processor’s live lane waits, the head that JSON-RPC reports stops advancing,
and WebSocket subscriptions publish nothing new. That happens after the live
lane was not running for a while, and max_blocks and the time limit bound
it. Several processors with the table fill one after another, and each
fetches the blocks again.
Filled blocks are ordinary delivery records under the processor’s delivery
policy, and count against delivery.max_bytes and the node-wide
[budgets.delivery] and [budgets.store] limits. A lane that reaches one of
those limits mid-fill stops there. It holds the block after the range and
announces nothing. While the store would refuse that block, because the lane
is paused above its stream’s resume mark (90 % of max_bytes), a node-wide
budget has no room for it, or the lane is paused at a storage limit while
storage stays above its low-water mark, the lane fetches no history for the
fill, as long as the block after the range is among the retained frames.
Once finality prunes that frame during a long hold, the lane recovers it from
history on every live block, as any paused lane recovers its first unapplied
block. After a restart of the live lanes, a lane held at a node-wide budget
can fetch once more before it waits again. The lane continues the fill once
the store would take the block: once consumers acknowledge and pruning brings
its stream down to the resume mark, once pruning any stream gives a node-wide
budget room, or once storage has room. The unfilled rest is recomputed from
the processor’s cursor on resume, so a history commit that an application’s
subscription lands above the cursor meanwhile can shorten what the lane fills
or announces. That is rare, and idempotent writes and the application’s
coverage diff cover it. A single filled block larger than the stream’s
max_bytes fails the lane, as an equally large live block would: raise
max_bytes or remove the table. A fill also makes a pause at a delivery or
storage limit more likely, and a held block’s delta counts against the shared
[budgets] pending_delta_bytes; when it does not fit, the lane fails
(live_gap_marker_exceeds_pending_delta_budget) until the budget is raised and
the lane reset. The
runbook lists the log
lines and the recovery.
The table is not part of the instance’s identity, so add, change, or remove it in place, as with the lifecycle policies below. A release that predates the table refuses a configuration that has it, so remove it before rolling back.
Orthogonal lifecycle policies
Every processor has six independent lifecycle policies. The pre-launch
retention shorthand has been removed so artifact, output, delivery, state,
checkpoint, and undo semantics cannot be inferred from one overloaded setting.
State
[processors.state] mode is durable or checkpointed. The store persists
processor state in both modes, and [processors.checkpoint] alone decides
whether recovery checkpoints are taken: checkpointed requires
[processors.checkpoint] mode = "automatic". ephemeral is rejected, because
no mode discards processor state.
Artifacts
[processors.artifacts] mode is none, window, or full. Artifacts are
immutable finalized map results used for bulk scan, export, and compatible
replay. They are independent of queryable output and delivery records; the
default is none.
window requires exactly one [processors.artifacts.window] limit:
max_blocks, max_age, or max_bytes. Full retention is removed only by an
explicit artifact deletion operation.
Output
[processors.output] mode is:
none: retain no queryable entity material;latest: retain the current entity value per key;window: retain the declared block, wall-clock age, row, or byte window;full: retain all selected queryable output.
window requires a [processors.output.window] table with exactly one
limit: max_blocks, max_age, max_rows, or max_bytes. Validation
refuses [processors.output] finalized_only, which was never applied; to
publish only finalized blocks, set publish = "finalized".
Delivery
[processors.delivery] mode is best_effort, window, or
until_acknowledged. Byte sizes accept integers or B, KiB, MiB, GiB,
KB, MB, and GB strings. Durations accept integer seconds or s, m,
h, and d strings.
until_acknowledged requires at least one required
[[processors.delivery.consumers]], each with a lease_ttl from one second
to 100 years. Required consumers protect pruning until
they are explicitly expired/reset; a temporarily lapsed lease does not
silently advance the watermark. on_limit is pause, fail, or
expire_and_reset. Paused instances automatically resume after
acknowledgement/pruning moves below the low-water mark.
Pruning is cumulative and block-aligned:
[processors.delivery.pruning]interval = "30s"minimum_batch_blocks = 64minimum_batch_changes = 10000maximum_delete_changes = 10000retain_finalized_blocks = 256retain_acknowledged_age = "1h"max_age and retain_acknowledged_age use durable node wall-clock
enqueue/acknowledgement time, not Ethereum block timestamps, so the changes a
backfill enqueues count as new.
A pass deletes at most maximum_delete_changes. A pass that deletes that many
is followed at once by the next, and a stream that pauses at its limit is
pruned at once, without waiting for interval.
Checkpoint and undo
[processors.checkpoint] mode is none or automatic. Automatic mode
checkpoints processor state at most once per 1,000 finalized blocks, and again
when a historical job completes, and keeps the newest positive keep count.
A restore repairs state only when the processor’s cursor sits exactly on a
checkpoint: a cadence block or the end of a completed job, even if finality
reached that block later. A processor between checkpoints cannot be restored
from one; restore a full-store backup or rebuild the processor. Portable
savepoints are exports, created and deleted explicitly through the API; the
node cannot restore them, and automatic checkpoint pruning does not cover
them. Each processor instance keeps at most 16, and each new one counts
against the physical store budget, which answers 507 when it does not fit.
Editing lifecycle policies, for example to raise a limit, leaves existing
checkpoints and savepoints valid.
[processors.undo] mode is none or unfinalized, and
unattested and attested publication require unfinalized. Unfinalized blocks always
keep their undo records, so a reorg can revert them in either mode. Once
finality passes a block, unfinalized keeps its record for another
safety_blocks blocks and none deletes it at once. Blocks applied as
already finalized record no undo.
API and credentials
Set api.bearer_token_env to the name of an environment variable, never a
token value. When configured, that one bearer protects native read, stream,
consumer, and management routes on the API listener; operational health,
metrics, and network-dashboard routes remain unauthenticated. serve fails
to start when the variable is unset, holds fewer than 16 characters, or holds
anything but printable ASCII without spaces; use a random value such as
openssl rand -hex 32. Clients send
Authorization: Bearer <token>, with the scheme in any case, and the node
compares tokens in constant time. A consumer created through the API carries
its own credential, and a backfill subscription’s consumer can; a new one
holds 32 to 512 printable ASCII characters without spaces, such as
openssl rand -hex 32. Every consumer-scoped call for a consumer with a
credential, its streams included, then needs it in
x-leani-consumer-credential besides the bearer, and gets 403 without it, so
one client cannot move another consumer’s cursor. Listing, inspecting, and
revoking consumers stay administrative: only the bearer protects them, so an
operator can revoke a consumer whose credential is lost. Consumers
declared under [[processors.delivery.consumers]] have no credential and
need only the bearer. The store keeps credential hashes and session-token
keys under a random per-store secret, which backups carry with the store. A
backfill subscription’s credential also enters its idempotency identity, an
unkeyed hash of the whole request, so use random credentials.
The native API, HTTP RPC, and WebSocket RPC binds must all differ. RPC has no
built-in authentication. A bind beyond loopback, such as 0.0.0.0, fails
validation unless it is protected or explicitly opted in:
| Listener | Beyond loopback requires |
|---|---|
api.bind |
api.bearer_token_env, or api.allow_unauthenticated_remote = true |
rpc.http_bind, rpc.ws_bind |
rpc.allow_unauthenticated_remote = true |
Opt in only behind a trusted network or an authenticating proxy. Put remote
listeners behind a TLS/authenticating proxy and restrict the unauthenticated
operational routes to a monitoring network. The compact configuration has no
bearer setting, so its [api] bind stays on loopback; use the advanced
schema to serve beyond it.
Browser access
The listeners refuse most requests a web page could make through a visitor’s browser:
- The native API answers a
Hostthat is an IP address,localhost, or a name inapi.allowed_hosts, and answers any other name with 421. A page that points its own name at 127.0.0.1 still sends that name; an address cannot be repointed, so health probes and scrapers that call the node by IP keep working. List every name clients use to reach the API, such as a compose service name or the name a reverse proxy forwards, without a scheme or port:allowed_hosts = ["leani", "indexer.internal"]. - Browsers send an
Originheader on CORS requests, such asfetch()calls to another origin, event streams, and WebSocket upgrades, and on POST and DELETE requests. They send none on navigations or on no-cors GET and HEAD requests, such as an image or script load. Both listeners accept requests without anOrigin, and requests from loopback origins on any port. Any other origin gets 403. A browser application served from another origin, including one behind the same reverse proxy, needs its origin inapi.allowed_originsorrpc.allowed_origins, such as["https://app.example"]. The node sets no CORS headers; a cross-origin application still needs a proxy that adds them and allows thecontent-typeandx-leani-requestrequest headers. - The native API refuses, with 403
cross_site_request, a GET or HEAD without anOriginthat the browser’s Fetch Metadata marks as coming from another site’s page (Sec-Fetch-Site: cross-siteorsame-site) and not as a navigation (Sec-Fetch-Modeother thannavigate). A consumer’s live or backfill stream route refuses such a request even as a navigation, since opening the stream takes the consumer’s session lease. Current browsers send Fetch Metadata only to potentially trustworthy URLs, which are HTTPS, loopback addresses, andlocalhost; a node reached over plain HTTP by another name or address, such as a LAN IP withallow_unauthenticated_remote, gets none, and neither refusal applies there. The SDK and curl send none either, and pass. - A native API POST or DELETE needs
content-type: application/jsonor the headerx-leani-request: 1, and gets 415 otherwise. JSON-RPC calls needcontent-type: application/json. The SDK sends both. A cross-site page can send neither without a CORS preflight, which the node never grants. - Only
POST /v1/processors/{processor}/collections/{collection}/entitiescreates a query snapshot. A GET without a cursor reads current output without one.
What still reaches the native API from a page: navigations from another
site, including into a frame, to routes other than consumer streams, and
GET or HEAD requests without Fetch Metadata: from browsers without it, or to
a node served over plain HTTP by a name or address other than loopback or
localhost. The page cannot read the responses, but without Fetch Metadata
a GET that opens a consumer’s live or backfill stream takes that consumer’s
session lease. A consumer’s credential keeps pages from opening its stream,
and api.bearer_token_env keeps them from opening any; a browser adds
neither header on its own.
When upgrading a relative data_dir, Leani refuses to start if its former
working-directory location contains a database or embedded subscriptions
while the location beside the configuration holds none: it would otherwise
start on an empty store. Move the old state, or set an absolute path to
select the intended store. When both locations hold state, it uses the one
beside the configuration and warns, naming both. Equivalent paths and
symlink aliases of the same directory count as one.
Historical CLI and node backfill
leani backfill --processor INSTANCE --from-block N --to-block M uses the
same history source assembly, configured pipeline, material coordinator and
verified P2P fallback as node jobs. --processor defaults to the
configuration’s only processor. When live P2P is enabled, the standalone command
verifies the configured finality anchor and opens its own persistent peer
pool. It checks the finalized upper bound and honors
sources.live.history_fallback_blocks. No live listeners or processors are
started. Disabled P2P and retained-input-only policies remain in force.
To use a running node and its existing peer pool:
leani backfill --endpoint http://127.0.0.1:18080 \ --processor INSTANCE --from-block N --to-block MThe command sends an authenticated materialization request and waits for its
status. LEANI_API_TOKEN supplies the token without putting it in shell
arguments. The token travels in an Authorization header, so across hosts use
HTTPS, for example through a TLS-terminating reverse proxy; plain HTTP suits
loopback or a private container network, and the command warns when it sends
the token over plain HTTP beyond loopback. An optional endpoint path prefix is preserved. Ctrl-C cancels
the submitted job. Without --endpoint, stop any process holding the data
directory before starting standalone backfill. Ctrl-C cancels a standalone
run, so the next serve does not resume it; a second Ctrl-C exits at once.
A standalone job is named by processor, chain and range. When an earlier run
of the same range recorded another verification policy, for example before
raw_history was enabled, the store refuses the new input as a different
job. Delete the earlier one with DELETE /admin/v1/materialization-jobs/{id},
or submit through --endpoint, which uses a fresh key per submission.
Xatu exports have table-specific coverage. In particular, a recent beacon file does not prove that the execution-transaction file needed for receipts has been published. Missing or incomplete partitions can fall back to the verified P2P bridge; dataset-only configurations need another available history source. Finality or peer failures are reported rather than silently removing the configured bridge.
JSON-RPC limits
Each limit has a rpc setting:
| Setting | Default | When exceeded |
|---|---|---|
max_batch_requests |
100 | the batch is refused whole with one -32600 error |
max_response_bytes |
16MiB |
each call past the limit is answered with -32005 |
max_log_results |
10000 | eth_getLogs fails with -32005 and a smaller block range to retry |
max_log_addresses |
1000 | the filter fails with -32602 |
max_log_topic_alternatives |
1000 | the filter fails with -32602 |
max_subscriptions_per_connection |
128 | eth_subscribe fails with -32005 |
max_websocket_connections |
256 | the WebSocket upgrade gets HTTP 503 |
max_outbound_bytes |
64MiB |
queued notifications of all WebSocket connections; when full, the connection holding the most is closed with 1013 |
max_subscription_event_bytes |
16MiB |
a WebSocket connection whose subscriptions produce more notification bytes for one chain event is closed with 1008, and one that leaves more unread with 1013 |
A closed WebSocket connection releases its slot and its subscriptions.
Notifications are queued one at a time, so while earlier ones are still
unsent, a connection can be closed with 1013 before one event’s
notifications pass the limit that would close it with 1008.
Each connection holds at most max_subscription_event_bytes of queued
notifications and one response to a call; it reads its next call only once
that response is sent, so a slow reader slows only itself. A connection that
keeps more than a quarter of its notification room queued for 30 seconds is
closed with 1013: it reads slower than its subscriptions produce. All
WebSocket listeners also share max_outbound_bytes of queued or sending
notifications (64 MiB by default). When that runs out, the connection
holding the most of it is closed with 1013, not the one that notifies
next; responses never use it. A send may take 30 seconds plus the time its
payload needs at 64 KiB/s before the connection is closed. These are
encoded-payload limits, not process RSS limits: at the defaults, responses
add at most max_websocket_connections × max_response_bytes.
max_outbound_bytes must be at least max_subscription_event_bytes and at
most 4,294,967,295 bytes.
rpc.transaction_locator is deprecated and ignored: nothing reads it. The
node still accepts it, with a warning in its log and in leani doctor;
remove it.
Delivery batching
History and live delivery use independent transport limits under
api.delivery.history_batches and api.delivery.live_batches. The defaults
are byte-first (4MiB history, 64KiB live), with hard encoded-byte, event,
processed-block, and maximum-delay bounds. History subscriptions may request
smaller effective limits; those values are persisted with the subscription and
returned by its status endpoint. maximum_buffered_batches and
maximum_buffered_bytes independently cap the per-connection encoder queue;
backpressure stops the encoder producer before either bound is exceeded.
SDK delivery sessions refuse an NDJSON record over 65 MiB with a
non-retryable invalid_response error. A record carries up to
maximum_encoded_bytes of changes plus its own fields, so for SDK clients
keep each lane’s maximum_encoded_bytes at or below 64 MiB; raising it, and
maximum_buffered_bytes with it, beyond that lets a record exceed what the
SDK accepts.
Opening a history stream may request only stricter byte/event/block/delay
limits through query parameters; these are connection-local and do not mutate
the durable subscription. A live stream, including one the SDK’s
streamLive() opens, takes no such parameters and uses live_batches as
configured. The SDK’s latency, balanced, and throughput
profiles are convenience functions that expand to those concrete numbers and
are resent after reconnect.
compression = "gzip" is negotiated through
Accept-Encoding; the response is one continuous gzip member with an
explicit sync flush after every NDJSON record, so batches and heartbeats are
observable immediately by streaming decoders. Set it to "none" to disable
compression for that lane.
Pre-launch schema policy
The project is not live yet and has no compatibility contract for existing consumers, config files, or databases. Configuration, SDK, API, and SQLite schemas may be replaced when that produces a cleaner final design; development databases should be recreated after an announced incompatible change. Durable delivery and processor encodings remain explicitly versioned so upgrade-safe replay can become a launch requirement without changing the public cursor model.
Built by Smart-Byte · Legal notice (Impressum) · Privacy