← back to all projects
CPOSIX threadsTCP socketsDistributed systems

CoWrite: Distributed File System

A terminal-based collaborative document store, built from scratch in C, with Google-Docs semantics over raw TCP: many users concurrently edit shared documents without conflicts via sentence-level locking, per-user access control, primary/backup replication with automatic failover, and checkpoint/undo rollback. Three programs (a name manager, storage servers, and a client REPL) speak a small length-prefixed JSON protocol.

The problem

Collaborative editing looks simple from the outside: several people type into the same document and it just works. Underneath, it is distributed systems in miniature: concurrent writers who must not clobber each other, storage machines that can die with a write in flight, and users who should only ever see the files they have been granted. Most software gets these guarantees for free from a database or a framework, which means most engineers never have to actually build them.

CoWrite is my attempt to solve those problems directly, in C, over raw TCP sockets. No framework, no database, no off-the-shelf coordination service. Every lock, ticket, replication task, and failover decision in the system is code I wrote and can defend line by line. The goal was not to compete with Google Docs; it was to earn the semantics that make Google Docs feel effortless.

Interactive: locks, tickets, and a crash

This is the real topology: two docs_client users on the left, the name manager brokering every operation in the middle, and a primary/backup storage pair on the right holding the document notes. Solid wires carry control RPCs; dashed wires are the data plane, where file content actually flows. Run a scenario, or crash the primary yourself and watch the name manager recover.

cowrite · 1 nm · 2 ss · 2 clients
replication alice docs_client bob docs_client Name Manager metadata · ACLs tickets (30s TTL) click to crash the primary SS1 storage · :9100 PRIMARY SS2 storage · :9200 BACKUP notes - primary copy s0 Distributed systems fail. s1 Plan for that. s2 Then test the plan. notes - backup copy sync s0 Distributed systems fail. s1 Plan for that. s2 Then test the plan. solid = control RPCs (NM) · dashed = data plane (client ↔ SS, content never crosses the NM)

Click the primary storage server at any moment to crash it - the name manager will fail over to the backup.

How it works

CoWrite is three programs speaking a small length-prefixed JSON protocol over TCP:

  • Name Manager (bin/nm) is the coordinator. It owns all file metadata, the ACLs, and the file → primary/backup map, and it brokers every operation through short-lived capability tickets (30-second TTL). It is multithreaded: a thread per connection, plus background workers for replication and ticket expiry.
  • Storage Server (bin/ss) holds file content on disk, enforces sentence-level write locks, performs commit-time merges, and keeps per-file undo snapshots and named checkpoints. Multiple instances register with the NM, which assigns primary/backup roles across them.
  • docs_client is the interactive REPL that authenticates with a username and drives every command.

The split that matters is control plane versus data plane. Clients never move file content through the name manager. A client asks the NM for an operation; the NM checks the ACL and answers with a storage-server endpoint plus a ticket; the client then connects to the storage server directly and presents the ticket. The NM stays a small metadata service instead of becoming an I/O bottleneck, and a storage server never has to take a client's word for anything: it honors only valid, unexpired tickets.

Concurrency & conflicts

Write locks are sentence-granular: a WRITE is scoped to a sentence index, so writers on different sentences of the same file proceed concurrently and only same-sentence writers serialize (the loser gets ERR_LOCKED and retries). A write session records a baseline sentence; on commit, the storage server re-reads the current file, relocates the target sentence by its content and surrounding context, and merges the edit in. If the baseline can no longer be found because someone changed it underneath the writer, the commit returns ERR_CONFLICT. To be precise about what that is: conflict detection is heuristic and string-based, not version-numbered. While a write is in progress, UNDO, REVERT, and DELETE on that file are refused with ERR_LOCKED, so rollback can never race a half-finished edit.

Replication & failover

On CREATE, the NM assigns a primary storage server round-robin and, when a second server is available, a backup. After every mutating operation the primary sends SS_FILE_UPDATE to the NM, which enqueues an asynchronous replication task: the backup pulls the authoritative content from the primary using a read ticket the NM hands it via NM_SYNC. Failure detection is connection-drop based. When a storage server's control connection to the NM dies, the NM walks every file whose primary was that server, promotes the backup to primary in place, and asynchronously re-replicates to restore a new backup. Because clients re-resolve the endpoint on every operation, the failover is transparent to them: the next ticket simply points at the promoted server.

Design decisions & trade-offs

Capability tickets instead of proxying. The obvious design routes all I/O through the coordinator; it is simpler and it is also how you build a bottleneck. Tickets keep the NM out of the data path entirely while still centralizing authorization. A storage server never trusts a client's claim of access, only a valid ticket the NM minted. The 30-second TTL bounds how long a stale grant can live. The cost is a slightly larger protocol surface and a two-hop dance for every operation.

Sentence granularity. Locking is a spectrum. Word-level locks would thrash, with every keystroke-sized edit acquiring and releasing state, and file-level locks would kill the entire point of collaboration by serializing all writers. Sentences are the unit people actually edit, so that is where the lock lives: coarse enough to be cheap, fine enough that two writers rarely collide.

Asynchronous replication. The backup is brought up to date after the commit is acknowledged, not before. That choice buys availability and low write latency, since a writer never waits on the backup, and it is an acceptable trade for a document editor. The cost is a real one, and the failover scenario in the demo shows where it bites: a freshly promoted backup may be missing the primary's very latest writes. I chose to pay that cost knowingly rather than pretend the system is synchronously consistent.

Honest scope

  • Failure detection is connection-drop / RPC-failure based. There is no active heartbeat, so a hung-but-still-connected storage server is not detected.
  • Replication is asynchronous and each file keeps a single backup, so a freshly promoted backup may lag the primary's most recent writes.
  • Conflict detection is heuristic and string-based (content + context matching), not version-vector or CRDT based.
  • The README's tested-limits table (max clients, failover time, replication lag) is deliberately left unfilled, because I don't quote numbers I haven't measured.
In the README's own words: "This is a learning implementation of failover, not a production HA system." It exists so I understand exactly what promotion, re-registration, and re-replication cost, not so anyone stores their only copy of anything on it.

Try it yourself

Requires GCC or Clang, make, a POSIX environment, and OpenSSL headers (the storage server links -lcrypto). Start the pieces in order so each can register with the previous one:

make                    # produces bin/nm, bin/ss, bin/docs_client

# 1) name manager
bin/nm 9000

# 2) storage servers - start a second one to exercise replication + failover
bin/ss ss1 127.0.0.1 9100 127.0.0.1 9000 ./ss_data
bin/ss ss2 127.0.0.1 9200 127.0.0.1 9000 ./ss_data2

# 3) client
bin/docs_client 127.0.0.1 9000

A short session: create a file, edit a sentence, snapshot it, and roll back.

$ bin/docs_client 127.0.0.1 9000
Username: alice
Connected as alice. Type 'help' for commands.

docs> CREATE notes
Created notes

docs> WRITE notes 0
write> 0 hello world
write> ETIRW
Write committed.

docs> READ notes
--- notes ---
hello world
-------------

docs> CHECKPOINT notes v1
Checkpoint 'v1' saved for notes.

docs> REVERT notes v1
Reverted notes to checkpoint 'v1'.