Consistency Contract: kernel, server, tenant¶
Issue: nexi-lab/nexus#4737 (read-your-writes revision token). Companions: mount-routing-ssot-gap.md (mount table vs metastore), #4736 (write → searchable, index_seq), #4741 (conformance suite).
This document is the published consistency contract for a Nexus deployment. Section 2 records what the pinned kernel (nexus-vfs at the rev in Cargo.lock) guarantees today, with file pointers so the claims can be re-verified. Section 3 defines the revision token the server exposes, which needs nothing beyond that kernel. Section 5 lists optional kernel work that would extend the token. Cross-node read fixes that were previously narrated only in Cargo.toml comments (PR #108 try_remote_fetch for sys_readdir, PR #138 "read ⊥ cache ⊥ materialize", content_id stamping) are summarised in §2.3 and §2.4; the Cargo.toml comments remain a changelog, not a contract.
1. Vocabulary¶
| Term | Meaning |
|---|---|
| zone | One raft group with its own log and state machine (root, corp-eng, …). A zone is mounted into the global VFS at one or more paths (DT_MOUNT rows). |
gen | Per-path content generation stored in the file's metadata row; increments on every write to that path and is returned by write and stat. |
commit_index | Highest log index a quorum has durably accepted. May run ahead of the state machine (raft/src/raft/node.rs, commit_index() doc: "Do not gate reads on this value"). |
applied_index | Highest log index the local state machine has applied. "A reader that sees applied_index >= N is guaranteed to also see every state-machine effect of log entries with index <= N" (node.rs, applied_index()). |
| revision token | <anchor>@<index>: /ws/a.txt@7 (path + gen, what writes return) or root@1234 (zone + applied_index, optional). §3. |
| mount table | MountTable::entries (Rust, in-memory, per kernel) that route() consults to pick the metastore for a path. |
| dcache | Kernel dentry/metadata cache in front of the per-zone state machine. |
2. What the kernel guarantees today¶
2.1 Mutations are strongly consistent on the leader¶
sys_write, sys_unlink, sys_rename, sys_mkdir, sys_setattr on a federation mount go through ZoneMetaStore → ZoneConsensus::propose (raft/src/zone_meta_store.rs, "Write consistency": put / delete / CAS / append_stream_entry are SC; the EC plane is opt-in for metadata registers only). A proposal's completion is signalled from apply_entries (node.rs, proposal.tx.send(Ok(result)) inside apply_entries), so:
- When a mutation RPC returns on the leader, the leader's state machine has applied it.
- When the mutation was issued on a follower, the proposal is forwarded (
forward_to_leader) and the reply comes from the leader. The follower's own state machine may still be behind when the RPC returns. Writing on a follower and immediately reading on the same follower is therefore not read-your-writes without the fence in §3. - Raft applies committed entries in log order on every node. This is what makes a per-path fence sufficient for whole-zone visibility (§3.4).
2.2 Metadata reads are local (sequentially consistent)¶
sys_stat, sys_readdir, exists, list and every other metadata lookup read the local state machine (node.rs, with_state_machine: "on a follower may be behind the leader by up to the replication lag, ZooKeeper-default style"). Only get_lock / list_locks use the ReadIndex protocol (read_linearizable). A follower can legitimately return found=false / metadata: None for a path the leader has already applied. That is the gap the revision token closes.
2.3 Content reads: local backend, then origin fetch, no cache-back¶
sys_read resolves metadata locally, then reads content from the mount's backend by content_id (kernel/src/kernel/syscall_impl.rs, step 5/7). On a backend miss with metadata present it calls try_remote_fetch: the virtual path is sent to the node named in last_writer_address via the ReadBlob RPC and that peer self-routes through its own VFSRouter. The fetch is a pure read — no local cache-back (nexus-vfs PR #138) — and returns FileNotFound when last_writer_address is unset, equals this node, or the remote call fails. sys_readdir has the same "sender dispatches, remote handles" peer path (PR #108). A writer-side node reading its own placeholder entry serves bytes from the federation cache (federation_cache_substitution_read).
Consequence: once metadata is applied locally (§3 fence satisfied), a read on any node either returns the bytes or fails loudly; it does not return stale bytes for the new content_id.
2.4 dcache invalidation is apply-side¶
Each zone installs an apply-side invalidation callback (Kernel::install_zone_apply_invalidator, wired from ZoneMetaStore::new; raft/src/raft/state_machine.rs "Apply-side invalidation callback — fires once per committed metadata mutation"). The callback fans out to every mount point that shares the zone's coherence_key (kernel/src/core/vfs_router.rs, mount_points_for_coherence_key), so crosslink mounts of one zone are invalidated together. A dcache entry therefore cannot outlive the apply of the mutation that changed it, and a sys_stat that shows the new gen is not a cache artefact.
2.5 Mount table can lag the metastore¶
Mount topology lives in two stores (raft DT_MOUNT rows and MountTable::entries) joined by a Rust → Python → Rust callback chain. Until the chain has run on a freshly (re)started follower, route() misroutes and sys_stat returns metadata: None even when the raft log has caught up. Full analysis and the kernel-side fix are in mount-routing-ssot-gap.md. Under the §3 fence this window is visible rather than silent: the anchor path stats as absent, so the fenced read waits and then answers 412 instead of an empty result.
2.6 What the pinned kernel does not expose¶
- No gRPC response carries
applied_index.WriteResponseis{content_id, size, gen};StatResponsehasgenbut no zone revision. Call("federation_cluster_info")is not served bynexusd-cluster(rust/transport/src/call_dispatch.rsonly dispatches agent / mount-point / service ops; anything else isunknown Call method). The PythonFederationRPCService.federation_cluster_infotherefore returns the standalone stub withapplied_index: 0.stat("/__sys__/zones/<id>")is a placeholder (version: 0,gen: 0)._nexus_raft.pyi(is_committed,set_metadata(consistency=…)) describes the retired PyO3 module. Python talks to the kernel over gRPC (KernelClient), sois_committedis unreachable from Python by construction — the "zero callers" observation in #4737 is structural.
None of this blocks the token: the per-path gen that write and stat already return is enough (§3).
3. Revision token (server ↔ tenant)¶
Implemented in nexus (src/nexus/lib/zone_revision.py, src/nexus/server/api/v2/_revision_fence.py). Works on the pinned kernel.
3.1 Token¶
revision = "<path>@<gen>" e.g. /ws/a.txt@7 (returned by writes)
revision = "<zone_id>@<index>" e.g. root@1234 (optional, kernel-stamped)
The anchor is everything before the last @, so paths containing @ are fine. An anchor starting with / is a path; anything else is a zone id; a bare integer is a zone token for root. Only the path form is emitted today; the zone form is accepted so a kernel that later stamps zone_id / applied_index on mutation responses (§5) needs no server change.
3.2 Where mutations return it¶
| Surface | Field | Header |
|---|---|---|
POST /api/v2/files/write | revision | X-Nexus-Revision |
POST /api/v2/files/batch/write | results[].revision | — |
POST /api/v2/files/copy | revision (destination write) | X-Nexus-Revision |
DELETE /api/v2/files/delete, POST /api/v2/files/rename | revision | X-Nexus-Revision |
NexusFS.write / sys_write / write_batch | "revision" key in the returned dict | — |
Writes always carry /path@gen. Delete and rename have no gen to anchor on and return null unless the kernel stamps a zone revision; a tenant that needs to fence a delete waits for the parent listing to drop the entry, or fences on the next write it makes. mkdir returns None on the Python API and is a follow-up.
3.3 How reads consume it¶
Request (either form; header wins):
X-Nexus-Min-Revision: /ws/a.txt@7 ?min_revision=/ws/a.txt@7
X-Nexus-Revision-Timeout-Ms: 5000 ?revision_timeout_ms=5000
Default timeout 5 000 ms, maximum 30 000 ms, 0 probes once. Applied to:
- files:
read,metadata,exists,list,glob,grep,stream,batch-read,batch/read - search:
query,query/batch,grep(GET and POST),glob(GET and POST)
The fence runs sys_stat(anchor) on the serving node under the caller's OperationContext (permission hooks apply, so it is not an existence oracle for paths the caller cannot read) and compares gen:
| Outcome | Status | Body / headers |
|---|---|---|
stat(anchor).gen >= index within the timeout | 200 (normal response) | X-Nexus-Revision: <anchor>@<observed gen> |
| Not reached before the timeout (path absent counts as gen 0) | 412 Precondition Failed | detail.error = revision_not_applied, detail.min_revision, detail.current_revision, detail.waited_ms; header X-Nexus-Revision: <anchor>@<current> |
Zone token on a kernel that cannot report applied_index | 501 Not Implemented | detail.error = zone_revision_unavailable |
| Zone token, kernel probe failed (transport) | 503 | detail.error = zone_revision_probe_failed |
| Malformed token / timeout | 400 | — |
A fenced read never answers with metadata: None or an empty listing because the node is behind: it waits, then either serves state that includes the write or says 412 with the revision it does have. Unfenced reads are unchanged (no header, no extra round-trip).
3.4 What the fence guarantees¶
- Same path:
read,metadata,exists,streamof the anchor see the write's metadata andcontent_id(§2.2, §2.4). - Same zone: because raft applies in order (§2.1), a node whose stat shows the anchor at
gen >= Ghas applied every earlier entry of that zone.list,glob,grepandsearchover that zone therefore include the write and everything the same client wrote before it. - Content:
sys_readmay still fetch bytes from the origin node (§2.3). It returns the written bytes or fails; never older bytes. - Search:
min_revisionon/search/*fences the VFS state the search plugin reads from. Whether the write is indexed is theindex_seqcontract of #4736 (/search/stats.last_index_seq >= index_seqreturned by index-on-write). A tenant that needs "written and searchable" checks both. - Other zones: a token fences only the zone that owns its anchor. A listing that spans mounts of several zones is fenced for the anchor's zone only.
Known edge: a path that is deleted and re-created starts a new gen sequence, so a token from the old life may never be satisfied and yields 412 after the timeout. Use the token from the latest write.
3.5 Tenant guidance¶
- Keep
revisionfrom every write response next to thecontent_id. - Send it as
X-Nexus-Min-Revisionon the read, list or search that must observe the write (any node behind the same load balancer). 412means "this node has not applied it yet": retry after the returnedcurrent_revisionadvances, or route to another node.- Batches: fence on the token of the item you are about to read, or on the last item's token to cover the whole batch.
Example:
curl -s -X POST "$NEXUS/api/v2/files/write" -H "Authorization: Bearer $KEY" \
-d '{"path":"/ws/a.txt","content":"hello"}'
# {"content_id":"…","version":3,"size":5,"modified_at":"…","revision":"/ws/a.txt@3"}
curl -s -D - "$NEXUS/api/v2/files/list?path=/ws" -H "Authorization: Bearer $KEY" \
-H "X-Nexus-Min-Revision: /ws/a.txt@3"
# HTTP/1.1 200 OK (or 412 with current_revision)
# X-Nexus-Revision: /ws/a.txt@3
4. Readiness¶
/healthz/ready is unchanged by #4737. While a freshly restarted follower's mount table lags the metastore (§2.5) fenced reads on that node answer 412, so the window is observable and retryable rather than silent. Gating readiness on mount-table convergence needs a kernel signal (§5 item 3) and stays with mount-routing-ssot-gap.md.
5. Optional kernel work (nexus-vfs)¶
Nothing here is required for §3. Each item widens the token and is consumed by the server already, tolerant of absence:
- Stamp mutations with a zone revision. Add
string zone_idanduint64 applied_indextoWriteResponse,BatchWriteItemResponse,DeleteResponse,RenameResponse,MkdirResponse,CopyResponse(proto/nexus/grpc/vfs/vfs.proto). Gives delete and rename a token and lets a client fence "everything so far" with one number instead of a path.KernelClientreads the fields viarevision_fields()andrevision_from_result()prefers them over the path form. - Serve
Call("federation_cluster_info", {"zone_id"})fromcall_dispatch.rs→DistributedCoordinator::cluster_infoas JSON (zone_id, node_id, has_store, is_leader, leader_id, term, commit_index, applied_index, voter_count, witness_count, links_count). This is whatread_zone_revisionpolls for zone tokens; without it they answer 501. - Mount convergence signal (
Call("federation_mount_convergence")or a/__sys__/mountsview) plus the apply-sideadd_mountcallback frommount-routing-ssot-gap.md, so readiness can gate on it. - Retire
_nexus_raft.pyior mark it as the description of an internal Rust API; nothing insrc/can import it.
6. Acceptance (tracked in #4741)¶
- Embedded, remote and 2-node federated: write returns
revision = R;read,list,glob,grepandsearch/querywithmin_revision = Robserve the write, on the leader and on the follower. - A follower asked for a revision it has not applied within the timeout returns 412 with
current_revision, nevermetadata: None. - A zone token on a kernel without §5 items 1–2 is 501; no test may pass by treating that as success.