Operations runbook
Deployment profiles
deploy/container.toml is a safe historical-only profile. It starts the query
API plus HTTP and WebSocket RPC on all container interfaces, but Docker Compose
publishes them only on loopback. It does not claim live or finality readiness.
Its processor uses on-demand history, so nothing is indexed until you run a
bounded backfill against the same data volume. serve holds the data
directory’s lock, and a backfill beside it fails with runtime data directory /var/lib/leani is already in use by another Leani process, so stop the service
first. docker compose run mounts the service’s volume, and its arguments
replace the image’s serve command, so name the profile with --config:
docker compose stop leanidocker compose run --rm leani backfill --config /etc/leani/node.toml \ --processor blobs-money --from-block 19426589 --to-block 19427588docker compose up -d leaniKeep LEANI_API_TOKEN exported for these commands too: Compose resolves it
for every command. The processor starts at block 19,426,589 and is
block-local, so later ranges may follow in any order. Then query the range.
Subscriptions remain quiet until a live source commits events.
A live-enabled node with a verified finalized head also accepts the CLI over its native API, without stopping it:
leani backfill --endpoint http://127.0.0.1:18080 \ --processor blobs-money --from-block 19426589 --to-block 19427588The standalone form attaches the same verified P2P history fallback when
sources.live.kind = "p2p"; the historical-only container profile above
leaves P2P disabled. Use a live-enabled configuration when missing Xatu
partitions must be recovered from peers.
The profile protects the API with a bearer token from LEANI_API_TOKEN:
serve fails to start without one of at least 16 printable ASCII characters
without spaces, so export it, for example
export LEANI_API_TOKEN="$(openssl rand -hex 32)", before docker compose up,
which stops with an error when it is unset, or pass it with
docker run --env LEANI_API_TOKEN. The
container’s health check calls /health/live, which needs no token. The
profile opts JSON-RPC, which has no authentication, into its all-interface
binds with rpc.allow_unauthenticated_remote; keep its ports on loopback or
behind an authenticating proxy. The API answers only Host names in
api.allowed_hosts, besides localhost and IP addresses; the profile lists
the compose service name leani, so other services can call
http://leani:8080. To add the names your clients use, mount a copy of the
profile with them over /etc/leani/node.toml.
The profile runs blobs-money as the instance blobs-container-1-6. A new
processor version that changes the processor’s identity takes a new instance
ID, because the store refuses another version under an instance it holds.
Version 0.1.0-rc.1 ran blobs-money 1.4.0 as blobs-container, and rc.2 to
rc.8 ran 1.5.0 as blobs-container-1-5, so on an earlier volume the new image
starts beside that instance and leaves it inert: nothing indexes or serves
it, but its rows stay in the volume and count toward
[budgets.store] maximum_physical_bytes. This upgrade is itself an instance
replacement: backfill subscriptions on the earlier instance show
processorConfigured: false and keep counting against
[budgets.delivery] maximum_history_retained_bytes until you settle them as
step 2 of Processor rebuild or rollback
describes. For a clean slate, remove the volume with docker compose down -v
before docker compose up --build, or restore a snapshot of the volume.
The profile runs at most two history chunks at once
([budgets.history_pipeline] maximum_active_chunks = 2): a Xatu backfill
holds about 450 MiB per active chunk, so two fit its 1 GiB memory_bytes
budget and leave the rest of the compose file’s 2 GiB container limit to the
node.
The container’s root filesystem is read-only. The image points
SQLITE_TMPDIR and TMPDIR at /tmp/leani, where SQLite spills statement
journals and sorts that outgrow memory, and the compose file mounts a tmpfs
there that the node’s user can write. Without a writable temporary directory,
large statements fail with disk I/O error (SQLite code 6410), such as a
finality advance over many blocks or a peer store refresh; mount one when you
run the image another way.
For a direct binary deployment:
- install the binary as
/usr/local/bin/leani; - create an
leanisystem user; - place a validated config at
/etc/leani/node.toml; - install
deploy/leani.service; - give the service user write access only to the configured data directory.
Keep bearer tokens in /etc/leani/environment, not in TOML. Public
exposure should go through a TLS reverse proxy with request and connection
limits.
Health and metrics
GET /health/livechecks that the process serves requests and SQLite answers a trivial query. It reads no tables, so it stays fast on large stores.GET /health/readyadditionally checks every required live/finality actor from in-memory readiness state.GET /debug/networkserves the built-in P2P and coverage dashboard.GET /v1/network/statusreturns the dashboard’s machine-readable snapshot.GET /metricsreturns bounded-cardinality Prometheus text metrics. Store-wide and per-processor row and byte counts are recounted at most every 10 seconds and can lag by that much./v1/network/statusshares the per-processor counts.GET /v1/processors/{id}/statusreports coverage independently of process health.
A historical-only node may be ready while a requested range is incomplete;
the range query still fails with range_incomplete unless the caller opts
into partial data. A live profile remains not ready until its independently
anchored live and finality actors report ready. Live readiness also drops
while retained recent frames sit at budgets.recent_raw_hard_bytes: live
ingestion waits there, logging recent_storage_full, until finality prunes
older frames (see Storage pressure). A parked processor
lane does not affect readiness (see
Parked processor live lane).
With sources.live.kind = "p2p" and either supported finality backend, serve
verifies the configured weak-subjectivity checkpoint, seeds the finalized
execution anchor, starts one shared P2P subscription, and launches cold
backfills for every processor that has a capable history source. A source
failure drops readiness immediately and the supervisor retries with bounded
exponential backoff (see Shutdown and supervision).
The live lane delivers unattested blocks at most two slots past the newest
attested head: the execution header of a light-client optimistic update that
at least two thirds of the sync committee signed, which the finality source
verifies within the slot it appears (see
finality levels). When a processor requires attested frames, it
includes blocks only up to that head. When no new attested head arrives for
four slots, for example while the finality
transports are down or sync-committee participation stays below two thirds,
the lane reports itself disconnected and live readiness drops until one does.
Configuration refuses sources.live.kind = "p2p" with
finality.kind = "disabled", because nothing would verify those heads; use
sources.live.kind = "disabled" for finalized-dataset backfills.
When live readiness drops this way while finality stays ready, run
leani source probe finality. Each Beacon endpoint whose finality verified
reports its attested_head, or in attested_head_error why it gave none: it
served no optimistic update, its update anchors no head, or its update failed
verification. The lane needs one endpoint that serves
/eth/v1/beacon/light_client/optimistic_update; replace endpoints that serve
none. With finality.kind = "consensus_p2p", peers must serve the
light_client_optimistic_update request; the debug log names each peer that
served none.
The network view reports the process-wide execution P2P manager. Live, history, and probe requests share its identity, discovery state, peer pool, request gate, and Reth reputation state. Each row includes:
- its current phase and active/stopped state;
- connected and known-peer counts;
- the requested block range and attempt count;
- how recently it changed; and
- the most recent bounded error message.
The view deliberately omits peer identities and retains only 32 stopped
manager records. It also reports cumulative material requests by outcome and
the time spent waiting for local peer-capacity slots. Physical peer sessions
are counted by establishment and disconnect, with disconnects grouped by
Reth’s protocol-level reason. A high queue wait is a local scheduling signal;
a timeout occurs only after dispatch capacity was obtained. Prometheus exposes
the same state under the leani_p2p_* metric prefix.
The execution identity is stored as execution-p2p-secret in data_dir.
Treat it as node identity material: keep it across restarts, restrict its
permissions, and never publish it. execution-network.sqlite is a disposable,
bounded operational store capped by sources.live.peer_store_max_entries. It
contains both broad discovery candidates and Leani’s independently observed
service evidence: verified header/body/receipt success times, highest served
block, smoothed response latency, last failure classification/time, fork
compatibility, and latest anchor qualification.
Reth’s known-peer snapshots and new service observations are committed by a dedicated background writer, so SQLite I/O never stops the network-manager event loop. Incremental upserts retain candidates absent from one short or failed run; indexed quality ordering decides which records survive the bound. Standalone feeds merge the SQLite evidence across sibling local Mainnet contexts, so a body- or receipt-serving peer learned by one processor is preferred by another without sharing processor data or the local P2P identity secret. The peer store is reconstructible and intentionally separate from the authoritative processor database and its backups.
Warm startup uses two cache tiers. A small hot tier contains recent body-serving
peers whose last verified body success is newer than their last recorded
failure, with IPv4 /16 and IPv6 /32 diversity preferred. Those peers are
dialed immediately. All other records stay in the broad tier and enter Reth’s
dialer in body_serving_peer_target-sized batches at the configured peer refill
interval. The segment-correct EIP-1459 DNS seeder and Reth’s native
Discv4/Discv5 discovery run concurrently from the beginning. Reth owns the only
Discv4 service and the bounded dial scheduler.
/v1/network/status groups candidate admissions, established sessions, and
qualification outcomes by cached_hot, cached_broad, dns_tree, trusted,
or discv4_or_5. Prometheus exports the same origin labels through
leani_p2p_peer_candidates_admitted_total,
leani_p2p_peer_sessions_by_origin_total, and
leani_p2p_peer_qualifications_total. Use these counters when comparing cold
and warm startup instead of treating cache size as evidence of usefulness.
Every new ETH session is immediately tested against the current independently verified execution anchor. Leani qualifies several sessions concurrently until the configured body-serving target is ready, then one at a time. Qualification is background work at normal priority throughout: it never uses the request slots reserved for the live lane. Qualification ranks proven serving peers first but does not exclude a newly connected peer: each material response is independently verified before use, so fresh peers remain immediate fallbacks. Proven serving peers are tried before untested discovery records on the next start.
sources.live.material_request_concurrency bounds simultaneous physical
requests together with the global source budget and connected-peer capacity.
Every execution P2P request shares one process-wide limit: the larger of
material_request_concurrency and history_header_request_concurrency, 32 by
default. History, probes, and peer qualification leave four of those slots
free for the live lane (half the limit when it is below eight), and a waiting
live request gets the next free slot first. Each source acquisition’s
max_in_flight_requests and the four-request per-peer pipeline usually impose
a lower effective limit. More concurrency helps only when the peer pool and
link have spare capacity; it also multiplies decoded response buffers. Treat
budgets.source_concurrency and budgets.history_pipeline.maximum_active_chunks
as memory-affecting settings and tune them together while watching RSS and the
material high-water metrics.
material_request_blocks sets the blocks per body or receipt request; the
evidence-backed default is eight. A peer that stops a reply at its response
size limit serves only the first of them: those are kept and the rest is
requested again. A failed request is retried block by block without narrowing
other requests. Raise it only with repeated real-source evidence for the
deployment’s peer and bandwidth profile.
For address/topic-filtered log processors, P2P checks the verified header bloom before requesting receipts. A negative avoids the receipt bundle; a positive still downloads all receipts for that block because the ETH protocol has no server-filtered receipt request. Bodies are fetched only when the descriptor’s log identity requires transaction hash. Therefore processor requirements, pool activity, bloom false positives, and receipt density can matter more than the configured concurrency ceiling.
For an inbound-capable deployment, set sources.live.listener_port and
sources.live.discovery_port to 30303, set
sources.live.discv5_port to a distinct fixed UDP port such as 30304,
expose all three sockets, leave enable_discv5 = true, and configure
sources.live.nat for the environment. Zero ports remain useful for isolated
tests. Inbound reachability improves diversity but is not required for
verification.
sources.live.trusted_peers may contain stable operator/community enode://
seeds that supplement public discovery without turning the pool into an
allowlist.
sources.live.minimum_peers is the hard connected-session floor before
execution requests may start and should normally remain 1. Capability
qualification does not gate startup: it ranks proven peers first while
commitment validation remains mandatory for every response.
body_serving_peer_target controls how many peers Leani concurrently proves
can serve commitment-valid bodies; reaching it does not delay startup after
the minimum is available. preferred_peers is a soft operational target and
never blocks ingestion. The Reth manager continues filling connections beyond
that target in the background up to max_outbound_peers.
Header, body, and receipt failures are isolated to their material lane. A
transient miss therefore cools that lane without disconnecting a session that
may still be useful to another processor; cryptographically invalid material
continues to ban the peer globally.
If the pool remains at zero connected peers for
sources.live.peer_recovery_timeout_seconds, the node rebuilds the complete
execution network manager and restarts Discv4, Discv5, and DNS bootstrap. This
recovers stale sockets after host sleep and retries a failed cold-start
bootstrap without operator action. The default is five minutes: discovery and
outbound dialing continue throughout that window, allowing Reth’s per-peer
backoff and candidate rotation to work before the underlying sockets are
recycled. The mainnet DNS root is also resubmitted every 15 seconds so a
transient resolver failure during the first lookup does not wait for the
whole-manager watchdog. Each rebuild logs building the execution P2P network manager and then built the execution P2P network manager or, at warn
level, failed to build the execution P2P network manager with its error.
While the live lane reconnects, it logs each failed attempt at warn level in
the live lane could not reconnect to the execution network; retrying. A
fixed Discv5 port can stay busy for up to a minute after a rebuild, while the
ended manager’s last discovery lookup finishes; the lane keeps retrying
meanwhile.
Processor coverage.complete means only that the stored range from the
configured start through processedThrough has no internal gaps. It does not
mean that processedThrough has reached the live source. The network status
adds storedRangeContiguous, syncTargetBlock, and blocksRemaining; use
those fields together with readiness.liveReady when diagnosing catch-up.
syncTargetBlock is derived from the live session’s observedHeadBlock: the
newest header the live lane has validated, never a head a peer only claimed.
It reports validated progress, so while the node catches up it trails the
network head. fromBlock and toBlock describe only its current material
request. A speculative request for the block after the observed head therefore
does not create artificial processor lag.
blocksRemaining counts every missing block from the configured start through
the observed head, including internal gaps; it is not merely the distance
between the highest stored block and that head.
readiness.ready means the required live and finality network lanes are
usable. It can be true while a processor backfills older gaps, so the
dashboard reports that combined live + backfill state explicitly.
The network.supervisor object reports running, backing_off, or stopped,
including its bounded last error, cumulative failure count, and retry
countdown. A zero active-session count during backing_off is a supervised
retry, not an idle healthy node.
The live/archive overlap may map the same canonical block at different
finality levels. The runtime validates all finality-only checksum variants,
keeps the strongest stored finality, and removes the redundant pending delta.
Any difference that cannot be reproduced by changing finality still fails
closed as a real transform or source conflict. At a restart, startup
reconciliation instead deletes every pending delta for a block the processor
already applied, without comparing it, before opening any network lane, so
archive availability or a slow P2P catch-up cannot delay cleanup; its warning
counts them in discarded_pending_deltas. processors[].pendingDeltas
exposes any durable rows that remain afterward.
finality.kind = "consensus_p2p" is the self-contained profile. Configure both
the independently obtained checkpoint root and its finalized beacon slot.
The node uses signed embedded mainnet bootnode ENRs unless finality.bootnodes
overrides them, binds discv5 to finality.discovery_port (0 selects an
ephemeral port), performs the mandatory Status exchange, and locally verifies
bootstrap, sync-committee, BLS, finality, and execution-payload proofs. Permit
outbound TCP and UDP; an inbound UDP mapping improves discovery but is not a
trust requirement.
finality.kind = "beacon_api" remains available when HTTP consensus
transports are operationally preferable. Configure
finality.minimum_agreement above one when independent transports are
available. Each poll waits about two seconds after the first verified endpoint
for the others, then follows the highest finalized slot that
minimum_agreement endpoints have reached or passed, so a lagging endpoint
neither holds finality back nor drops readiness at an epoch transition.
Finality never moves backwards, and two verified endpoints that report
different roots at one slot fail closed. Endpoints that name one transport
twice, for example with and without a trailing slash, fail validation because
each would count toward agreement.
See Checkpoint lifecycle for how the configured checkpoint is used and when it must be refreshed.
On-demand RPC shares the configured source-concurrency ceiling. The node divides its memory and temporary-disk budgets across those request slots, enforces a one-minute request timeout and the advertised maximum log range, tries an alternate capable source only before returning a response, and never stores fetched frames in the recent cache. Put additional connection and request-rate limits at the reverse proxy when exposing this potentially expensive path.
Suggested alerts:
- liveness failure: page immediately;
- required live/finality readiness false for 5 minutes: page;
- processor head stops advancing for 3 expected block intervals: warn;
- pending-delta or outbox bytes exceed 80% of configured budget: warn;
- database size approaches the filesystem limit: warn at 70%, page at 90%;
- finalized cursor does not advance for 20 minutes: warn;
leani_finality_anchor_expiry_secondsbelow three days: warn;- any finalized contradiction or store-integrity failure: stop publication and page immediately.
Import observability/grafana/leani.json for the release dashboard and
load observability/prometheus/alerts.yaml into the Prometheus-compatible rule
evaluator. The checked rules cover missing scrapes, required-component
readiness, stalled processor/finality cursors, a non-draining outbox, and a
finality anchor close to its age limit.
The stalled-cursor rules apply only where the profile requires the live
source (leani_live_required) or finality (leani_finality_required), so a
historical-only node whose backfill has finished does not alert. They join
processor series to those gauges on the scrape target’s job and instance
labels. Keep honor_labels disabled, the default, for the Leani scrape job:
Prometheus then stores each processor’s own instance label as
exported_instance.
Filesystem percentage alerts remain the host operator’s responsibility because
the node exports its database/recent-cache byte counts but not host capacity.
Shutdown and supervision
serve stops at Ctrl-C or SIGTERM. Open change streams (SSE) and consumer
delivery streams (NDJSON) end after a whole event or record, and JSON-RPC
WebSocket connections get a 1001 going-away close frame; clients reconnect
from their last cursor as after any closed connection. The node then waits at
most 10 seconds for open connections and its background work, and at most 5
more for blocking work such as a file write, abandons whatever is still
open, and exits 0. A second signal during the first 10 seconds exits at once
with status 130; startup reconciliation repairs a commit it interrupted (see
Crash or interrupted commit).
deploy/leani.service (TimeoutStopSec) and compose.yaml
(stop_grace_period) allow 30 seconds for the stop; give another container
setup more than Docker’s default 10 seconds.
Background work runs in supervised tasks: the durable backfill and
raw-history schedulers, the network lane supervisor, artifact compaction,
query snapshot cleanup, and each processor’s delivery pruning and coverage
compaction. Each runs until the shutdown. A task that stops before it, by
returning, failing, or panicking, shuts the node down: the log names the task
in a background task ended; shutting the node down at error level, and the
node exits with status 1 after the same bounded shutdown. The systemd unit
(Restart=on-failure) and compose.yaml (restart: unless-stopped) start it
again. A persistent execution P2P source that cannot be built, for example
for an invalid sources.live.nat, stops the node the same way.
The network lane supervisor restarts failed runs of the network lanes
itself, and drops readiness whenever the lanes do not run, a panic included.
A failed run restarts after 1 second, and each further failure doubles the
wait up to a minute; after a run that stayed up for at least a minute, the
wait starts at 1 second again. A run that ends waits for its cold backfills
to stop before the next one starts; every 10 seconds of that wait, the log
names the ones still running in
cancelled automatic cold backfills are still running at warn level. When
the lanes halt (see
Finality contradicts the followed chain),
they stay stopped and not ready while the node keeps serving what it stored.
A live lane that stays not ready while verified finality is ready for
sources.live.stall_timeout_seconds, ten minutes by default, within one run
of the lanes is treated as stalled. Usually its execution P2P network is
wedged, which a lane restart cannot heal; it can also be a finality endpoint
that stops sending attested heads, which a restart may not fix. The lanes
then stop, the log says the live lane stalled while verified finality stayed ready; the node exits so that it restarts at error level, and the node exits
with status 1 for systemd or Docker to start it again. While finality is not
ready either, the network itself is likely down and the node keeps waiting,
and a wait at the recent-frame hard limit does not count (see
Storage pressure). A run that fails quickly, as when
another process holds the P2P ports or persistent_retries = false gives up,
never reaches the timeout: the supervisor restarts it with backoff and logs
each failure. Run the node under a supervisor that restarts it, or raise the
timeout, up to 30 days.
Checkpoint lifecycle
finality.checkpoint, with finality.checkpoint_slot, is the trust root, and
it is needed only for the first start. That start verifies a light-client
bootstrap for the checkpoint, which must be at most 14 days (336 hours) older
than its slot.
From then on, the finality source keeps one bootstrapped light client, one per
endpoint for beacon_api, and advances it with every finality update, plus
the sync-committee update when a period ends. Nothing is bootstrapped again,
and no age check runs while the node keeps running. A beacon_api endpoint
that was down at start, or bootstraps slowly, bootstraps later from the newest
anchor the endpoints agreed on, so it can still join after the configured
checkpoint has aged out. Its bootstrap keeps running across polls instead of
restarting each time.
The node persists the newest verified finalized anchor as
finality-anchor.json in data_dir. It writes a temporary file, syncs it to
disk, and renames it over the old one, so a crash leaves either the previous
anchor or the new one. The file records the anchor’s beacon slot and root, its
execution block number and hash, the configured checkpoint it was verified
from, and a format version. It holds no secrets. Only anchors at an epoch’s
first slot are written, because Beacon nodes serve bootstraps for
epoch-boundary blocks, so the file trails finality by about one epoch. An
older anchor never replaces a newer one.
Every start, and every restart of the network lanes, bootstraps from the persisted anchor instead of the configured checkpoint when both hold:
- the anchor was verified from the configured checkpoint, so it is newer by construction; and
- the anchor is at most 14 days old.
An anchor verified from another checkpoint is never used: changing
finality.checkpoint re-anchors the node on the new root, and the node logs a
warning naming both roots. leani doctor reports the mismatch too. The one
exception is an embedded subscription (leani subscribe without a running
node) that reuses a cached checkpoint, checkpoint.json, whose trust is
locally_verified: an anchor the subscription verified itself on an earlier
run. A persisted anchor at a later slot replaces it. A checkpoint from a
provider quorum follows the rule above, whether the subscription has just
accepted it or reuses it from the cache with trust provider_quorum.
The bootstrap is still verified against the anchor’s block root, and the
configured checkpoint’s own age is not checked. A missing, corrupt, or
unreadable file, or an anchor past the limit, falls back to the configured
checkpoint with a warning. So does a persisted anchor whose bootstrap no
endpoint or peer serves. Startup fails only when the configured checkpoint is
unusable too, for example because it is older than 14 days.
leani source probe finality and the benchmark’s history probe start from
the persisted anchor by the same rules, but never write the file.
The persisted anchor stops advancing while verified finality stalls, while the node is stopped, and while writing the file fails. A failed write is logged and counted, and never fails finality itself. Watch these:
leani_finality_anchor_age_secondsis the age of the newest verified anchor;leani_finality_anchor_expiry_secondsis the time left before a restart can no longer bootstrap from it, and turns negative once it has passed;leani_finality_anchor_write_failures_totalcounts failed writes offinality-anchor.json: while it grows, a restart may start from an older anchor than these gauges show;- the
LeaniFinalityAnchorExpiringalert fires when fewer than three days remain; leani doctorwarns when the anchor a restart would use expires within three days: the persisted anchor, or the configured checkpoint if none applies.
To refresh the trust root, take a recent finalized checkpoint root and slot
from independent providers, set finality.checkpoint and
finality.checkpoint_slot, and restart: the persisted anchor verified from
the old root is then ignored. leani reset all removes the file with the rest
of the runtime state.
Errors, logs, and probe reports show endpoint and provider URLs as
scheme://host[:port], with /… in place of any path, because providers put
API keys in userinfo, query strings, and paths. Request errors add the API
path, such as eth/v1/beacon/genesis. Endpoints that would show alike carry
their index, such as finality.endpoints[1].
Backup, restore, and compact
Stop the node and any embedded subscription or standalone backfill using this
data directory before running leani db commands (including inspect, verify,
and backup). They take the same .leani.lock as other writers, and every
one but backup opens the store with migrations enabled, which upgrades a
store from an earlier release. Use the API and /metrics for inspection
while the node is running.
Create a consistent backup after stopping those writers:
leani db backup /backups/leani-$(date +%s).sqlite \ --config /etc/leani/node.tomldb backup reads the store through a read-only connection and never
upgrades it: the backup keeps the store’s schema, so the backup of a store
from an earlier release restores with that release’s binary. It refuses a
store whose schema is newer than the binary supports, and a data directory
without a store.
That command is intentionally SQLite-only. When any processor artifact
segments exist, as with artifact_storage.backend = "tiered_segments", it
fails before writing a backup rather than producing an incomplete restore.
Until a versioned bundle-backup command ships, stop the node and snapshot the
complete data_dir (SQLite database/WAL plus processor-artifacts) or use an
atomic filesystem/volume snapshot.
Verify the backup using an otherwise identical config whose data_dir points
to an isolated directory, with the binary that will open it: a newer binary
upgrades the copy. Never overwrite the active database. Stop the service, move
the active database aside with its leani.sqlite-wal and leani.sqlite-shm
files, since SQLite would replay a leftover WAL onto the restored copy, copy
the verified backup into place, then start the previous compatible binary. Retain the moved database until
coverage, processor counts, cursors, and subscriptions are verified.
db compact checkpoints the WAL and vacuums the active database; immutable
artifact segments need no rewrite. The rewrite also turns on SQLite
incremental auto-vacuum, which new stores have from creation, so pages freed by
pruning return to the filesystem. A store created before auto-vacuum was
enabled logs a warning at every start and never shrinks below its high-water
mark; run db compact on it once. Like every leani db command, it runs only
while the node is stopped, and it needs free disk for a second copy of the
database, because the vacuum rewrites the whole database.
db prune-changes is lease-aware; expired consumers must perform the
documented snapshot/reset flow.
Failure runbooks
History schema or source failure
Stop the affected backfill, keep the last committed coverage, run the source probe, and compare its schema/source plan with the last benchmark report. Switch to another capable configured source at the missing range boundary. Never mark failed chunks complete.
P2P outage or invalid material
Readiness must become false when the live session is unavailable. Open
/debug/network or inspect /v1/network/status before treating every
zero-peer message as a live outage. Preserve the recent and undo windows, stop
unfinalized publication before a hard storage limit, and compare local queue
wait with peer timeouts. The production source keeps one persistent broad peer
pool, penalizes invalid material through Reth, retries without discarding
healthy connections, and caps recovery backoff. Invalid commitments are
permanent evidence against that response, not retryable data.
Archive lag is not a live outage. The reconciliation cursor remains at the
first unverified finalized batch and tries interchangeable history sources
again. A durable failed archive reconciliation is a correctness incident:
preserve the database and source object, stop publication, and compare the
processor version/configuration and block identity before resuming.
Reconciliation compares only exact finalized coverage, which keeps each
block’s identity and mapped-delta checksum. It skips blocks nothing covers,
and blocks that coverage compaction reduced to proof segments, logging the
latter once as archive reconciliation cannot compare blocks compaction reduced to proof segments; they stay unaudited. Compaction keeps the blocks
after a processor’s first verified hot/cold handoff exact until a verified
reconciliation passes them, unless they lie more than 65,536 blocks below
finality, so only an audit stalled that long loses blocks to compaction.
Finality outage or disagreement
Continue only unattested or attested publication. Do not advance or prune the finalized cursor. A disagreement or contradiction is fail-closed and requires operator review of checkpoint provenance and independent endpoints. A single bad transport is not an outage: consensus P2P bans a peer whose light-client material fails verification, asks the next one, and neither asks nor dials it again, across reconnects, until the lanes restart. Stale updates are retried, so neither ends the finality stream. An exhausted peer set logs a warning at most every five minutes.
Network fork this release does not support
Each release verifies the consensus forks it knows; Glamsterdam’s Gloas is
not among them yet. Once mainnet activates a fork a release does not know,
finality stays unavailable and the live lane stops, fail-closed: consensus
peers answer with a fork digest the node does not know, which it logs once as
consensus peers serve a fork this release cannot verify, and a Beacon API
endpoint fails with an error naming the fork, such as
serves `gloas` light-client data. No peer is banned for it. Upgrade to a
release that supports the fork before its mainnet activation. Upgraded
within 14 days, the node resumes from its persisted finality anchor; later,
it needs a fresh finality.checkpoint.
Live lane disconnected during a finality apply
Symptom: right after finality advances, the live lane reports itself
disconnected with no sync-committee-attested execution head above block … for … seconds, live readiness drops, and no block is included, although the
finality sources are healthy. It is most likely every 1,000 finalized blocks,
when each processor with automatic checkpoints copies its whole state into a
recovery checkpoint, and with large processor states.
Cause: both finality sources verify and publish attested heads only while the node reads their finality stream, and the node applies each verified finality update, checkpoints included, before it reads the next. An apply that takes longer than about 36 seconds leaves the live lane without a new attested head for its 48-second grace, four slots.
What to expect: nothing is lost or reverted. When the apply completes, the next attested head arrives, the lane catches up on the blocks produced meanwhile, and readiness returns. This is a known limitation of this release; a later release publishes attested heads independently of finality applies.
Finality contradicts the followed chain
When verified finality names another block at a height than the retained canonical chain holds, or a retained block below the finalized block does not link to it, the node neither finalizes either chain nor defers forever.
A contradiction with retained unfinalized blocks, for example after the lane
stalled across a reorg of an attested head, heals itself. The node logs
verified finality contradicts retained unfinalized blocks; restarting the network lanes to revert them as a warning and restarts the network lanes
after the supervisor’s backoff. The restart reverts only the retained
unfinalized blocks that are not on the newly verified finalized chain, and
undoes processor coverage of them as a reorg, logging retained unfinalized blocks do not link to the verified finalized anchor; reverting them as a reorg. Those blocks are never finalized; the cold backfill and the live lane
fetch the finalized chain again.
Every start, and every restart of the network lanes, decides which retained
blocks are on that chain the same way. Where parent links from them do not
reach the anchor, as after a contradiction or an outage longer than the
finality lag, the node proves the blocks on the anchor’s chain with
execution headers from its peers that link by parent hash down from the
anchor, keeps and finalizes them, and logs proved retained unfinalized blocks against the verified finalized anchor with the blocks kept and
reverted. When it cannot prove them within 60 seconds, for example without
execution peers, it logs could not prove retained unfinalized blocks against the verified finalized anchor; reverting them as a warning; when the proof
would span more than 8,192 blocks, retained unfinalized blocks are too far below the verified finalized anchor to prove; reverting them. It then reverts
every retained unfinalized block that does not link to the anchor.
The network lanes halt instead when the contradiction is with finalized
history, which no revert repairs, or when the same unfinalized contradiction
comes back a third time before finality moves to another block, so two
restarts did not repair it. The node then logs verified finality contradicts the retained canonical chain; network lanes halt until the node restarts at
error level: readiness stays false, network.supervisor reports stopped
with the contradiction as its last error, and nothing retries it. The API
keeps serving what is stored.
- Preserve the database and logs. Check the checkpoint provenance and the finality transports: a halt means the node followed another chain than the one consensus finalized and could not revert it, or a trust root is wrong.
- If the error says
verified finality contradicts finalized canonical history, finalized history in the store is wrong and no revert repairs it: rebuild in a newdata_dir. - If it says the contradiction
recurred after 2 restarts of the network lanes, restart the node once the finality transports check out: startup reverts the unfinalized blocks as above. If it halts the same way again, rebuild in a newdata_dir.
Storage pressure
Inspect per-processor attribution with db inspect and /metrics. Prune
change history only through db prune-changes, respecting active leases.
Never evict rollback data required by unfinalized blocks. Add capacity or stop
unfinalized advancement before the hard limit. If the database file stays at its
high-water mark after pruning, check the startup log for the auto-vacuum
warning, stop the node, and run db compact once.
Retained recent frames are the reorg and replay input, so the node never drops
an unfinalized one to make room. When they reach
budgets.recent_raw_hard_bytes, live ingestion stops before the next block,
live readiness drops, and the log reports recent_storage_full. Ingestion
resumes once a finality advance prunes older frames. After 15 minutes at the
limit the live lane fails, and the supervisor restarts the network lanes from
a fresh finalized anchor, which lets finality prune again; the live stall
timeout does not count this wait. A node that keeps
reaching the limit needs working finality, or a hard limit above the
rpc.minimum_recent_blocks window plus the unfinalized tail.
A processor’s delivery log is pruned in the background by its
delivery.pruning policy, oldest change first: finalized changes older than
its window, and, while the log exceeds its byte limit or the processor is
paused at it, older finalized changes whatever their age, never one a
required consumer has not acknowledged. A block-local processor’s history
and live blocks share its log, so a live change still inside the window can
precede older history. Pruning by age stops at that change. Pruning at the
limit does not, since the window of a paused processor does not move; a
best-effort consumer that had not read the change gets reset_required.
A paused processor resumes once the log is below 90 percent of its limit.
Embedded subscriptions (leani subscribe without a node) prune
the same way while they run, so their built-in processors keep within 64 MiB
and 24 hours of changes.
Retained raw history has its own [raw_history] budgets. A raw-history store
over either of them, for example after lowering one, still opens and serves
its segments, but admits no new segment. A job created with
segment.onLimit: pause waits at the storage limit, with the budget named in
lastError, and resumes once the node runs with a larger budget; one created
with fail fails.
Retained raw-history segment failure
The node verifies each retained segment’s whole-file checksum the first time
it reads the segment after starting, and each block’s record checksum on
every read. At startup it only checks that each segment file exists with its
closed length, and logs what it recovered. A segment that fails never stops
the node: it leaves the catalog, so its blocks read as missing and RPC and
backfills use another source, its file moves to
data_dir/raw-history/quarantine with a .corrupt suffix, and the node logs
a warning. A segment whose file is missing at startup leaves the catalog the
same way. A closed file no catalog row claims, left by a crash before its
publication committed, moves to the quarantine with an .orphan suffix.
- Check the raw-history jobs: each job that owned the segment names it and
its blocks in
lastError. A running job acquires those blocks again, and a completed one returns to the queue and does too. A failed or cancelled job keeps the error it ended with. - Check the disk and filesystem before trusting the node with more writes: a corrupt segment usually means a failing disk.
- The quarantine keeps at most 1 GiB, or one segment of
raw_history.maximum_segment_physical_byteswhen that is larger. Past that, the files quarantined longest ago are deleted first, at startup and after each new quarantine, but the newest is always kept. Copy a file elsewhere to keep it for inspection; delete the others whenever you like.
Retained raw-history segments unavailable
When more than half of the catalogued segment files are missing at startup,
as when the volume holding data_dir/raw-history/segments is not mounted yet
or a restore is still running, the node takes the directory as unavailable
rather than emptied. It keeps every catalog row, logs a warning, and counts
them as unavailable_segments in its startup recovery log. A segment file
that disappears while the node runs is treated the same way. Reads of those
segments fail and fall back to other sources, and the jobs that own them do
not acquire them again. Do not create raw-history jobs until the directory
is back: a new job drops the rows of the missing segments nothing owns, and
may replace others with segments it acquires itself.
- Mount or restore the directory, then restart the node: the segments whose rows were kept serve again. A returning file whose row a new job dropped meanwhile is quarantined as an orphan.
- If the files are gone for good, delete every raw-history job that owns them, cancelling running ones first, then create each again with the same request. The new job skips a segment whose file is gone, removes its catalog row once nothing owns it, which also frees its share of the budgets, and acquires the blocks from its sources.
Parked processor live lane
A failure that belongs to one processor parks only that processor’s live
lane; shared ingestion, finality, and every other processor continue. This
holds for every processor mode and delivery ordering.
/v1/processors/{id}/status reports the lane as paused or failed.
/v1/network/status adds its runState, pauseReason, and liveGap (the
first unapplied block), and leani_processor_run_state carries the state. A
paused lane resumes by itself. A failed lane is frozen: it commits no live
block, and finality does not advance it. It is still mapped for a reorg’s
replacement blocks, only to move its first unapplied block onto the new
branch. Its first failure stands: a later failure does not change its reason,
and a later park does not move its first unapplied block. Only
processor_finality_conflict replaces the reason. A failed lane waits for an
operator: fix the cause, then call
POST /admin/v1/processors/{id}/lanes/live/reset with the header
x-leani-request: 1, which works for every
delivery ordering and pauses the lane so that it replays from its first
unapplied block. A lane that fails again on the same block parks again; it
does not stop the node.
A reorg never leaves a parked lane’s first unapplied block on a block the
chain no longer has. A reorg that replaces blocks at or below it moves it down
to the first replacement block, so the lane replays the whole new branch. A
reorg without a replacement branch moves it onto the new tip. When the lane
has applied that tip, a paused lane resumes at once, and a failed lane’s first
unapplied block moves onto the new tip’s successor once that block arrives. If
the lane never applied the new tip, such as the finalized anchor the node
seeds after downtime, a paused block-local lane’s gap simply completes, while
an ordered lane waits there for that block from history. A failed block-local
lane keeps its first unapplied block on that tip, so after a reset it waits
for a history source to serve it (finalized_gap_waiting_for_history_source),
a block it does not need: make sure a history source can serve the finalized
anchor, and the lane applies it and resumes by itself. A first unapplied
block found off the canonical chain, as an earlier version could leave one,
moves back to the last canonical block the lane applied; the lane then
replays from there instead of skipping blocks or failing.
| Reason | State | Resumes when |
|---|---|---|
delivery_spool_hard_limit, physical_store_hard_limit, artifact_store_hard_limit, pending_delta_hard_limit |
paused | Consumers acknowledge or storage frees up. |
unfinalized_gap_waiting_for_finality |
paused | Finality reaches the first unapplied block, which then comes from the history source. No retained frame can serve that block, for example because it was retained without this processor’s material. |
finalized_gap_waiting_for_history_source, finalized_gap_recovery_unavailable, finalized_gap_recovery_incomplete |
paused | A history source can serve the finalized gap. |
operator_reset_pending_replay |
paused | The replay after an operator reset reaches the live tip. |
startup_reconciliation_replay |
paused | The replay of retained frames that startup reconciliation started reaches the live tip, normally before live blocks resume. |
hot_cold_handoff_failed |
paused | The processor’s automatic cold backfill or its overlap verification failed; the handoff is recorded as failed. A block-local lane replays and resumes at once. An ordered lane resumes once a later backfill passes its gap. A start’s backfill runs only to the finalized head, so restart the node once chainFinalizedHead in /v1/network/status has reached the lane’s liveGap; an earlier restart leaves the lane paused. |
processor_live_reduce_failed |
failed | Reset after fixing the processor or its input. Its reducer, or the store’s check of what the reducer wrote, rejected the block. |
processor_live_mapping_failed |
failed | Reset after fixing the processor or its input. Mapping the block, or checking a pending delta’s finality variants, failed. |
processor_live_delta_conflict |
failed | Investigate before resetting: a pending delta for an applied block carries different content, so the processor’s transform is not deterministic or its inputs differed. The applied block stands. Restart the node, whose startup reconciliation deletes that pending delta, then reset the lane. |
single_block_exceeds_delivery_limit, single_delta_exceeds_pending_delta_limit, live_gap_marker_exceeds_pending_delta_budget |
failed | Reset after raising the limit. |
live_gap_canonical_identity_changed and the other finalized_gap_recovery_* reasons |
failed | Reset after checking the history source against the canonical chain. For an ordered lane, finalized_gap_recovery_canonical_mismatch can also mean that the block history returned does not descend from the lane’s own last applied block, which is then off the canonical chain: restart the node, whose startup reconciliation undoes an unfinalized off-chain block, then reset. The lane then waits in operator_reset_pending_replay until the automatic cold backfill of the next start fills the undone heights, so restart the node once more; an on-demand processor waits for a backfill request instead. A finalized off-chain block needs a rebuild. |
processor_finality_conflict |
failed | Rebuild the processor as a replacement instance; the reset route refuses this lane with 409 live_lane_requires_rebuild. The same instance cannot be rebuilt in place today: configure a replacement instance, with a new processor version or configuration, and move to it as in Processor rebuild or rollback. Its coverage holds a finalized block hash at another height, or another block at a finalized height, so it contradicts the canonical chain, and finality stops advancing it. This reason replaces any earlier failure reason. |
Crash or interrupted commit
A live block commits in steps: the node first retains its frame as the new canonical block, then each processor applies it in a transaction of its own. A reorg likewise switches the canonical chain first and then undoes each processor’s reverted blocks. When an apply reaches a processor’s delivery limit, the store pauses or fails the lane first and the runtime then records its first unapplied block. A crash, a kill, the embedded subscription runtime’s shutdown abort, or a network-lane restart can stop between those steps. That leaves a processor behind the canonical chain or on a reverted branch, a pending delta that nothing applies, or a stopped lane with no record of where it stopped. Every network-lane start therefore runs startup reconciliation for each processor before any lane opens:
- It undoes the processor’s unfinalized blocks that are not the canonical block at their height, newest first, and publishes their undo changes as the reorg would have.
- It deletes pending deltas the processor can never apply: those below its start, for a block that is not canonical, and for a block it already applied. For a block-local processor that includes any below its first unapplied block, such as one an interrupted cold-backfill commit left; the backfill maps that block again.
- It pauses a running lane that trails the retained canonical frames at its
first unapplied one, with reason
startup_reconciliation_replay, and replays those frames through the normal gap path before live blocks resume. A lane that has applied nothing yet, such as a newly configured block-local processor, replays the retained frames too. A block-local lane whose next block falls in a hole in the retained frames, such as blocks never retained over downtime, replays from the first retained frame above the hole; its cold backfill covers the hole. - A lane the store paused or failed at its delivery limit, but whose first
unapplied block was never recorded, gets that block recorded and keeps its
state and reason. A paused lane replays from there once consumers
acknowledge and delivery capacity frees up, instead of skipping the blocks
it missed, and a failed lane can be reset. The recorded block carries no
required delivery size, so for
single_block_exceeds_delivery_limitthe reset route cannot refuse a limit that is still too small; the replay then fails the lane again on the same block.
The store can also pause or fail a lane at its delivery limit while the lane
is at the live tip, for example in its cold backfill’s commit on the lane’s
live stream. That records no first unapplied block either, and at a restart
reconciliation has no retained block to record yet. The live lane handles it
with its next block: a paused lane commits that block as usual, so the store’s
refusal parks the lane there, or applies it once capacity has freed up. A
failed lane records that block, which a block-local lane has not applied or
which follows an ordered lane’s last applied block, so a reset replays from
there. A failed ordered lane still behind its history records nothing, and
its history cannot apply while it is failed. Startup reconciliation records
its first unapplied block, the one after its cursor, only while that block is
among the retained frames. Once finality has pruned it, nothing records it:
the reset route refuses the lane with 400 invalid_request, since it has no
durable live gap, and only rebuilding the processor as a replacement instance
recovers it, as in Processor rebuild or rollback.
A paused or failed lane whose first unapplied block is already recorded
replays from there as usual, and a lane failed for another reason, such as
processor_finality_conflict, is left alone. Reconciliation replays nothing
below the retained frames. A processor’s automatic cold backfill fills those
blocks up to the finalized anchor; an on-demand processor waits for a backfill
request. Before its live lane applies a block past those blocks, an on-demand
block-local processor logs “the live lane skips blocks this processor never
processed” at warn level, with from_block, to_block, and announced, and
its live stream announces the range in a system.live_gap.put change for the
application to request. A startup replay from the first retained frame above
a hole announces it the same way.
A processor with [processors.live_gap_fill] (see
Live gap fill) fills a
range of at most its max_blocks from history first, before it applies that
block. The lane logs filling the blocks the live lane skips from history at
info level, with from_block, to_block, and blocks, and filled the blocks the live lane skipped when it is done, with applied, duplicates (blocks a
history subscription had applied), and elapsed_ms. Every live lane waits
while a fill runs, at most five minutes plus applying the blocks of its last
request: a request still running at five minutes is given up. elapsed_ms is
how long the node held all of them. A longer range logs the live lane skips more blocks than live_gap_fill.max_blocks; announcing them at
info level, and announces as above. A fill that cannot start or finish,
because no history source serves the range, the range is not finalized yet, a
source fails or serves a block that does not link, or the time is up, logs
could not fill the blocks the live lane skips; announcing the rest at warn
level, with filled_blocks and the reason. The stream then announces from
the first block it did not fill, and the warning above follows.
A lane that reaches a delivery or storage limit mid-fill stops there. While
the store would refuse its next block, at the stream’s delivery limit, at a
node-wide [budgets.delivery] or [budgets.store] budget, or 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. Either way it
reports paused with that reason (see
Parked processor live lane), announces
nothing, and continues the fill once the store would take the block: once
consumers acknowledge and pruning makes room in its stream or in the
node-wide budget, or once storage frees up. A single filled block larger than
the stream’s max_bytes fails the lane with
single_block_exceeds_delivery_limit, as an equally large live block would:
raise max_bytes or remove the table, then reset the lane. A fill also makes
a pause at a delivery or storage limit more likely, and a held block’s pending
delta counts against the shared [budgets] pending_delta_bytes. When that
budget cannot take it, the lane fails with
live_gap_marker_exceeds_pending_delta_budget: raise the budget, then reset
the lane.
Each processor that needed a repair logs one warning, “startup reconciliation
repaired processor state against the canonical chain”, with the fields
undone_blocks, highest_undone_block, discarded_pending_deltas, and
replay_from_block. The info line “startup reconciliation completed” then
reports, per processor, the blocks applied and reverted and the pending deltas
left. A consistent store is left untouched, so the warning appears only after
an interruption or for a lane that trails the retained frames. No operator
action is needed.
SQLite failure
Stop writers, run db verify, preserve the database and WAL for diagnosis,
and restore a previously verified backup into a new path. Do not run a
destructive downgrade or hand-edit schema metadata.
Expired stream cursor
The API returns 410 cursor_expired or a terminal reset_required SSE event.
Query a fresh snapshot, atomically replace consumer state, then resume from
the returned retained cursor.
Processor rebuild or rollback
Processor code/config identities create separate immutable instances. Backfill the replacement instance in parallel, compare coverage and outputs, then move consumers. Rollback selects the prior binary/store pair or restores its backup; it never mutates a newer instance in place.
The old instance’s backfill subscriptions do not move. Their retained records
count against [budgets.delivery] maximum_history_retained_bytes until they
are deleted, and once the node no longer configures the old instance, their
stream and acknowledgement routes refuse (normally 404 not_found), so their
consumers can neither read nor acknowledge them. Settle them around the switch:
- Before the configuration drops the old instance, let each of its history
subscriptions finish, with its consumer acknowledging the
backfill_completerecord, or cancel it. Delete each once its consumer has acknowledged what it was delivered. - After the node restarts without the old instance, list the subscriptions
with
GET /admin/v1/backfill-subscriptions:processorConfigured: falsemarks those of an instance the node does not configure. That can be temporary, as when a restart’s configuration leaves an instance out by mistake: configure it again instead. If the old instance will not return, cancel each of its subscriptions withPOST /admin/v1/backfill-subscriptions/{id}/canceland delete it withDELETE /admin/v1/backfill-subscriptions/{id}?discardUnacknowledged=true, which discards its unacknowledged records. Both requests need the headerx-leani-request: 1.
The SDK’s backfill client makes the same calls with list(), cancel(id),
and delete(id, { discardUnacknowledged: true }).
A store upgrades to the binary’s schema on the first start that opens it, and
the upgrade cannot be undone. A start that the store refuses for a processor
under an existing instance ID (processor instance … conflicts with its stored descriptor) refuses before that upgrade, so the store stays usable by the
binary that wrote it. Store migrations are forward only: rolling an upgraded
store back means restoring the backup taken before its upgrade.
Built by Smart-Byte · Legal notice (Impressum) · Privacy