Field notes · Distributed systems

The File That Wouldn't Die

or: a grad-school P2P file system, from one laptop to the internet

Prefer to watch? Every animation in this post, narrated in about five minutes, with captions built in. The narration is a local AI clone of my voice.

In 2020 we built a peer-to-peer file system out of Python sockets and threads. Here's what it got right, what it got wrong, and every core idea you need to grow it from a laptop to real traffic: framing, locks, hashing, quorums, versions, tombstones, gossip, Merkle trees, erasure codes and consensus.

Where this comes from

A grad-school project of mine at UMBC: about 1,100 lines of Python, raw TCP sockets, a thread per connection. Section 01 is what we actually built. Everything after is how I'd grow it today; those numbers are illustrative unless they say otherwise.

client.py · 2020
> create notes.txt
File notes.txt Uploaded on Server and sent for replication!
> list
notes.txt , test
> download notes.txt
File notes.txt Downloaded!
>

The short version, for whichever hat you're wearing

  • Split files into chunks, copy each chunk three times, and put the copies in different places.
  • Every change gets a version; every delete leaves a tombstone; every chunk carries a checksum.
  • Assume machines vanish: notice fast, route around them, and repair in the background.
012020

What we built

Four laptops on home Wi-Fi, one shared folder. Press play.

Animation · the 2020 system, from boot to a crashed node
0 / 10Press play to boot a node, move some files and crash a laptop.
The 2020 design. A client connects to node A on port 9998. Node A listens for other nodes on port 9999, runs a thread per connection guarded by three locks, keeps a global directory of every file, its version and which node holds it, and stores files on disk with the version in the name; deleted files are renamed with a star. Nodes B, C and D are found through a hard-coded list of IP addresses with a three-second connection timeout. client.pylist · create · download · upload · quit NODE A · 192.168.0.123 client port :9998 peer port :9999 a thread per connection3 locks · a queue between threads global directorynotes.txt · v3 · on A, Ctodo.txt · v1 · on B ServerMemory/ on disk"notes.txt 3" · version in the name"old.txt *" · a deleted file node B192.168.0.111 node C192.168.0.113 node D192.168.0.137 a hard-coded list of peer IPs3-second connect timeout each

    What it measured in 2020

    operation1 KB fileunder 1 MB
    create + replicate0.4 s0.7 s
    download, edit, upload1 s5 s
    upload to every replica0.7 s4 s

    real numbers from our report: 4 servers, 4 clients, home network, rounded to the second

    It worked, and it survived crashed laptops. It also had every classic distributed-systems bug in miniature. Let's fix them one at a time.

    02reliability

    TCP sends bytes, not messages

    We ended every message with the word aosppdfs and read 1,024 bytes at a time. Watch what can go wrong:

    Animation · reading a stream, 8 bytes at a time

    Pick a case.

    framing · pseudocode
    import struct
    
    async def send(writer, payload: bytes):
        writer.write(struct.pack("!I", len(payload)) + payload)   # 4-byte length, then the bytes
        await writer.drain()                                       # backpressure: wait if the socket is full
    
    async def recv(reader) -> bytes:
        (size,) = struct.unpack("!I", await reader.readexactly(4))
        if size > MAX_FRAME:
            raise ValueError("frame too large")                    # never trust a length blindly
        return await reader.readexactly(size)                      # exactly that many, however TCP splits them
    03speed

    One lock for everything

    Every thread shared one global directory behind one lock. Four reads and two writes arrive:

    Interactive · six threads, three ways to share one directory
    A timeline of six threads waiting for and holding a lock.

    Pick a lock.

    Snapshots: readers grab the current immutable map; a writer builds a new one and swaps the pointer. At thousands of connections, swap threads for asyncio streams.

    04scale

    Where does a file live?

    We picked a random peer for the copy, then broadcast the whole directory so everyone could find it. Hashing makes the location computable instead:

    Interactive · 120 files, then a fourth server joins
    Files placed on servers by hashing their names.

    Each dot is a file, placed by the hash of its name and coloured by the server that owns it.

    Real 32-bit hashes of 120 file names, 16 virtual points per server. Consistent hashing moves about 1/n of the files; mod n moves most of them.

    05reliability

    How many copies must say yes?

    Keep N = 3 copies. A write waits for W of them; a read asks R of them. Break some servers and try it:

    Interactive · N = 3, you pick W and R
    W
    R

    Tap a server to break it. Then write, then read.

    put · pseudocode
    async def put(name, data, if_match, W=2):
        chunks = [data[i:i + CHUNK] for i in range(0, len(data), CHUNK)]    # 4 MiB pieces
        ids = [sha256(c) for c in chunks]                                     # the checksum is the chunk's name
        replicas = ring.owners(name, n=3, distinct_zones=True)
    
        acks = await first(W, [r.store(ids, chunks) for r in replicas], timeout=0.5)
        if len(acks) < W:
            raise Unavailable("not enough copies, try again")                 # never claim it was saved
    
        return await metadata.compare_and_set(                                # Raft: one truth per name
            name, expect=if_match, new={"chunks": ids, "version": if_match + 1})
    06reliability

    Two people, one file

    Animation · Ana and Ben both edit notes.txt
    1. Pick a rule.

    On the web this is just ETag + If-Match. For offline edits, vector clocks spot concurrent versions.

    07reliability

    Deleting is a write

    Animation · delete a file while one server sleeps
    1. Pick a delete.
    08failover

    Who's still alive?

    Our peer list was typed by hand. Real clusters gossip: every second, each server tells a couple of random others what it knows.

    Animation · 12 servers, one dies, the news spreads
    Twelve servers in a circle passing news of a failure by gossip.

    Press kill.

    Missed heartbeats make a server suspect; gossip spreads the verdict in about log(n) rounds. See gossip protocols.

    09visibility

    Is my copy still right?

    Disks rot quietly. Hash every chunk, then hash the hashes into a tree, and two replicas can find a difference without shipping the data:

    Animation · replica B has one silently corrupted chunk
    A Merkle tree of eight chunks, compared level by level.

    Each box shows a short hash. Leaves are chunks.

    10scale

    Copies, or math?

    Interactive · lose servers until the file is gone

    Pick a scheme.

    Erasure codes rebuild lost pieces from any k survivors (Reed–Solomon). Hot files: copies. Cold files: math.

    11failover

    Agreeing on names

    Bytes can live anywhere. But "which version of notes.txt is current?" needs one answer. That's consensus:

    Animation · a five-member Raft group for metadata
    1. Pick a scenario.

    Raft picks consistency over availability: a minority can't write. That's the C in CAP, chosen on purpose.

    12speed

    Fast downloads

    Interactive · download a 64 MB file in four chunks
    A timeline of four chunk downloads.

    Pick a strategy.

    illustrative seconds · hedge = ask a second replica if the first is slower than usual (The Tail at Scale)

    13scalefailover

    From one laptop to the internet

    Same ideas at every step. Press play, and it grows from the bottom up.

    Animation · twelve steps from localhost to three zones
    0 / 12Press play to grow the system from one laptop.
    The system at four sizes. On one laptop: three node processes on ports 9001 to 9003 and a client. At home: more laptops join through a seed address. On the internet: a tunnel or a load balancer with TLS and rate limits, stateless HTTP gateways that check tokens, and DNS with a CDN. In the cloud: a Raft group for metadata across three zones, storage nodes in three zones with a copy of each chunk in each, an erasure-coded cold tier and a backup region. users, anywhere DNS · CDN for hot public files load balancerTLS · rate limits or: a tunnelno open ports at home HTTP gateways · stateless · autoscaledtokens · PUT / GET / DELETE /files/… metadata: a Raft group, one member per zonenames · versions · chunk maps zone astorage nodescopy 1 zone bstorage nodescopy 2 zone cstorage nodescopy 3 cold tiererasure-coded · 1.5× not 3× backup regionasync copy the internet above · home below home Wi-Fi: more laptops joineach needs one seed address · gossip does the rest ONE LAPTOP node 1:9001 · ./data1 node 2:9002 · ./data2 node 3:9003 · ./data3 a clientcurl -T notes.txt localhost:9001/files/notes.txt
      the same file system, as HTTP
      PUT    /files/notes.txt   If-Match: "v3"         → 200 ETag: "v4"  |  412 someone beat you
      GET    /files/notes.txt   Range: bytes=0-4194303 → 206 the first chunk
      GET    /files/notes.txt   If-None-Match: "v4"    → 304 your copy is current
      DELETE /files/notes.txt   If-Match: "v4"         → 204 and a tombstone
      GET    /health                                   → 200 for the load balancer
      14scale

      Adding a server

      Animation · a fifth server joins
      1. Pick where.
      15visibility

      Watching it heal

      The number that matters most isn't latency. It's how many chunks are missing a copy right now.

      Interactive · a chaos drill on a 5-server cluster
      All copies healthy.

      simulated: one tick per 10 seconds · repair is throttled so users don't notice

      Sorry, quick interruption

      Leslie Lamport: "A distributed system is one in which the failure of a computer you didn't even know existed can render your own computer unusable." Our 2020 answer was a list of every computer, typed by hand.

      16

      2020 vs today, one page

      1. Messages on a TCP stream
        end marker (2020)length prefix

        Any bytes, no hangs. Cost: a 4-byte header.

      2. Many connections
        thread each + one lock (2020)asyncio + snapshots

        Readers never wait. Cost: async code everywhere.

      3. Where a file lives
        random peer (2020)hash ring, zone-aware

        Computable, and growth moves 1/n. Cost: virtual nodes to balance.

      4. How many copies
        2 (2020)3, W = 2, R = 2

        Survives one loss during repair. Cost: 3× storage.

      5. Two writers
        last writer wins (2020)compare-and-set

        No silent loss. Cost: clients retry.

      6. Deletes
        rename to "name *" (2020)versioned tombstones + GC

        We were right; add expiry. Cost: tombstones take space.

      7. Membership
        typed IP list (2020)seeds + gossip

        Servers come and go. Cost: tuning suspicion timeouts.

      8. Metadata
        broadcast to all (2020)a Raft group

        One truth per name. Cost: a majority must be up.

      9. Bit rot
        trust the disk (2020)checksums + Merkle repair

        Corruption is found, not served. Cost: hashing CPU.

      10. Getting in
        raw sockets on Wi-Fi (2020)HTTPS, tokens, a load balancer

        Safe to expose. Cost: one more hop.

      17

      The well-earned drink

      Every Brew Miles trip ends with one:

      1. Frame your bytes, and version everything, deletes included.
      2. Hash to place, copy to survive, and spread copies across failure zones.
      3. Make writes conditional and pick quorums that overlap.
      4. Gossip to notice, Raft to agree, Merkle trees to repair.
      5. Watch under-replication, and kill a server on purpose before one dies on its own.
      Further reading
      Show the papers and docs this post leans on