Reserved read-admission lane (CONCEPT:EG-KG.coordination.reserved-read-lane)¶
A reserved admission lane that only reads/queries may use, so an interactive MCP read or query is never shed to
BUSYbehind an ingestion write firehose. The engine stays responsive while it ingests 24/7.
This page is the deep reference for CONCEPT:EG-KG.coordination.reserved-read-lane. For where it sits in the
broader scaling story see the Engine Scaling Program
(the responsiveness layer); for the MVCC read path it relies on see
engine.md and CONCEPT:EG-KG.storage.snapshot-read-off-writer.
WHAT¶
EG-KG.coordination.reserved-read-lane adds a small, dedicated read-admission semaphore
(ServerState::read_admission) alongside the existing global backpressure pool
(max_in_flight) and the per-graph fairness cap (per_graph_inflight). When a
read loses the normal admission path under write saturation, it falls back to
this reserved lane instead of being shed BUSY. Writes can never acquire the
lane, and it bypasses the per-graph cap entirely.
The contract is one sentence: under any amount of ingestion write load, an interactive read keeps an open lane — and only a genuine read flood that also fills the small reserved lane is shed (so memory stays bounded).
This is an admission-layer fix, not a locking fix
Reads already serve from MVCC snapshots and so never contended for a write
lock (see HOW below). EG-KG.coordination.reserved-read-lane fixes the one place the engine did
couple reads to writes: the global admission semaphore, which shed reads
to BUSY indiscriminately once writes saturated it. EG-KG.coordination.reserved-read-lane gives reads their
own lane through that gate.
WHY¶
The engine's __commons__ graph is a 24/7 ingestion firehose. Under saturation
two admission resources fill up:
- The global
max_in_flightpool (EPISTEMIC_GRAPH_MAX_INFLIGHT, defaultcpus*64clamped256..8192) — shared by reads and writes. - The per-graph fairness permit (
per_graph_inflight, defaultmax_in_flight/4) for__commons__itself.
Before EG-KG.coordination.reserved-read-lane a read that lost both was shed BUSY exactly like a write — so a
heavy ingestion stream could starve interactive MCP reads/queries even though a
read is cheap and never blocks a write. The engine must stay responsive while
ingesting, so an interactive read must outrank background ingestion at the
admission gate.
The "database is locked" red herring¶
The live symptom that motivated EG-KG.coordination.reserved-read-lane was a database is locked error under K=4
ingestion. The root cause was traced and is not the engine:
- redb is MVCC and never emits that string — its errors are
Database already open/transaction in progress. - Engine reads already serve from
begin_read()/ in-memory snapshots (CONCEPT:EG-KG.storage.snapshot-read-off-writer), so they never contend for the write lock. - The literal
sqlite3.OperationalError: database is lockedcame from the agent-utilities FanOut outbox (KG-2.74, a sibling SQLite component).
The engine's only contribution to the unresponsiveness was the global
max_in_flight semaphore shedding reads to BUSY under write saturation — which
is exactly what the reserved lane fixes.
HOW¶
The transport admission lives in
src/server/transport.rs::handle_connection.
Each request is classified by
src/server/access.rs::requires_write and resolved
through a pure, unit-testable admit_request:
fn admit_request(
sem: &Arc<Semaphore>, // global max_in_flight pool
read_sem: &Arc<Semaphore>, // reserved read lane (EG-KG.coordination.reserved-read-lane)
pg_map: &DashMap<String, Arc<Semaphore>>, // per-graph fairness permits
pg_limit: usize,
graph: &str,
is_write: bool, // from requires_write(&req.method)
) -> Admission { … }
The classifier requires_write is the canonical read/write split: structural
mutations (AddNode/AddEdge/CompareAndSetNodeFields/BatchUpdate/…),
RDF mutations, KV mutations, and — crucially — statement-aware query writes
(it parses just enough of a Sql/CypherQuery/GraphQl string to tell a
mutating statement from a read) all classify as writes; everything else
(including pure-compute finance/datascience methods) classifies as a read.
The admission algorithm¶
Both reads and writes try the normal path first (a global permit, then the graph's per-graph permit). The split only happens when that path is lost:
flowchart TD
REQ["request (method, graph)"]
CLASS{"requires_write(method)?"}
NORMAL{"try global permit<br/>AND per-graph permit?"}
WRITELOSE["shed BUSY<br/>(back-pressured, retry — NEVER dropped)"]
READLANE{"try reserved read lane<br/>(read_admission semaphore)?"}
READSHED["shed BUSY<br/>(only a genuine read FLOOD reaches here)"]
GRANT_NORMAL["Granted {global, per_graph}"]
GRANT_READ["Granted {read}<br/>(bypasses per-graph cap)<br/>+ read_reserved_admitted++"]
REQ --> NORMAL
NORMAL -->|yes| GRANT_NORMAL
NORMAL -->|no| CLASS
CLASS -->|write| WRITELOSE
CLASS -->|read| READLANE
READLANE -->|permit| GRANT_READ
READLANE -->|full| READSHED
Key invariants, straight from the code:
- A write that loses the normal path is shed
BUSY— strictly back-pressured (retry with backoff), never dropped. The durable write path stays the bottleneck; the reserved lane is not a relief valve for writes (theis_writebranch returnsAdmission::Busybefore the lane is even tried). - A read that loses the normal path falls back to
read_sem, which writes can never acquire, and which bypasses the per-graph cap — so even when__commons__has saturated both the global pool and its own fairness permit, an MCP read keeps an open lane. A read "pays no fairness tax" because it is cheap and must stay live. - The reserved lane is bounded. Only a genuine read flood that also fills
read_semis shedBUSY, so the lane can never grow unbounded in-flight work. - The three permit slots are mutually exclusive: a normal admission holds
global+per_graph; a reserved-read admission holds onlyread.
Reads never touch a write lock (the EG-KG.storage.snapshot-read-off-writer tie-in)¶
The reserved lane is safe to grant aggressively because a read never contends for a write lock:
- Cypher / SQL / GraphQL / SPARQL read off an in-memory
GraphCoreanalysis_snapshot_versioned()taken under the topology read lock (heavy compute runs off the lock entirely,CONCEPT:EG-KG.txn.per-graph-write-isolation). - The redb read-through (
read_node, hit only on a RAM miss) serves the evicted node directly off aDatabase::begin_read()MVCC snapshot on the target shard, concurrently with the single writer (CONCEPT:EG-KG.storage.snapshot-read-off-writer) — it never routes through the writer thread and never forces a group-commit.
So the engine's redb tier never returns "database is locked", and a read admitted on the reserved lane completes against a consistent snapshot without ever waiting on the write firehose.
CONFIG¶
| Knob | Default | Meaning |
|---|---|---|
EPISTEMIC_GRAPH_READ_RESERVED |
auto-sized (see below) | Size of the reserved read-admission lane (in-flight read slots). An explicit value > 0 wins; otherwise the auto-size is used. |
The auto-size is Capacity::read_reserved()
(src/autosize.rs):
i.e. an eighth of the global admission cap, floored at 8 so a 1–2 core box still
keeps several read lanes open, ceilinged at 1024. Reads hold a slot only for the
brief off-lock snapshot, so even a small reservation keeps the read path alive.
The value is read once at startup in src/main.rs and logged
(Backpressure: max in-flight = … (per-graph cap = …, reserved read lane = …)).
Observability¶
A read admitted via the reserved lane increments the Prometheus counter:
"Read/query requests admitted via the RESERVED read lane (CONCEPT:EG-KG.coordination.reserved-read-lane) after the global pool / per-graph cap was saturated by writes — each is an interactive read that would otherwise have been shed BUSY behind ingestion."
A rising read_reserved_admitted_total alongside busy_rejections_total is the
signal that the reserved lane is actively keeping reads alive under ingestion
pressure.
Proof tests¶
Three tests in transport::tests
(src/server/transport.rs) prove the guarantee
deterministically against the pure admit_request:
| Test | What it proves |
|---|---|
read_is_admitted_when_write_pool_is_saturated |
With the global pool fully drained by writers, a write sheds BUSY while a read is still Granted via the reserved lane (global.is_none(), read.is_some()). The core "interactive read is never starved behind ingestion" guarantee. |
read_lane_is_bounded_under_a_read_flood |
The reserved lane is itself bounded: holding all reserved permits, the next read floods the lane → BUSY; once a slot frees, reads admit again. The reservation guarantees availability, not unbounded admission. |
reads_survive_under_max_write_load_on_hot_graph |
Concurrency stress: while writers saturate the global pool, a burst of concurrent reads on the same hot graph (__commons__, pg_limit = 4 like the live default) are all admitted, never BUSY — mirrors the live K=4 ingestion symptom at the admission layer. |
All three are pure-function / in-process tests, so the responsiveness guarantee is asserted without standing up a server.
Operating notes — end-to-end resource priority¶
EG-KG.coordination.reserved-read-lane is the engine half of an end-to-end "interactive reads outrank background ingestion" guarantee. It composes with the agent-utilities resource-priority edict (AU-ORCH.scheduling.resource-priority-edict/1.99) on the orchestration side:
- agent-utilities keeps interactive orchestration from being starved by background ingestion at the task scheduling / lane level (background ingestion lanes are capped so they cannot crowd out interactive work).
- EG-KG.coordination.reserved-read-lane enforces the same priority one layer down, at the engine
admission gate: even when ingestion has saturated the engine's global pool and
the
__commons__per-graph cap, an interactive read still gets a lane.
Together they mean an MCP read/query stays responsive end-to-end while the ecosystem ingests continuously. Tuning guidance:
- Default is usually right. The auto-size tracks
max_inflight, so a bigger box gets a bigger reserved lane automatically. - Raise
EPISTEMIC_GRAPH_READ_RESERVEDonly if you observe reads sheddingBUSY(the reserved lane filling) under a genuinely read-heavy and write-heavy mix — e.g. many concurrent interactive agents querying while ingestion runs. Keep it well belowmax_inflightso the reserved reads can't themselves exhaust the box. - It is harmless on a Pi. The lane floors at 8; reads hold a slot only for a snapshot, so the reservation costs almost nothing when idle.
See also: Capabilities matrix · Per-Graph Write Coalescer · Engine Scaling Program · Cost Model & Capacity · Operations Runbook.