A read-write server

Copy Markdown View Source

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:

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, 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:
  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:

  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:

    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.

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.