Current section
Files
Jump to
Current section
Files
documentation/tutorials/read-write-server.md
# A read-write server
The [read-only tutorial](read-only-server.html) 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:
```bash
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. An `Agent` holding
`%{tree, next_fileid, writeverf}`, plus every pure helper the
backends need: handle packing, attribute building, `sattr3`
application, `wcc_data` capture.
- **`Tahr.InMemory.MountBackend`** — publishes `/export`,
exactly like the read-only tutorial's version.
- **`Tahr.InMemory.NFSBackend`** — all 21
`Tahr.NFSv3.Backend` callbacks for real.
`InMemory.programs/0` returns the program registry, so standing the
server up is:
```elixir
: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:
```elixir
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:
```elixir
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, apply `sattr` if it
already exists (this is how `O_TRUNC` arrives).
- `{:guarded, sattr}` — fail with `NFS3ERR_EXIST` if 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 with `NFS3ERR_EXIST`. The backend stores the
verifier on the node at creation and compares on retry:
```elixir
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
end
```
## SETATTR 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:
```elixir
defp guard_matches?(_node, nil), do: true
defp guard_matches?(node, %Nfstime3{} = ctime), do: node.ctime == ctime
```
`InMemory.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:
1. **Cycles.** `rename(/a, /a/b)` must fail with `NFS3ERR_INVAL`.
The backend walks the moved directory's subtree before committing:
```elixir
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}
end
```
2. **The existing destination.** Replacing a non-empty directory
fails with `NFS3ERR_NOTEMPTY`; dropping a file onto a directory
(or vice versa) fails with `NFS3ERR_ISDIR` / `NFS3ERR_NOTDIR`.
Otherwise the victim is unlinked first — respecting hard links,
so a node with `nlink > 1` loses 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:
```elixir
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:
```bash
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`.