Skip to main content

atlas-native filesystems

An atlas-native cluster (docs/NATIVE_NODE.md) serves POSIX filesystems next to its block volumes. Files and directories live in the same replicated catalog as volumes, and file data uses the same copy-on-write extents (3 replicas) on a per-filesystem grid. Clients mount a filesystem through FUSE with atlas-native-mount; the Atlas gateway creates, snapshots and clones filesystems as filesystem volumes on backend bkd_native.

Model​

  • A filesystem (/v1/fs) is an inode table inside the catalog: id, name, root inode 1, and an optional source_snapshot for clones.
  • An inode is a directory (name → inode map), a regular file (offset → extent map plus size), a symlink (target, at most 4096 bytes) or a special file (FIFO, socket, character or block device, with rdev). Each has mode, uid, gid, nlink and atime/mtime/ctime in nanoseconds. Names are 1–255 bytes, without / or NUL, and never . or ...
  • Every change is one Raft command (MetaCommand::Fs) applied identically on each replica. Timestamps are chosen by the leader and travel in the command, so replicas stay deterministic. Creates carry a client-chosen op_id: a retried create returns the inode it already made instead of failing with "exists".
  • Hard links, rename (including over an existing entry, across directories, and refusing to move a directory into itself), setattr (mode, owner, size, times) and truncate follow POSIX. Shrinking a file drops the extents past the new end and trims the extent that straddles it.
  • File data is written through the extent path the volumes use, on the filesystem's own grid (extent_bytes, chosen at create time): a write covering part of an extent rewrites that extent with the old bytes merged in; never-written ranges are holes and read as zeros; reads stop at end of file.
  • Snapshots copy a filesystem's inode table under a snapshot id and take a reference on every extent it points to; no data is copied. A snapshot is read-only and addressed as <fs>@<snapshot>. Clones start a new filesystem from a snapshot. Writes on either side allocate new extents, so a filesystem, its snapshots and its clones never see each other's changes. Each survives deleting the others; extents are freed by GC once nothing references them.

Node API​

