Orlop Metadata Mirror: Local Reads, Proven Freshness
Status: implemented as of v0.6.0 (issue #122). The read-through mirror ships enabled by default (
ORLOP_METADATA_MIRROR=0disables it). Pipelined unlink is implemented but opt-in (ORLOP_METADATA_PIPELINE=1) until its performance gate is demonstrated on production workload benchmarks (section 6, gate 4).
Orlop’s remaining performance gap is metadata-heavy work, not bulk throughput. Measured on a production agent disk (200 small files, five rounds, medians):
| Workload | Durable disk | Local ext4 | Gap |
|---|---|---|---|
| create small files | 115.2 files/s | 1,161.8 files/s | 10.1x |
| walk the tree | 69.1 ms | 4.6 ms | 15x |
| delete the tree | 812.5 ms | 6.9 ms | 117.8x |
| 1 GiB sequential write | 196.9 MiB/s | 953.8 MiB/s | 4.8x |
Sequential I/O is already served by the chunk data plane and the local chunk
cache. The metadata gap has a single cause: every lookup, readdir,
readlink, and manifest fetch is a fresh network round trip, and every
mutation pays extra round trips to learn versions the client recently saw.
unlink costs two round trips (MANIFEST_GET for the version, then
MANIFEST_DELETE), rename costs three, create costs two (LEASE_GRANT
plus MANIFEST_PUT).
This document specifies the fix: a client-side metadata mirror, hydrated and invalidated by a server change feed, with explicit durability fences for the one mutation path that becomes asynchronous. The server remains the single source of truth. Nothing in this design weakens CAS versioning, leases, audit, quota, or the per-agent path moat, and it introduces no last-writer-wins behavior anywhere.
1. Shape of the solution
Section titled “1. Shape of the solution”Three pieces:
- Revision stamping (server). Every metadata mutation, from any writer, stamps the rows it touches with a per-tenant monotonic revision and records tombstones for deletions. This turns the tenant database itself into a coalesced change log: “everything that changed since revision N” is a query, not a replay.
- Change feed (wire). Two new client ops and one new server push let a
mount fetch that query incrementally through a
(rev, path)cursor and subscribe to live change events on the existing data connection. - Mirror (client). The mount keeps a local SQLite mirror of its subtree’s
metadata and serves
lookup,getattr,readdir,readlink, and manifest reads from it, but only while a live subscription plus a caught-up cursor prove the mirror is current. The moment that proof lapses, every read goes back to the server, exactly as today.
Two designs were considered and rejected:
- Journal replay. The session journal cannot drive a mirror: it records
only manifest ops (
mkdir,rmdir,symlink,mknod,chown, andutimensatwrite no journal rows), itsseqis per mount session rather than per disk (a new session restarts at 1, so a sequence watermark silently skips rows across sessions), and some writers (agent purge, seeding) bypass it entirely. The journal keeps its job: audit and revert. The change feed is a separate, complete mechanism. - Last-writer-wins sync. Cloudflare’s
computerproject keeps a local SQLite workspace mirror and syncs revisioned content-addressed deltas; its mirror shape and cursor design inform this one. But its preview protocol accepts silent lost updates in three places (concurrent pushes, type conflicts, per-path coalescing of intermediate states). Orlop already has per-path CAS and returnsESTALEto the losing writer; this design keeps that behavior everywhere.
2. Revision stamping
Section titled “2. Revision stamping”The per-tenant SQLite database gains one counter, one column per metadata
table, and one tombstone table (all additive, applied by
ensureTenantSchema like prior column additions):
create table change_counter ( singleton integer primary key check (singleton = 1), next_rev integer not null);
alter table manifests add column rev integer not null default 0;alter table dir_entries add column rev integer not null default 0;alter table symlinks add column rev integer not null default 0;alter table special_nodes add column rev integer not null default 0;
create table change_tombstones ( path text primary key, rev integer not null);Every transaction that mutates metadata allocates one revision (read
next_rev, increment it, same transaction) and stamps it onto every row it
creates or modifies. Deleting a path upserts a tombstone at that revision;
re-creating the path deletes the tombstone (the live row’s newer revision
supersedes it). A directory rename restamps the whole subtree at one
revision, which is why the feed cursor is a (rev, path) pair rather than a
scalar (section 3).
Stamping is unconditional. It happens inside the same SQLite transaction as the mutation for every writer: the nine data-plane mutation ops, journal revert, agent purge, and offline seeding/migration tools. Unlike the journal it is not gated on a session id, because a mirror that misses even one writer’s changes serves stale data. This is also why lease possession is never used as a freshness proof: journal revert and agent purge legitimately mutate metadata without holding the mount’s leases, and the feed is the only channel that makes those mutations visible to the client.
Because the counter, the row stamps, and the tombstones commit atomically with the mutation itself, the feed can never observe a revision without its rows or rows without their revision, including across a crash that rolls back the WAL tail.
Tombstone retention. Tombstones are pruned after a retention window, and
a pruned_before_rev watermark records the oldest revision the feed can
still serve. A client resuming from a cursor older than that watermark is
told to rebaseline (section 3). This bounds table growth without any
server-side bookkeeping of client cursors.
3. The change feed
Section titled “3. The change feed”Three new frame types on the existing mTLS data connection, mirrored in Go
(cmd/orlop-server/dataplane) and Rust (src/backend/dataplane) like every
other op:
| Op | Hex | Direction |
|---|---|---|
CHANGES_FETCH |
0x1B | client → server |
CHANGES_SUBSCRIBE |
0x1C | client → server |
CHANGES_EVENT |
0x1D | server → client (push) |
Negotiation is explicit. Every CHANGES_FETCH and CHANGES_SUBSCRIBE
request carries sync_protocol (currently 1); the response echoes the
version the server will speak. A server that does not implement the feed
answers with EINVAL (unknown op), and the client runs with the mirror
disabled, byte-for-byte identical to today’s behavior. A client never
interprets feed frames until it has seen its own sync_protocol echoed, so
an old client cannot misread a new stream and a new client cannot misread an
old server. Unknown push frames were already discarded by older clients, so
CHANGES_EVENT is safe to introduce on a shared connection.
CHANGES_FETCH pages through the coalesced state:
request: { sync_protocol, cursor_rev, cursor_path, limit, include_chunks }response: { sync_protocol, entries, next_rev, next_path, current_rev, resync_required }The response contains every live entry and tombstone in the caller’s subtree
with (rev, path) strictly greater than (cursor_rev, cursor_path), ordered
by (rev, path), capped at limit. A fresh mirror starts from (0, "");
because the comparison is lexicographic on the pair, pre-existing rows whose
stamp is still the schema default 0 are delivered too. The cursor is a
resume point, not a snapshot: a path that changes mid-hydration simply
reappears later in the stream at its new revision, and convergence holds
because the cursor never advances past a revision that still has undelivered
paths. resync_required is returned when cursor_rev predates
pruned_before_rev; the client must discard its mirror and restart from
(0, "").
Each entry is final-state, one per path:
{ path, rev, kind, -- file | dir | symlink | special | tombstone size, mode, mtime, uid, gid, atime, nlink, inode_id, version, -- the manifest CAS version, files only link_target, rdev, chunks } -- packed chunk list, when include_chunksEntries carry everything lookup, getattr, readdir, and readlink need,
plus (with include_chunks) the manifest chunk list so reads need no
MANIFEST_GET either. There is no rename opcode and no operation history:
final-state entries are idempotent to apply, indifferent to receiver history,
and make cold start the same code path as catch-up.
CHANGES_SUBSCRIBE registers the connection for live events and returns
current_rev. From then on the server pushes CHANGES_EVENT frames,
{ entries, current_rev, reset }, from the same post-commit broadcast hook
that feeds the journal pub/sub. Delivery is per-allocation, filtered to the
agent’s subtree, and non-blocking: if a subscriber’s buffer overflows, the
server pushes { reset: true, current_rev } and drops the buffered backlog.
A reset tells the client its event stream has a gap; it falls back to
CHANGES_FETCH from its last applied cursor and serves reads from the server
until it has caught back up. The subscription dies with the connection.
Authorization and audit. Feed requests pass the same policy check and
agent-subtree confinement as every other op: the feed for an agent-scoped
certificate contains only paths under /<agentID>, enforced server-side by
the same checkAgentPath logic, and denials audit exactly like any other
denied op. Fetch and subscribe are themselves audited ops. The deny and audit
semantics of all existing ops are untouched.
4. The client mirror
Section titled “4. The client mirror”The mirror is one SQLite database per allocation under the existing cache
root ($XDG_CACHE_HOME/orlop, else $HOME/.cache/orlop), next to the chunk
cache index:
<cache-root>/ chunks/… index.sqlite # chunk LRU index (existing) mirror/ <allocation-id>.sqliteOne entries table in the shape of the wire entry (plus a derived parent
column indexed for readdir), and one mirror_meta table holding the
applied cursor, the negotiated sync_protocol, the server identity, and the
mirror schema version. The database opens with WAL and synchronous=NORMAL;
it is a cache, not a store of record. Validation is fail-closed, in the same
spirit as the live-handoff snapshot: wrong schema version, wrong server
identity, or any integrity error deletes the file and rehydrates from
(0, ""). A missing or corrupt mirror never fails a mount; it only means the
first reads go to the server.
The freshness invariant. The mount serves a metadata read locally if and only if all of the following hold:
- a
CHANGES_SUBSCRIBEis live on the current data connection, - the applied cursor has reached the
current_revreported at subscribe (or by a later event), and - no unprocessed
resetis pending.
Otherwise, and during hydration, reconnection, or after any reset, every read goes to the server exactly as today. Freshness is proven by revisions, never by leases, and never by elapsed time.
Serving matrix, once fresh:
| Operation | Today | With mirror |
|---|---|---|
lookup / getattr |
network on first touch, then unproven in-memory cache | mirror, proven |
readdir |
LIST every call |
mirror |
readlink |
READLINK every call |
mirror |
| manifest fetch for reads | MANIFEST_GET every open |
mirror (chunk list included) |
version lookup before unlink / rename |
extra MANIFEST_GET round trips |
mirror |
| chunk data | chunk cache, unchanged | chunk cache, unchanged |
Mutations remain synchronous wire ops. On a successful reply the client
applies the result to the mirror immediately (it knows the path and the new
version from the response) rather than waiting for its own change event;
events apply as upserts guarded by rev, so the echo of a local mutation is
idempotent. Mutation round trips also shrink, because the mirror already
holds the CAS version: unlink drops from two round trips to one and
rename from three to one. The CAS itself is unchanged; if the mirror’s
version is stale the server answers ESTALE with a RecoveryHint, the
client refetches, reconciles the mirror, and retries, the same conflict path
that exists today.
The macOS NFS server gains the same mirror through the shared store layer.
Because the wire EntryWire also gains mtime and version fields
(append-only msgpack, the sanctioned compatibility path), the NFS
getattr/readdir implementation stops issuing one MANIFEST_GET per file
even when the mirror is cold.
Stale manifests and GC. A mirror entry can briefly reference chunks that
an external mutation replaced. Refcounted GC’s retention window already keeps
just-unreferenced chunks alive; if a CHUNK_GET still misses, the client
drops the mirror entry, refetches the manifest, and repairs the mirror. A
stale read is thus detectable and self-healing, never silently wrong bytes
(chunks remain content-verified on every read).
Audit posture. A read served from the mirror does not produce a
server-side audit row, just as FUSE getattr served from the in-memory node
table produces none today. The audit stream remains complete for mutations,
for every denied operation, and for every op that reaches the wire. Paths the
policy or the agent moat would deny never enter the mirror, because the feed
is filtered server-side by the same checks; EACCES results are never
cached.
5. Durability and fences
Section titled “5. Durability and fences”Read-through mode (the default). Write acknowledgement semantics are
unchanged. A metadata mutation is durable when the server’s success frame
arrives, meaning the transaction committed to the tenant SQLite database
under WAL with synchronous=NORMAL: it survives a server process crash; an
unclean server host crash can roll back the newest committed transactions in
the un-checkpointed WAL tail. That window predates this design and is
unchanged by it. A client crash after the reply loses nothing; a client crash
before the reply loses at most the in-flight op, which the kernel never saw
acknowledged.
Pipelined mode (opt-in until its gate is met). One mutation becomes
asynchronous: unlink. The FUSE reply returns once the unlink is appended to
a bounded, ordered, per-mount queue; queued unlinks are issued in order over
the existing connection, carrying CAS versions from the mirror. The mirror
applies the unlink immediately, so every subsequent local operation
(lookup, readdir, a recreate at the same path) observes it; ordering
guarantees the server converges to the same state.
An acknowledged-but-unsent unlink is lost if the client process or host
crashes: the file simply still exists on the durable disk afterward. POSIX
makes the same statement about local filesystems, where an unlink is durable
only after fsync of the parent directory. The fences below are therefore
the durability contract, and each one drains the queue (or the affected
subset) and reports any queued failure before returning:
| Fence | Scope drained |
|---|---|
fsync / fdatasync on a file or directory |
whole queue |
syncfs |
whole queue |
rmdir |
queued unlinks under that directory |
rename |
queued ops intersecting source or destination subtree |
| clean unmount, live handoff | whole queue |
| lease revoke, connection loss, subscription reset | whole queue |
| any op whose server reply could contradict a queued op | affected paths |
A queued unlink that fails on the server (for example ESTALE from a
concurrent external mutation) surfaces as an error on the next fence,
fsync-reports-writeback-error style, and forces a mirror
reconcile. The queue is bounded (ops and bytes); when full, unlink becomes
synchronous rather than growing without limit. create stays synchronous in
all modes, but stops paying a separate LEASE_GRANT round trip where the
mount’s exclusivity already covers the path.
If the adversarial suite (section 6) cannot demonstrate these semantics exactly, pipelined mode does not ship and the deliverable is the read-through mirror with synchronous mutations, per the issue’s own fallback.
6. Acceptance gates
Section titled “6. Acceptance gates”From issue #122, restated as the checklist this work merges against:
- Durability documented. Section 5 is the specification: exact acknowledgement points, the WAL-tail caveat, the pipelined crash window, and the fence table.
- Adversarial tests. Reconnect, cursor loss,
resync_requiredrebaseline, duplicate and out-of-order event delivery, external mutation via journal revert and agent purge while mounted, lease loss, CAS conflict on a mirror-supplied version, and crash during a pipelined drain. Each must show the mirror either serving proven-fresh data or falling back to server reads, never serving stale data silently. - Workload benchmarks. create/stat/walk/delete on small-file trees,
npm install,git checkoutandgit status, and large sequential I/O, reported with rounds, medians, and a discarded-measurements note, against both the previous release and a local-disk baseline. - Performance. At least 5x on two of the three worst metadata scenarios (create 10.1x, walk 15x, delete 117.8x), with no more than a 10% regression in large sequential I/O. Expected sources: walk from the mirror alone; delete from the mirror (two round trips to one) plus pipelining; create from the merged lease grant.
- Explicit negotiation.
sync_protocolin every feed request and response; unknown op or unknown version disables the mirror cleanly. - Fallback scope. If gate 2 fails for pipelined mode, ship read-through only; synchronous mutation commits remain the default acknowledgement semantics.
7. Compatibility
Section titled “7. Compatibility”| Client \ Server | pre-feed server | feed-capable server |
|---|---|---|
| pre-mirror client | unchanged | unchanged (never sends feed ops, ignores unknown pushes) |
| mirror client | EINVAL on first fetch, mirror disabled, behavior identical to today |
negotiated via sync_protocol |
All wire changes are additive: new op codes, append-only msgpack fields
(EntryWire.mtime, EntryWire.version), no changes to existing frames, no
reserved-byte reuse. The tenant database changes are additive columns and
tables applied by the existing schema-ensure path. The mirror file is a pure
cache: deleting it is always safe.