The read-only tutorial ends where every
interesting question begins: what happens when clients start
writing? This tutorial walks through Tahr.InMemory — the
read-write reference backend that ships with the library and powers
mix tahr.serve — and explains the protocol obligations that
only show up once state can change.
Try it before reading on:
mix tahr.serve
# in another terminal
sudo mount -t nfs -o vers=3,tcp,port=2049,mountport=2049 \
127.0.0.1:/export /mnt/nfs
echo "it works" > /mnt/nfs/proof.txt
ls -l /mnt/nfs
The shape
Three modules:
Tahr.InMemory— the store. AnAgentholding%{tree, next_fileid, writeverf}, plus every pure helper the backends need: handle packing, attribute building,sattr3application,wcc_datacapture.Tahr.InMemory.MountBackend— publishes/export, exactly like the read-only tutorial's version.Tahr.InMemory.NFSBackend— all 21Tahr.NFSv3.Backendcallbacks for real.
InMemory.programs/0 returns the program registry, so standing the
server up is:
:ok = Tahr.InMemory.start()
children = [
{Tahr.RPC.Server, port: 2049, programs: Tahr.InMemory.programs()}
]Why an Agent
Every mutation callback in the read-only tutorial was a pure lookup.
Writes force a choice: who owns the tree, and what stops two
clients racing a rename against a readdir? The Agent answers both —
InMemory.update/1 runs each mutation inside
Agent.get_and_update/3, so mutations serialise. Reads go through
InMemory.get/1 and never block a writer for longer than one
callback.
That single decision makes most of the protocol's concurrency obligations trivial, and it's worth copying: NFSv3's consistency model assumes operations arrive at the server in some order. An Agent gives you one.
Weak cache consistency
Open any mutation callback and you'll meet wcc_data. NFSv3
clients cache aggressively; when something changes a file or
directory, the server reports the object's attributes from before
and after the operation so the client can tell "my cache is stale"
from "I made this change myself". Both halves are optional (nil),
but a client that never gets them must revalidate constantly.
The pattern in every mutation:
pre = InMemory.wcc_pre(node) # size + mtime + ctime, before
# ...apply the change to the tree...
%WccData{before: pre, after: InMemory.attr(fileid, node)}Errors carry the same envelope — the client needs to refresh its
cache whether the operation landed or not. RENAME is the odd one
out: it moves between two directories, so it reports wcc_data for
both parents.
WRITE and COMMIT
WRITE replies state how durable the data is (:unstable,
:data_sync, or :file_sync) alongside a per-boot writeverf3.
Clients use the verifier to detect a server restart: if it changes,
uncommitted unstable writes may have been lost and must be resent.
The in-memory backend keeps things honest and simple — every write
goes straight into the tree, so it reports :file_sync and COMMIT
has nothing left to flush:
reply =
{:ok,
%{
wcc: %WccData{before: pre, after: InMemory.attr(fileid, file)},
count: byte_size(data),
committed: :file_sync,
verf: state.writeverf
}}The verifier is generated once per boot with
:crypto.strong_rand_bytes(8) — a restart really does change it.
A production backend with a write-behind cache would return
:unstable or :data_sync, track dirty regions, and make COMMIT
actually flush.
CREATE's three modes
CREATE is one callback with three personalities, selected by a discriminated union:
{:unchecked, sattr}— create if missing, applysattrif it already exists (this is howO_TRUNCarrives).{:guarded, sattr}— fail withNFS3ERR_EXISTif the name is taken.{:exclusive, verf}— atomic create-if-not-exists. The subtle one: a retried EXCLUSIVE create (client didn't see the reply) presents the same 8-byte verifier, and must succeed idempotently rather than failing withNFS3ERR_EXIST. The backend stores the verifier on the node at creation and compares on retry:
defp create_existing(state, dir_id, existing_id, {:exclusive, verf}, pre) do
file = Map.fetch!(state.tree, existing_id)
wcc = InMemory.wcc(pre, dir_id, state.tree)
if file.create_verf == verf do
reply = {:ok, InMemory.handle_for(existing_id), InMemory.attr(existing_id, file), wcc}
{reply, state}
else
{{:error, :exist, wcc}, state}
end
endSETATTR and its guard
guard_ctime is the client's optimistic-concurrency check: "apply
these attributes only if the file hasn't changed since I read it."
Compare against the file's ctime and refuse with
NFS3ERR_NOT_SYNC on mismatch:
defp guard_matches?(_node, nil), do: true
defp guard_matches?(node, %Nfstime3{} = ctime), do: node.ctime == ctimeInMemory.apply_sattr/3 handles the rest of the sattr3 contract:
nil fields stay unchanged, :set_to_server_time and
{:client, time} both work, size changes truncate or zero-pad,
and ctime always advances.
RENAME's sharp edges
Two worth keeping:
Cycles.
rename(/a, /a/b)must fail withNFS3ERR_INVAL. The backend walks the moved directory's subtree before committing:if node.type == :dir and descendant?(state.tree, fileid, to_id) do {from_wcc, to_wcc} = rename_wcc(state.tree, from_dir, from_id, to_dir, to_id) {{:error, :inval, from_wcc, to_wcc}, state} endThe existing destination. Replacing a non-empty directory fails with
NFS3ERR_NOTEMPTY; dropping a file onto a directory (or vice versa) fails withNFS3ERR_ISDIR/NFS3ERR_NOTDIR. Otherwise the victim is unlinked first — respecting hard links, so a node withnlink > 1loses a link rather than its content.
Cookie verifiers that actually change
The read-only tutorial used a constant cookieverf3 because its
tree never changes. Here it can't be constant: a client paginating
through READDIR while another client creates files must be told to
restart. InMemory.verf_for/1 derives the verifier from the
directory's mtime — every mutation bumps the mtime, every stale
pagination gets NFS3ERR_BAD_COOKIE and restarts from cookie 0:
def verf_for(%{mtime: %Nfstime3{seconds: seconds}}), do: <<seconds::big-unsigned-64>>Documented limitations
A reference backend stays readable by not pretending to be production-grade. The module docs carry the full list; the highlights:
- READDIR/READDIRPLUS ignore the client's byte budgets and return
everything. Fine for small directories; a production backend must
stop when the next entry would exceed
maxcount. - ACCESS grants everything. A production backend computes the grant from the credential's uid/gid and the node's mode.
- MKNOD refuses with
NFS3ERR_NOTSUPP, which RFC 1813 explicitly permits.
The serve task
mix tahr.serve boots Tahr.InMemory behind an RPC
listener without any application wiring:
mix tahr.serve # 127.0.0.1:2049
mix tahr.serve --port 0 # ephemeral port, printed on startup
mix tahr.serve --bind 0.0.0.0 # all interfaces
The task prints the exact mount command for the bound address and
port. See Mix.Tasks.Tahr.Serve for the full option list.
Where next
You've seen every moving part a production backend needs: a state
owner with serialised mutations, wcc capture on every change, a
per-boot write verifier, mtime-derived cookie verifiers, and honest
nfsstat3 mapping. Everything else — ETS tables, databases, object
stores, clustered filesystems — is the same shape with a different
tree.