All routes need the API token (and client certificate where configured), like the rest of /v1/*.

Method and pathDescription
GET /v1/fs{"filesystems": [{id, name, inodes, bytes, source_snapshot, extent_bytes}]}.
POST /v1/fs{"name", "id"?, "extent_bytes"?} → 201 {"id"}. Idempotent with the same id and name. extent_bytes (default 1 MiB, at most the cluster's extent_bytes) is the file extent grid; snapshots and clones keep it.
DELETE /v1/fs/{fs}204. Snapshots and clones of it are unaffected.
GET /v1/fs/{fs}/statfs{inodes, used_bytes, free_list_bytes, quota?}.
GET /v1/fs/{fs}/quota{max_bytes?, max_inodes?} ({} without a quota; see "Quotas").
PUT /v1/fs/{fs}/quota{"max_bytes"?, "max_inodes"?} replaces the quota; {} removes it. Unknown fields are refused.
GET /v1/fs/{fs}/locality?path=&max_inodes=Where a file or directory tree's data lives (see "Dataset locality"). path defaults to /, max_inodes to 100000.
POST /v1/fs/{fs}/locality/pin{"hosts": [...], "path"?, "after"?, "max_extents"?, "max_inodes"?} → {examined, local, moved, bytes_moved, tiered, erasure_coded, deferred, next, truncated}. Leader only.
POST /v1/fs/{fs}/rename{parent, name, new_parent, new_name} → 204.
POST /v1/fs/{fs}/snapshots{"name", "id"?} → 201 {"id"}.
GET /v1/fs-snapshots{"snapshots": [{id, fs_id, name, created_ns, inodes}]}.
DELETE /v1/fs-snapshots/{id}204.
POST /v1/fs-snapshots/{id}/clone{"name", "id"?} → 201 {"id"} of the new filesystem.
GET /v1/fs/{fs}/inodes/{ino}Attributes: {ino, kind, mode, uid, gid, nlink, size, blocks, rdev, atime_ns, mtime_ns, ctime_ns}.
POST /v1/fs/{fs}/inodes/{ino}/attrAny of {mode, uid, gid, size, atime_ns, mtime_ns} → attributes.
GET /v1/fs/{fs}/inodes/{dir}/lookup?name=Attributes of the entry (name percent-encoded; . is the directory, .. its parent).
GET /v1/fs/{fs}/inodes/{dir}/entries{"entries": [{name, ino, kind}]} in name order. ?limit=N returns at most N; ?after=<name> (percent-encoded) starts after that name. Without limit, the whole directory.
POST /v1/fs/{fs}/inodes/{dir}/entries{name, op_id, kind, mode, uid, gid, target?, rdev?} (kind: file, dir, symlink, fifo, socket, char_device, block_device) → 201 attributes.
POST /v1/fs/{fs}/inodes/{dir}/unlink, /rmdir{name} → 204.
POST /v1/fs/{fs}/inodes/{ino}/links{parent, name}: a hard link → attributes.
GET /v1/fs/{fs}/inodes/{ino}/target{"target"} of a symlink.
GET /v1/fs/{fs}/inodes/{ino}/xattrs{"names": [...]} of the extended attributes.
GET /v1/fs/{fs}/inodes/{ino}/xattrs/{name}Raw value (name percent-encoded); 404 no_attr if unset.
PUT /v1/fs/{fs}/inodes/{ino}/xattrs/{name}?mode=Raw body as the value → 204. mode: set (default), create (409 exists if set), replace (404 no_attr if unset).
DELETE /v1/fs/{fs}/inodes/{ino}/xattrs/{name}204; 404 no_attr if unset.
GET /v1/fs/{fs}/inodes/{ino}/data?offset=N&len=MRaw bytes; short at end of file.
PUT /v1/fs/{fs}/inodes/{ino}/data?offset=NRaw body written at offset (extends the file) → attributes.
POST /v1/fs/{fs}/sessions{"session", "ttl_ms", "cache"?} (TTL 1000–300000): opens a client session, or renews it with a new TTL → {session, ttl_ms, expires_ms}. Session ids are client-chosen (the mount uses a UUID). cache: true if the session will hold cache leases.
GET /v1/fs/{fs}/sessions/{id}/recalls?wait_ms={"inos": [...]}: cache leases the session must give back, waiting up to wait_ms (at most 25 s) for one. Leader only.
POST /v1/fs/{fs}/sessions/{id}/recalls/done{"inos": [...]}: the session dropped what it cached under these leases → 204.
POST /v1/fs/{fs}/sessions/{id}/renew→ {session, ttl_ms, expires_ms}; 410 no_session once it expired or was closed.
DELETE /v1/fs/{fs}/sessions/{id}204; releases every lock the session holds.
POST /v1/fs/{fs}/inodes/{ino}/locks{session, owner, kind, start?, end?, pid?} (kind: read, write, unlock; end inclusive, default end of file) → 204, or 409 locked on another owner's conflicting lock. POSIX F_SETLK semantics per (session, owner); renews the session. Regular files only.
GET /v1/fs/{fs}/inodes/{ino}/locks?session=&owner=&kind=&start=&end={"conflict": {ino, session, owner, kind, start, end, pid} | null}: the first lock that would block the request (F_GETLK).
POST /v1/fs/{fs}/inodes/{ino}/locks/release{session, owner}: drops the owner's locks on the inode (a close) → 204.
GET /v1/fs/{fs}/locks{"sessions": N, "locks": [...]}: every lock held on the filesystem and the open sessions in its metadata group.

{fs} may be <fs>@<snapshot> for reads; any change to a snapshot gets 409 read_only.

?session=<id> names the client session a request comes from. On GET .../inodes/{ino} and GET .../lookup, &lease=1 also asks for a cache lease: the leader confirms its leadership (a read barrier), grants the session a lease on the inode (on a lookup, on the directory and the child) and adds "lease_ms" to the attributes; no lease_ms means none was granted (a change to the inode is in flight, a follower answered, or the tree is a snapshot).

Errors are {"error", "code", "leader"}. code is stable and maps to an errno in the client: not_found (404, ENOENT), exists (EEXIST), not_empty (ENOTEMPTY), not_dir (ENOTDIR), is_dir (EISDIR), invalid (EINVAL), read_only (EROFS), busy (EBUSY), no_attr (404, ENODATA), too_big (E2BIG), unsupported (EOPNOTSUPP), locked (EAGAIN), no_session (410, ENOLCK) — all 409 except where noted — plus not_leader (421), unavailable (503) and internal (500).

Reads are served by the leader once it has applied its log as of the request, so a client always sees its own writes. ?barrier=1 on a GET lets any replica answer after a read barrier (still linearizable, at the cost of one round trip to the leader), and ?stale=1 reads whatever the receiving replica has applied. Mutations go to the leader; followers answer 421 with the leader's id.

atlas-native-mount​

crates/atlas-native-fuse (bin atlas-native-mount, fuse cargo feature, Linux only):

atlas-native-mount --endpoint https://n1:7480,https://n2:7480,https://n3:7480 \
--token-file /etc/atlas-native/token --ca-file ca.pem --fs <id> /mnt/data
atlas-native-mount ... --fs <id> --snapshot <snapshot-id> /mnt/data-at-snap # read-only

Options: --identity-file (client certificate + key PEM), --ttl-ms (attribute and name cache, default 1000), --writeback-bytes (default 4 MiB), --writeback-parallel (full runs sent at once in the background, default 4; 0 sends them in the write call), --readahead-bytes (default 4 MiB, 0 disables), --max-io-bytes (largest request, default 8 MiB; keep at or below the nodes' max_request_bytes), --retry-secs (default 30), --read-only, --allow-other, --fuse-threads (kernel request workers, each with its own /dev/fuse fd, default 4), --session-ttl-ms (lease of the mount's session, default 15000), --cache-leases (see Consistency), --acl (see "POSIX ACLs"), --direct-reads and --prefer-host (below). It runs in the foreground until fusermount3 -u; the mount uses default_permissions, so the kernel checks modes and ownership.

  • Failover: every call goes to the last endpoint that answered and moves on through 421s, 503s and unreachable nodes until --retry-secs runs out. Creates reuse their op_id and writes are absolute, so retrying after an ambiguous failure cannot apply twice; a remove that finds the entry already gone after such a failure counts as done.
  • Write-back: sequential writes to an open file are buffered per inode and sent when the buffer fills, on fsync/flush/close, and before a read, truncate or unlink of that file; getattr already reports the buffered and in-flight size. Full runs go out in the background, --writeback-parallel at a time, so a writer keeps filling the next run while earlier ones are on the wire; runs of one file that overlap are sent in order. A full run is cut at a multiple of --writeback-bytes, so a stream that started mid-extent is sent in aligned runs from then on. A failed send fails the next write to that file and is reported once by fsync/flush/close, as with NFS.
  • Read-ahead: a read that continues where the previous one ended fetches --readahead-bytes and serves following reads from it; random reads fetch only what was asked.
  • Unlink while open: removing a file another handle in the same mount still has open renames it to a hidden .atlas_hidden_<ino>_<n> entry (not listed by readdir) and removes it on the last close, or at unmount.
  • Directory listings: each open directory reads 1024 entries per request (?after=&limit=) and keeps its place between the kernel's readdir calls, so listing a directory of any size costs one request per page. Seeking back restarts the listing.
  • Direct reads (--direct-reads): the leader only answers GET /v1/fs/<fs>/inodes/<ino>/layout?offset=&len= (each extent's offset, length, SHA-256 and replicas with their data-node addresses, in the order a read should try them); the client then fetches the extents from the data nodes itself, in parallel, over the data-node protocol, and verifies every checksum. An unreachable node, a checksum mismatch or a layout made stale by a concurrent write falls back to reading through the leader. The client needs network access to the data nodes; when they require mTLS, --identity-file must hold a certificate signed by the cluster CA in --ca-file, and that identity is also accepted for writes by the data nodes, so give it only to trusted clients. Direct reads take read traffic off the leader; on a single host they cost an extra round trip (see the table below). An erasure-coded extent's layout lists its shards in shard order with each shard's checksum; the client reads and verifies the k data shards and joins them, and leaves decoding around a missing shard to the leader. Each replica in the layout carries its data node's host; --prefer-host <host> tries full copies on that host first (use the host the mount runs on, after pinning the dataset there).

Dataset locality​

A training job reads its dataset fastest from data nodes on its own host. Two calls let a scheduler find and make that placement:

  • GET /v1/fs/{fs}/locality?path=/datasets/imagenet walks the file or directory tree (hard links visited once, symlinks not followed) and returns files, dirs, extents (distinct: an extent shared by hard links, clones or snapshots counts once), bytes, tiered_bytes and erasure_coded_bytes, then nodes (every data node with its host, rack, zone, up, bytes of full copies and shard_bytes of erasure-coded shards) and hosts (every host with the bytes it holds a full copy of and local_fraction, that over all replicated bytes), most bytes first. A scheduler places the job on the host with the highest local_fraction. truncated: true means the walk stopped at max_inodes and the figures cover only part of the tree. The walk holds the group's catalog read lock while it runs, delaying commits to that group, so keep max_inodes proportionate.
  • POST /v1/fs/{fs}/locality/pin {"path": "/datasets/imagenet", "hosts": ["gpu-node-3"]} gives every replicated extent of the tree a full copy on one of hosts: an extent without one gets a copy written to an eligible node on those hosts (most free space first), and one of its replicas is released, preferring one on the new node's rack, so the replica count and host spread stay the same. It examines max_extents (default 256) extents per call, in id order after after; call it again with the returned next until next is null. Calls are idempotent: extents already local count as local. Tiered and erasure-coded extents are skipped and counted (a shard on a host is not a local copy). deferred counts extents left for another call: no readable copy, no eligible node on the hosts, or a concurrent change.

Pinning moves existing data only. New writes are placed as usual, and repair may move a pinned copy off a host that fails, so pin again after the dataset changes or a host is replaced. Mount on the pinned host with --direct-reads --prefer-host <host> to read the local copies; the CSI driver doesn't pass --prefer-host yet, since it needs the data nodes' host to match the Kubernetes node names, which the chart doesn't configure.

POSIX ACLs​

Mount with --acl (CSI: the acl mount option) and setfacl/getfacl work as on a local filesystem: the kernel enforces the ACLs (FUSE_POSIX_ACL), and the cluster keeps them consistent, the same for every client:

  • system.posix_acl_access and system.posix_acl_default are validated (the Linux xattr encoding: one owner, group and other entry, a mask with any named entry, sorted, no duplicates) and refused with EINVAL otherwise. A default ACL is for directories only.
  • Setting an access ACL sets the mode's permission bits from it (owner, mask or else group, and other); one that says no more than the mode is not stored. chmod rewrites the access ACL's owner, mask (or group) and other entries.
  • A node created in a directory with a default ACL gets it as its access ACL, ANDed with the requested mode, and the umask is not applied; a new directory also inherits the default ACL. Symlinks have no ACLs. Without a default ACL the umask applies as usual.
  • Without --acl the kernel neither enforces nor exposes ACLs (getfacl shows the mode only), but ACLs set by --acl mounts are still stored and inherited by the cluster.
  • Each mount's kernel caches an inode's ACL. A change made through another mount may be missed until that kernel drops the inode from its cache, so treat ACL changes like other metadata across mounts (--ttl-ms, close-to-open).
  • Not done: clearing setgid when a non-member of the group sets an ACL (the kernel's FUSE_SETXATTR_ACL_KILL_SGID). NFSv4 ACLs are not supported.
  • Upgrades: ACL xattrs and the create mode sent for inheritance are new to the metadata commands, so upgrade every metadata node before mounting with --acl.

Quotas​

PUT /v1/fs/{fs}/quota {"max_bytes": 107374182400, "max_inodes": 1000000} limits a filesystem; either limit may be left out, and {} removes the quota.

  • max_bytes caps used_bytes: the bytes of the file extents the filesystem references, so holes and sparse truncates cost nothing and an extent shared with a snapshot or clone counts in each filesystem that references it. A write is checked when its extents are committed, against the net change: rewriting a range replaces its extents, so overwrites pass even at the limit, and only growth is refused.
  • max_inodes caps the inode count, the root directory included. Hard links don't add inodes.
  • A refused change fails with code quota (HTTP 409); the FUSE client returns EDQUOT. With write-back on (the default), a buffered write can be refused after the write call returned, so the error comes from a later write, fsync or close, as with NFS. The data a refused write already sent to the data nodes is released straight away.
  • Lowering a limit below current usage is allowed: nothing is removed, and growth stays refused until deletes bring usage back under it. Removing, truncating and rewriting always work.
  • Snapshots keep the quota they were taken with and clones inherit it. Extents that only a snapshot still holds don't count against the live filesystem.
  • FUSE statfs (df) reports the quota as the filesystem's size and the remaining quota as free space and free inodes; without a quota it reports usage plus a large headroom, since capacity is the cluster's.
  • Upgrades: the quota is a new metadata command, so upgrade every metadata node before setting one; an older node cannot apply it.

Data path​

  • A read fetches all the extents it covers in parallel (up to 8 at once), each from a replica chosen from the extent id so the load spreads over every replica; any replica that fails or doesn't verify falls back to the next.
  • A write that spans several extents writes their replicas in parallel (up to 4 extents at once, each to 3 nodes) and installs them all with one metadata commit (install_extents / install_file_extents), so a 4 MiB write costs one Raft round trip, not four. All placements of one write draw from one copy of the free list, so concurrent placements never reuse the same free range.
  • Each data-node client keeps up to 8 idle connections, so concurrent requests to one node don't queue behind a single socket; payloads over 16 KiB are written straight from the caller's buffer.
  • The node HTTP server sets TCP_NODELAY and sends small responses in one write. Before it did, every small reply waited for the client's delayed ACK (40 ms on Linux), which capped random reads at about 24/s and sequential writes at 18 MiB/s regardless of hardware.
  • Upgrades: the two batched commands are new log entries, so upgrade every metadata node before clients write through an upgraded leader; an older node cannot apply them.

Direct I/O​

A file opened with O_DIRECT bypasses the kernel's page cache (the FUSE client answers the open with FOPEN_DIRECT_IO) and the mount's own caches:

  • A write goes to the cluster before it returns: anything the mount still buffers for the file is sent first, then the write itself, and the call returns once its extents are committed. It is visible to every other mount straight away, without a close or fsync.
  • A read fetches from the data nodes, skipping read-ahead, so it returns what other mounts have committed, even if this mount cached older bytes for the file.
  • No alignment is required: any offset and length work, as on other FUSE and network filesystems.
  • Several direct writes to one file run in parallel (FOPEN_PARALLEL_DIRECT_WRITES); concurrent writers to the same range still see last-writer-wins.
  • O_DIRECT has to be given to open: turning it on later with fcntl(F_SETFL) doesn't reach FUSE, and the handle keeps its open-time behaviour. Not tested: mmap of an O_DIRECT handle (the kernel restricts shared mappings of direct-I/O FUSE files); map the file through a handle opened without O_DIRECT.
  • O_SYNC and O_DSYNC without O_DIRECT keep write-back: the kernel follows each write with an fsync, which sends the buffered data before the call returns.
  • Cost: every direct write is one round trip to the cluster and waits for its commit, so small direct writes are latency-bound. On the lab k3s host (shared, spinning disks, one replica) a direct write took about 70–160 ms whatever its size up to 1 MiB; batch writes, or leave O_DIRECT off, where durability per call isn't needed. Not yet measured on NVMe.

Consistency​

  • Within one mount, operations are linearizable: every call goes through the leader and returns only after its command is committed and applied.
  • Across mounts it is close-to-open: data written on one client is visible to another after the writer closes or fsyncs the file and the reader opens it after that; attributes and names may be cached for up to --ttl-ms (set 0 for no caching). Unlocked concurrent writers to the same file range see last-writer-wins at the extent level.
  • Cache leases (--cache-leases): attributes and names are cached for as long as the cluster's lease on them lasts (5 s, re-taken on the next miss) instead of for --ttl-ms, and never go stale: before any change to an inode commits, the leader recalls every other mount's lease on it and waits until that mount has dropped its cache (its recall thread answers within a round trip) or the lease ran out. A mount's own changes do not wait on its own leases. The kernel gets a zero TTL, so every stat and lookup reaches the mount's cache. A new metadata leader knows none of its predecessor's leases: while a caching session opened under an earlier leader is open, it holds changes back for one lease period (5 s) after taking over. A mount that stops answering recalls delays other mounts' changes to what it cached by up to 5 s. File data is still close-to-open.
  • Unlink-while-open only protects handles in the same mount; a file removed by another client disappears for everyone.
  • Extended attributes in the user., trusted. and security. namespaces are stored on the inode (replicated, copied by snapshots and clones): at most 64 KiB per value and 256 KiB per inode. Of system.*, only the two POSIX ACLs are stored (see "POSIX ACLs"); the rest is refused with EOPNOTSUPP.
  • Locks: fcntl byte-range locks and flock locks are held in the cluster, so every mount of the filesystem sees them. A mount opens a session on its first lock and renews it every third of --session-ttl-ms; the session holds its locks. Closing a file drops its process's locks on it, unmounting closes the session, and a mount that dies (or loses the cluster for a whole TTL) has its session expired by the metadata leader and its locks released. A mount whose session expired under it logs the loss and starts a new session on its next lock. flock reaches the cluster as a whole-file lock, as on Linux NFS, so it conflicts with fcntl locks on the same file. F_SETLKW polls the leader (10–250 ms backoff) while it waits; at most --fuse-threads − 1 waits run at once, more fail with ENOLCK, so waiters never take the kernel worker an unlock needs. A snapshot mount grants every lock locally (nothing writes there). Locks need every metadata node upgraded first: an older node cannot apply them.
  • O_DIRECT reads and writes skip the caches; see "Direct I/O".

Atlas gateway​

With the native backend enabled (docs/NATIVE_NODE.md, "Atlas gateway"):

atlasctl create-volume team-share --kind filesystem --backend bkd_native --tenant acme
atlasctl snapshot-volume vol_native_fs_<id> --name before-upgrade
atlasctl clone-snapshot snap_native_fs_<id> --name team-share-copy
atlasctl restore-snapshot snap_native_fs_<id>

Filesystems appear as volumes vol_native_fs_<id> (kind filesystem, backend id fs:<id>) and their snapshots as snap_native_fs_<id>. A filesystem has no size limit: its size_bytes is the sum of its file sizes (0 when empty), expanding it is refused, and the requested size at create time is only used for tenant quota admission. Block data routes (/volumes/{id}/data) refuse filesystems; mount them with atlas-native-mount instead.

NFS​

The FUSE client supports kernel export (FUSE_EXPORT_SUPPORT; the node API resolves . and .. in GET /v1/fs/{fs}/inodes/{dir}/lookup), so a mount can be re-exported over NFS. The chart's NFS gateway does this with NFS-Ganesha; see NATIVE_NFS.md.

S3​

The chart's S3 gateway serves filesystems (and snapshots) as buckets whose objects are these same files: a/b/c is the file c in directory a/b. See NATIVE_S3.md.

SMB​

The chart's SMB gateway runs Samba over --allow-other FUSE mounts, one per share. SMB users are Unix accounts with configured uids and gids, so the mount's permission checks apply to them. See NATIVE_SMB.md.

Replication​

atlas-native-replicate keeps a read-only replica of a filesystem on another cluster from snapshot increments that carry only changed grid cells, with promote and demote for failover and failback. See NATIVE_REPLICATION.md.

Limits and performance​

Each filesystem lives in one Raft group's catalog (metadata.groups spreads filesystems across groups; docs/NATIVE_NODE.md, "Metadata groups"). Commands apply in place: every command checks its inputs before it changes anything, so a rejected one leaves the catalog as it was (debug builds assert this on every apply), and the leader validates proposals against one running copy of the catalog plus its uncommitted entries. A create therefore costs the same at 100k inodes as at 10k. Log compaction (every 1024 entries) checkpoints only the records changed since the last one to catalog.redb (docs/NATIVE_METADATA.md, "Catalog store"). Inodes and directory entries are paged from it, so memory holds what changed since the last checkpoint plus a bounded inode cache, not the namespace. The leader's applied and speculative catalogs share everything paged.

Engine-level create rates without FUSE or HTTP (crates/atlas-native/tests/metadata_bench.rs, one host, tmpfs) are ~9k/s from one proposer and ~12k/s from eight on a 3-voter group at 20k files. The table below predates pipelined replication and the catalog store.

Measured 2026-10-04, file creates through the node API with 16 concurrent clients on a 3-node cluster on one laptop (Apple SSD, release build, other load on the machine), all filesystems in one cluster counted:

InodesCreates/sp50 msp99 msCatalog MiBLeader RSS MiB
10k1261232962.2168
50k1566933811.9356
80k2635516619.0367
100k2715417423.6628

No requests were retried or failed; the spread between rows is load from other processes on the laptop, not the inode count. Before commands applied in place, each create copied the catalog and throughput fell from 200/s at 10k inodes to 36/s at 60k. On the shared lab host, where fsync takes tens of milliseconds and other tenants keep I/O pressure around 15%, creates are bound by disk latency but stay flat with the inode count (same run shape, no retries):

InodesCreates/sp50 msp99 msCatalog MiBLeader RSS MiB
5k4036211031.241
15k473218113.499
25k4234612145.8112

These runs predate the paged catalog, when leader memory grew about 6 KiB per inode. Memory now follows the inode cache rather than the inode count: store::bench::memory_of_a_paged_catalog (4096-inode cache, second lab host, 2026-10-05) peaks at ~76 MB for 400k files in one directory and ~78 MB spread over 1000, flat from 300k files on as redb's page cache fills (112–114 MB before directory entries were paged).

Data path, FUSE mount, measured 2026-10-04: 3 nodes on one 12-core host (other tenants' load average around 25), node data on tmpfs so the shared HDDs don't mask software overhead, fio with one job and psync on a 256 MiB file, page cache dropped before each read test, release builds, two rounds each (ranges shown):

BeforeAfterAfter, --direct-reads
1 MiB sequential write18 MiB/s379 MiB/s348–350 MiB/s
1 MiB sequential read328–374 MiB/s369–405 MiB/s483–541 MiB/s
4 KiB random read23 IOPS588–606 IOPS510–519 IOPS

Most of the write and random-read gain is the TCP_NODELAY fix (see Data path); the rest is group commit and parallel extent I/O. A 4 KiB random read still fetches and checksums its whole extent (1 MiB by default). On the lab hosts' shared disks the same runs are bound by fsync latency and other tenants' I/O (pressure 50–80%), so they are not a fair measure of either build. crates/atlas-native/tests/datapath_bench.rs (--ignored) measures the engine alone over localhost data nodes.

Checkpoint fast path (large sequential writes, e.g. a training checkpoint):

  • A write covering whole extents of the file's grid (--writeback-bytes a multiple of the filesystem's extent_bytes, which the defaults are) reads nothing back to merge, so the leader places its data on the data nodes without the engine's write lock; only the commit is serialized. Partial-extent writes still read, merge and rewrite under the lock.
  • Every placement draws free space from one shared view (the applied free list minus the ranges of placements not yet committed), so concurrent writers never share space; a commit whose outcome is unknown (a timeout, a lost leadership) keeps its ranges out of use for 5 minutes.
  • File-backed data-node devices reserve append space under a lock, then write and sync outside it, so concurrent appends share the device instead of queueing behind each other's fsync.

Measured 2026-10-05 (release builds; the engine benchmark is checkpoint_throughput in tests/datapath_bench.rs, 1 MiB extents, 3 replicas, 8 MiB writes; the client one is checkpoint_bench in atlas-native-fuse/tests/ops.rs, one file in 128 KiB writes):

BeforeAfter
Engine, 8 writers, laptop SSD (macOS)76 MiB/s150 MiB/s
One file through the client, laptop SSD11 MiB/s17 MiB/s
Engine, 8 writers, lab host, data on tmpfs820 MiB/s746 MiB/s
One file through the client, lab host, tmpfs226 MiB/s233 MiB/s
Engine, 8 writers, lab host's shared HDD6 MiB/s4 MiB/s

The gain shows where per-write latency (fsync, network) dominates and the device takes concurrent writes. On the lab host's tmpfs the runs are CPU-bound on a shared 12-core host and on its HDD (other tenants at 20–40% I/O pressure) the differences are within run-to-run noise; neither is a measure of the NVMe target, which still needs the benchmark lab.

Known limits:

  • One filesystem never spans groups: its inodes all live in one group, behind one leader.
  • Filesystem snapshot trees are held in memory in full, and each is one record in the store.
  • A write that covers part of an extent reads, merges and rewrites the whole extent: up to the filesystem's extent_bytes (1 MiB by default). With the cluster on its default 4 MiB grid, random 4 KiB writes measured 24/s at a 4 MiB file grid, 28/s at 1 MiB, 35/s at 256 KiB and 42/s at 64 KiB (p50 40 → 22 ms); the remaining ~20 ms is the replicated commit. A smaller grid means more extents in the in-memory catalog, so use 64–256 KiB only for random-write-heavy filesystems.
  • The client reuses connections (one per concurrent request) but does not pipeline or batch requests, so each metadata operation is one round trip to the leader.
  • A read verifies whole extents, so a small random read transfers and checksums a full extent.
  • Writes always go through the leader; only reads can go direct.
  • Unlink-while-open works only within one mount (see Consistency).

Verification​

  • crates/atlas-native/src/namespace.rs unit tests (every command, idempotent replay, rename edge cases, truncate refcounts, snapshot and clone isolation) and crates/atlas-native/tests/fs.rs (engine: holes, EOF, truncate, GC, reopen, clones).
  • crates/atlas-native/tests/node.rs: the file API on a 3-node in-process cluster.
  • crates/atlas-native-fuse/tests/ops.rs: the FUSE operations layer against a live in-process cluster, including a leader failure mid-workload, unlink-while-open, read-only snapshot mounts, clone isolation, concurrent creates, direct reads (across extents, after a rewrite) and their fallback to the leader; crates/atlas-native-fuse/src/dirs.rs unit tests page a 2500-entry listing through small kernel buffers with one request per page, and rewind on a seek back.
  • crates/atlas-native/tests/allocator.rs: one write's concurrent extent placements reuse distinct free ranges (verified by reading back and scrubbing every replica).
  • crates/atlas-driver-native/tests/nodes.rs and crates/atlas-gateway/tests/native_backend.rs: filesystem create, snapshot, clone, restore and delete through the driver and the gateway.
  • Lab (2026-10-04), a 3-node cluster on one host with a FUSE mount: pjdfstest unlink, mkdir, rmdir, rename, link, symlink, mknod, mkfifo, truncate, chmod, chown and open all pass (8565 tests); cp/mv/rm round trips; git clone + git fsck --full --strict; a snapshot mount stayed identical (sha256) while the source was overwritten, truncated and extended, refused writes with EROFS, and a clone of it took writes that neither the source nor the snapshot saw. atlasctl created, snapshotted, cloned, restored and deleted a filesystem through a gateway in real mode against a live cluster.