02. File Sync — Low-Level Design¶
~22 min read · Part 3 of 4 (Overview → HLD → LLD → Q&A)
The HLD named the two rails. This file opens the four pieces that carry the design's weight — content chunking with block-level dedup, the metadata service and its cursor, the sync and conflict-resolution engine, and the content-addressed blob store — and pins down the schemas, the chunking math on the scenario's numbers, and the concurrency corners where sync actually breaks.
Data models¶
Metadata is partitioned by namespace_id. A namespace is one user's root folder or one shared folder; every file, version, and cursor for that namespace lives on one shard, which is what makes ordering a single-row transaction.
-- One row per namespace: the serialization point for all its commits.
CREATE TABLE namespace (
namespace_id BIGINT PRIMARY KEY,
head_cursor BIGINT NOT NULL DEFAULT 0, -- monotonic; every commit advances it
kind SMALLINT NOT NULL -- 0 = user root, 1 = shared folder
);
-- One row per file/dir path currently present in a namespace.
CREATE TABLE file (
file_id BIGINT PRIMARY KEY,
namespace_id BIGINT NOT NULL,
path TEXT NOT NULL, -- '/videos/demo.mp4'
current_version BIGINT NOT NULL, -- FK -> file_version.version_id
is_dir BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (namespace_id, path) -- one live file per path
);
-- Append-only version history. Never updated in place -> free versioning + undo.
CREATE TABLE file_version (
version_id BIGINT PRIMARY KEY,
file_id BIGINT NOT NULL,
namespace_id BIGINT NOT NULL,
blocklist JSON NOT NULL, -- ordered ["<sha256>", ...]
size_bytes BIGINT NOT NULL,
mtime TIMESTAMP NOT NULL,
author_device BIGINT NOT NULL,
commit_cursor BIGINT NOT NULL, -- the head_cursor value at commit
is_conflict BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_ns_cursor ON file_version (namespace_id, commit_cursor); -- delta pulls
-- Global block index (not sharded by namespace; sharded by hash prefix).
CREATE TABLE block (
block_hash CHAR(64) PRIMARY KEY, -- SHA-256 of the 4 MB block
size_bytes INT NOT NULL, -- <= 4 MB (last block is short)
refcount BIGINT NOT NULL, -- how many versions point here
location TEXT NOT NULL -- blob-store placement handle
);
The choices that matter. First, file_version is append-only and carries the ordered blocklist — a file version is its recipe of block hashes, so "what changed between two versions" is a list diff and versioning is free (old versions are simply older rows). Second, commit_cursor and namespace.head_cursor are the ordering primitive — a commit is accepted only if it names the current head, and accepting it advances the head by one; this single monotonic integer is the total order every device replays. Third, the block index is sharded by hash prefix, not by namespace, because a block is shared across users and namespaces — that is the whole point of dedup — so it cannot live on any one namespace's shard. Fourth, refcount lives with the block, because with dedup a block is deletable only when no version anywhere references it, and that count is the delete path's whole safety story.
Component internals¶
Component 1 — The chunker (content chunking + block-level dedup)¶
Responsibility: turn a file into an ordered list of block hashes, and turn a changed file into the minimal set of new blocks to upload.
The baseline is fixed-size 4 MB blocks, the Dropbox choice. Walk the file, cut a boundary every 4 MB, hash each block with SHA-256, and the file's recipe is that ordered hash list. Diffing a new version against the old is a positional comparison of two hash lists.
BLOCK = 4 * 1024 * 1024 # 4 MB
def chunk_fixed(path) -> list[str]:
hashes = []
with open(path, "rb") as f:
while (buf := f.read(BLOCK)):
hashes.append(sha256(buf).hexdigest())
return hashes
def blocks_to_upload(new_list, old_list, store) -> list[str]:
candidates = set(new_list) - set(old_list) # hashes not in the prior version
return store.probe(list(candidates)) # of those, which the store lacks
Fixed blocks are cheap to compute and give clean positional diffs for in-place edits that don't change length — which is exactly the scenario. They have one known weakness: an insertion that shifts all following bytes changes every subsequent block boundary, so a naive fixed-block scheme would re-upload the whole tail of the file. The alternative is content-defined chunking (CDC): slide a rolling hash (Rabin fingerprint) over the bytes and cut a boundary wherever the low bits of the rolling hash hit a target pattern, so boundaries are anchored to content, not offset. An insertion then only disturbs the one or two chunks around it. The tradeoff is concrete: CDC costs meaningfully more CPU per byte and yields variable-size blocks, in exchange for far better dedup on files that get insertions rather than in-place edits. The pragmatic call is fixed 4 MB blocks as the default (simpler, faster, and enough for the common edit-in-place and cross-file-dedup cases) with CDC reserved for workloads dominated by inserts.
Component 2 — The metadata service (commit + cursor)¶
Responsibility: serialize commits into a per-namespace total order, using optimistic concurrency on the cursor, and detect conflicts.
class MetadataService:
def commit(namespace_id, path, blocklist, parent_cursor, device_id) -> CommitResult
def get_delta(namespace_id, since_cursor) -> (changes, new_cursor)
commit is one transaction against the namespace's shard. It reads head_cursor, compares it to the caller's parent_cursor, and only if they match does it write the new file_version, repoint file.current_version, and advance head_cursor. The compare-and-advance is the optimistic-concurrency guard; because it is a single-row conditional update inside one shard, it needs no distributed lock.
def commit(ns, path, blocklist, parent_cursor, device_id):
with txn(shard_for(ns)):
head = SELECT head_cursor FROM namespace WHERE namespace_id = ns FOR UPDATE
if head != parent_cursor:
return Conflict(current_cursor=head) # someone else committed first
new_cursor = head + 1
vid = insert_file_version(ns, path, blocklist, new_cursor, device_id)
upsert_file(ns, path, current_version=vid)
UPDATE namespace SET head_cursor = new_cursor WHERE namespace_id = ns
return Committed(new_cursor, vid)
get_delta is the read side: given a device's last-synced cursor, return every file_version in the namespace with commit_cursor > since_cursor, ordered, plus the new head. The idx_ns_cursor index makes this a range scan, so a device that was offline for a week pulls its backlog in one ordered pass.
Component 3 — The conflict resolver¶
Responsibility: when a commit's parent is stale, produce a deterministic outcome that loses no data.
When commit returns a conflict, the client does not overwrite and does not blindly retry with the new head. It re-pulls the delta to learn the winning version, then re-commits its own version under a new path as a conflicted copy, so both survive:
def resolve_conflict(ns, path, my_blocklist, device_id, server_head):
winner = get_delta(ns, since=my_parent).latest_for(path) # the version that won
# Preserve my work beside the winner instead of discarding it.
copy_path = conflicted_name(path, device_id) # 'demo (Device-B's conflicted copy).mp4'
commit(ns, copy_path, my_blocklist, parent_cursor=server_head, device_id=device_id,
is_conflict=True)
Determinism comes from two facts. The namespace's single serialization point gives every device the same winner (whoever the shard ordered first), and the conflicted-copy name is a pure function of the losing device_id, so every device independently derives the identical two-file result. There is no vote and no clock comparison to disagree on.
Component 4 — The blob store (content-addressed, refcounted)¶
Responsibility: store immutable 4 MB blocks under their content hash, dedup on write, and reclaim blocks only when safely unreferenced.
A PUT /blocks/{hash} verifies the uploaded bytes actually hash to the claimed key (rejecting a lying or corrupt client) and, if the key is absent, writes it; if present, it is a no-op — the dedup happens here, silently. refcount is incremented when a file_version referencing the block commits and decremented when a version is finally purged past its retention window. Garbage collection is a background mark-and-sweep: a block with refcount = 0 is not deleted immediately but tombstoned with a grace period, because a commit in flight may be about to reference it — deleting on the instant the count hits zero is the classic dedup GC race that loses a block out from under a concurrent upload.
Core algorithm — the scenario, stepped through¶
Walk the 1 GB video and its concurrent one-line edit end to end with the real numbers.
Step 1 — first upload (chunking + dedup on cold content). The client chunks the 1 GB video: 1,024 MB / 4 MB = 256 blocks, hashed to 256 SHA-256 keys. probe reports all 256 missing (cold content), so all 256 upload — 1,024 MB across the wire. The client commits file_version v1 with the 256-hash blocklist against parent_cursor = N, and the namespace head advances to N+1 = call it N₁. Cost recorded: 256 blocks stored, refcount 1 each.
Step 2 — the edit is made on two devices at once. Device A and Device B both hold v1 and cursor N₁. Each makes the same one-line edit. Fixed-block chunking re-hashes the file; because the edit lands inside block 40 (say) and does not change the file's length, blocks 0–39 and 41–255 hash identically to v1, and only block 40 gets a new hash. Each device's probe therefore reports exactly one block missing, and each uploads 4 MB — the 256:1 saving the whole design exists to deliver. Note the two devices produce the same new block-40 hash if their edits are byte-identical (dedup means only one 4 MB block is stored even though two devices uploaded it), or two different hashes if the edits differ; either way the block rail just stores what it is given.
Step 3 — the concurrent commit collides. Both devices now commit a new version of /videos/demo.mp4 with the new blocklist and parent_cursor = N₁. Say Device A's commit reaches the shard first: head == N₁ matches, so A wins — v2 is written, file.current_version points at it, and the head advances to N₂. Device B's commit arrives microseconds later with parent_cursor = N₁, but the head is now N₂, so the guard fails and B gets 409 Conflict.
Step 4 — deterministic resolution, no loss. Device B re-pulls the delta, sees A's v2 as the winner for /videos/demo.mp4, and re-commits its own version at /videos/demo (Device-B's conflicted copy).mp4 against the current head N₂, advancing it to N₃. Now the namespace holds two files: A's edit under the original name and B's edit preserved beside it. Every other device pulls deltas N₁→N₃ in order and reconstructs the identical two-file state — A's block-40 fetched once, B's block-40 fetched once, everything else already local. The collision cost the system one extra 4 MB block and one extra metadata row; it cost the users zero lost work.
Sequence diagram — concurrent edit and deterministic resolution¶
Device A Device B MetadataSvc (ns shard) BlockSvc/Blob
│ edit blk40 │ edit blk40 │ │
├─ probe ────────┼────────────────────┼───────────────────────▶│ (both: 1 missing)
├─ PUT blk40 ────┼────────────────────┼───────────────────────▶│ (4 MB)
│ ├─ PUT blk40 ────────┼───────────────────────▶│ (4 MB / dedup)
├─ commit(parent=N1) ─────────────────▶│ head==N1 ✓ -> head=N2 │
│◀── 200 committed, cursor N2 ─────────┤ │
│ ├─ commit(parent=N1) ─▶│ head==N2 ✗ (409) │
│ │◀── 409 conflict, head=N2 ──┤ │
│ ├─ get_delta(since=N1) ▶│ returns A's v2 │
│ │◀── winner = v2 ───────┤ │
│ ├─ commit(copy_path, parent=N2) ─▶│ head=N3 │
│ │◀── 200 committed, cursor N3 ─────┤ │
│ ... all devices pull delta N1->N3: converge on 2 files ... │
One serialization point orders the two commits; the loser is preserved as a conflicted copy rather than discarded, and every device derives the same result from the same ordered log.
Concurrency and edge cases¶
- Idempotent block upload. Content addressing makes
PUT /blocks/{hash}naturally idempotent — a retry after a timeout re-writes identical bytes under the same key, a no-op. There is never a "duplicate block" to reconcile. - Idempotent commit. A commit that times out on the network may have actually landed. The client retries with the same
parent_cursor; if the first attempt succeeded, the head already advanced, so the retry gets a409and the client reconciles via delta rather than committing twice. Optionally the client attaches a commit UUID the service dedups, collapsing the retry cleanly. - Orphaned blocks. Blocks uploaded before a commit that never happens (client crash between
PUTandcommit) are referenced by nothing, so theirrefcountstays 0 and the GC sweep reclaims them after the grace period — never on the hot path. - GC-versus-commit race. A block's
refcountcan hit 0 just as a new commit is about to reference it (the same content re-appearing). Deleting immediately would corrupt that commit, so a zero-refcount block is tombstoned with a grace window and only physically deleted if it stays unreferenced through it; a commit that references it during the window simply resurrects it. - Hash collision. SHA-256 collision is astronomically unlikely, but a paranoid store can verify bytes on a hash match before treating two blocks as identical; the design assumes "same hash ⇒ same content," and the verify-on-match option is the escape hatch if that assumption is ever doubted.
- Move / rename is metadata-only. Renaming
/a.mp4to/b.mp4changes the file'spathand writes a new version, but the blocklist is unchanged, so zero blocks transfer — a 1 GB file moves for the cost of one metadata row, which whole-file systems get wrong by re-uploading. - Offline reconciliation. A device offline for a week replays its queued local commits in order; each is an optimistic commit against the head it will discover on reconnect, so any that collide with intervening remote edits fall into the same conflicted-copy path — the offline case is not special, it is just the concurrent case stretched over time.
- Atomic local apply. Reconstructed files are written to a temp path and atomically renamed into place, so a crash mid-write never surfaces a half-downloaded file to the user or to the folder watcher.