R2 Design — Distributed Ownership (fencing + handle registry)
Date: 2026-08-18 · Status: implemented in tree, not deployed · ROADMAP-2026-08-10 §R2 One line: a write lease is a JSON grant (
client_id+session_id+fencing_epoch), nothostname-PID; every file-data commit CASes that grant so a partitioned old writer cannot publish after a new writer holds the inode.
0. Why this exists
The G6 exclusive-writer lease is a TTL lock, not fencing.
- Holder is
hostname-PID(swiftfuse-climount). Daemon restart → new PID → 30sEBUSYeven though it is the same machine. acquire_write_leasePUTs that string under{prefix}/leases/{inode}attached to an etcd lease. Re-entrancy is byte-equality on the string.commit_inodeCASesinode.version+ etcdmod_revisiononly. A partitioned old writer that still holds a staleHandlecan commit after a new writer has the lease.- There is no distributed open-handle table. Cross-node open-unlinked GC is local
NodeTableonly.
R2 does not add INVAL_ENTRY / INVAL_INODE / NOTIFY_STORE, does not flip write_back, and does not use FOPEN_DIRECT_IO. JSON-bytes CAS stays on mod_revision.
1. Identities
| Token | Meaning | Lifetime |
|---|---|---|
client_id | Stable mount identity: {hostname}-{uuid} | Survives daemon restart. UUID file: /var/lib/swiftfuse/client_id, else {meta_db.parent}/client_id. |
session_id | This process / this mount | New UUID (or hostname-PID) at each start. |
fencing_epoch | Monotonic token for this inode’s write generation | Increments on each successful acquire that is not same-client + same-session re-entry. Fresh key starts at 1. |
etcd_lease_id | The etcd lease attached to the lease key | Dies with the writer process (TTL 30s, no keepalive — same as today). |
session_id changing with the same client_id is the restart case: re-acquire is allowed and must bump fencing_epoch so in-flight commits from the dead session are fenced.
2. Lease record (etcd key {prefix}/leases/{inode})
JSON, schema_version = 1:
{
"schema_version": 1,
"client_id": "swift1-a1b2c3d4-...",
"session_id": "sess-7f8e...",
"fencing_epoch": 3,
"etcd_lease_id": 75849201023
}
Backward compatible: a pre-R2 value that is a bare UTF-8 string is parsed as
client_id = that string, session_id = "", fencing_epoch = 0, etcd_lease_id = 0
The next acquire from that holder upgrades the key to JSON.
Acquire (acquire_write_lease_v2)
- Grant a new etcd lease. Try
Compare::version(lease_key) == 0PUT of a JSON record withfencing_epoch = 1. - If the key exists:
- other
client_id→None(FUSE maps toEBUSY). Surplus grant revoked. - same
client_id+ samesession_id→ return the existing grant (no epoch bump). Surplus grant revoked. - same
client_id+ newsession_id→ PUT replacement JSON withfencing_epoch + 1attached to the new etcd lease (CAS on the bytes we just read). This is restart.
- other
acquire_write_lease(inode, holder)stays. It calls v2 withclient_id = session_id = holderand returnsbool. Existing client-A / client-B tests stay valid.
Sqlite: acquire always succeeds (single-node). An in-memory map still tracks the latest grant so commit_inode_fenced can reject a stale epoch. Other-client EBUSY is etcd-only.
Grant stored on the FUSE Handle
LeaseGrant { client_id, session_id, fencing_epoch }
Set at open / create after a successful acquire. flush_handle and the O_TRUNC commit pass this grant into commit_inode_fenced.
3. Fencing on file-data commit
commit_inode is unchanged: version + mod_revision only. Directory mtime touches, chmod, and the grow-only SETATTR helper (no write lease) stay on this path.
commit_inode_fenced(meta, grant) is the write path. It must succeed only if all of:
- stored
inode.version == meta.version - lease key exists
lease.client_id == grant.client_idlease.fencing_epoch == grant.fencing_epoch
Otherwise CommitResult::Conflict (never Committed).
On etcd this is one txn:
WHEN inode.mod_revision == expected
AND lease.value == bytes we just read (after the epoch/client check)
THEN PUT inode (version+1)
Lease missing, other holder, or stale epoch fails the compare → Conflict. A partitioned old writer with epoch N cannot commit after epoch N+1 is on the key.
Wired at:
| Call site | Function | Grant source |
|---|---|---|
| Flush / fsync | ChunkFs::flush_handle → commit_file_data | Handle.lease_grant |
open(O_TRUNC) chunk wipe | inode_fs.rs open | grant from this open |
| SETATTR shrink (size/chunks) | inode_fs.rs setattr | any open writable handle of that inode; grow-only / no handle stays unfenced |
Pure check (no etcd) lives in swift_meta::lease::lease_allows_commit.
4. Handle registry (minimum viable)
Keys: {prefix}/handles/{inode}/{client_id}/{fh}
- Value:
"1"(presence only). - Attached to a daemon session etcd lease (TTL 60s, keepalive ~20s). Daemon death expires every handle this mount registered.
register_handleon everyopen/create.unregister_handleonrelease.handle_count(inode)= prefix GET count.
Sqlite: in-memory HashMap<(inode, client_id, fh), ()>.
GC hook — wired
remove_file_entry must consult the registry before unlink_inode. A mount that never opened the file used to delete the record under a peer fd. If handle_count>0 or lease_held, only the name is dropped.
ChunkFs::maybe_gc does not AND handle_count onto the old local gc_ready. That would leak: the unlinking mount never sees another FORGET, and the holding mount never marked deleted.
Full condition:
this mount: open==0 ∧ lookup_count==0
engine: handle_count==0 ∧ !lease_held(inode)
name: sqlite tombstone `deleted:/{id}`, or lookup(parent, name)
missing / points at another inode (etcd)
Engine errors skip GC. After the last peer close+forget, that peer’s maybe_gc reclaims (nlink is not stored; name-gone is the distributed deleted bit). Tests: existing open-unlinked suite + remote_handle_blocks_deferred_gc + writer_lease_blocks_deferred_gc.
5. What this change does not do
- Live partition gate (tc netem + old writer resume). That is the ROADMAP R2 gate, not this unit of work.
- Live kernel FORGET timing. Remount runs
sweep_unlinkedfor nameless leftovers. - Deploy, remount,
kernel_ttlflip,pkill,cargo clean. - Notify / direct-IO / write-through workarounds.
6. Tests (must exist)
| Test | Engine | Asserts |
|---|---|---|
stale_epoch_cannot_commit | sqlite (always) + etcd (ETCD_ENDPOINTS) | A epoch N, then N+1 (new session or other writer); A commit with N → Conflict 100% |
same_client_id_reacquire_after_new_session | both | same client_id, new session_id → acquire succeeds, epoch increments |
other_client_cannot_acquire | etcd | other client_id → None / false |
existing etcd_leases | etcd | client-A / client-B string API still green |
lease_allows_commit_* | unit, no backend | missing / mismatch / stale epoch |
| handle registry register/count/unregister | sqlite always; etcd if endpoints | count 1 after register, 0 after unregister |
7. Files
crates/swift-meta/src/lease.rs— record, grant, parse,lease_allows_commitcrates/swift-meta/src/{engine,etcd,sqlite,lib,model}.rscrates/swift-fuse/src/{chunk_fs,inode_fs}.rscrates/swiftfuse-cli/src/main.rs— stableclient_idcrates/swift-meta/tests/{etcd_test,engine_test}.rs