Want it harder? Hide the model answer and work each phase in a scratchpad against its time budget, then reveal and score yourself. Or simply read on: the checkpoints below work either way.
How Do You Keep a Billion Repositories Identical on Five Machines Each, While People and Agents Push to Them Around the Clock?
It is a Thursday morning in late October 2026 in Bengaluru. Nandini Hegde maintains an open-source Python library that parses and validates Indian postal addresses and PIN codes. Thousands of projects depend on it. Today is the 4.0 release. At 10:40 she adds the last pull request to the merge queue, which tests it against the newest main together with the two pull requests ahead of it. At 11:02, from her laptop in Koramangala, she runs git push origin v4.0.0 over SSH.
That one tag is a small write with a long tail. It must reach a majority of the repository's replicas before GitHub says yes. It wakes the release workflow: a matrix of 24 jobs on Linux, macOS and Windows, each on a fresh virtual machine, building packages from code anyone could have proposed. It sends webhooks to her documentation host and to the project's chat bot. Within the hour, thousands of other projects' CI pipelines fetch her repository to test against the new version. Her co-maintainer in Pune has three coding agents pushing to their own branches of the same repository every few minutes. And at 11:20 someone searches all of GitHub for parse_pin(, the function she deprecated, to see whose code will break.
- 225 million+ — developers on GitHub; India has 32.0 million, second only to the United States (Octoverse, 28 October 2026)
- 17.9 billion — pushes in the year to September 2026; 34.3 billion commits; 1.3 billion merged pull requests, about 3.5 million a day (same report)
- 124 million — Actions jobs a day on average over the year to September 2026, 1,440 a second, more than three times the previous year's total (Octoverse 2026); GitHub gave 71 million a day in December 2025 and about 23 million in early 2024 (GitHub, 11 December 2025)
- 1 billion — repositories, each kept on five replicas by default; Git activity grew from 218.2 billion to 473.3 billion events a month between September 2025 and August 2026 (Brian Celenza, GitHub Engineering, 6 October 2026)
- 100 to 1,000 — reads for every write in most repositories; Spokes handles well over 100 billion operations a day (Eleanor White and Victoria Dye, GitHub Engineering, 21 October 2026)
- Almost 45 million repositories, 115 TB of code and 15.5 billion documents in the new code search at its beta, with ~100 ms p99 per shard (Timothy Clem, GitHub Engineering, February 2023)
For context:
GitHub has published how Spokes replicates repositories, how pushes are processed, how code search works, Actions' volumes and limits, and monthly availability reports with root causes; it hasn't published how the Actions scheduler places jobs or how webhooks are queued internally
GitHub has been moving its infrastructure to Azure since 2025; this quest describes the published state as of October 2026
Everything not in the list above is an illustrative design, not GitHub's internal architecture
This is the system we'll design today.
GitHub's Spokes serves a billion repositories, five replicas each by default; the year to September 2026 saw 17.9 billion pushes, 1.3 billion merged pull requests and 124 million Actions jobs a day. That is ~1,300 pushes a second and up to ~1.3 million Git reads a second; each push wakes 60+ follow-up tasks, and on an assumed 5 minutes a job ~430,000 runner VMs are busy. A push is acknowledged only when a strict majority of replicas apply it with matching checksums; in 2026 GitHub is narrowing the quorum from the whole repository to each reference. Everything after the write reads one Kafka event per push, with independent consumers, which raised fully processed pushes from ~99.897% to at least 99.999%; the merge queue tests each pull request against the newest main. Webhook receivers must answer within 10 seconds, so each host gets its own queue, cap and breaker, and a redelivery keeps its delivery id. Actions queues jobs per account, runs each on a fresh VM, gives forks no secrets and refuses stale runners by lease epoch. Code search (Blackbird) is sharded by Git blob id, about 100 ms p99 per shard, where grepping 115 TB would take ~96 seconds. The deep dives end with release day, a receiver down for four days and 2,000 coding agents on one monorepo.
The Interview Begins#
You're interviewing at a company that builds developer tools. The interviewer pastes the end of a terminal session into the shared document: * [new tag] v4.0.0 -> v4.0.0. Next to it, a screenshot of a repository's Actions tab: "Release · 24 jobs · Queued". Under it, a webhook's "Recent deliveries" list, one row red: "Failed · timed out".
"One push did all of that. Design the platform behind it: where the repository lives, how the push gets in, what happens after it, how those 24 jobs run, how the world hears about it, and how anyone finds the code later."
Developers and automated agents push to and fetch from Git repositories over SSH and HTTPS. Every push updates pull requests, triggers CI workflows, sends webhooks to outside services and refreshes a code search index. Repositories range from a single file to monorepos that thousands of engineers push to every day.
Requirements
- Host Git repositories of any size; accept pushes, serve clones and fetches over SSH and HTTPS
- Never lose an acknowledged push; once a branch moves, nobody sees it move back
- Pull requests with diffs, reviews, mergeability checks and a merge queue
- Deliver a webhook for every subscribed event to millions of outside endpoints
- Run CI jobs from untrusted code, isolated per job, with fair shares between customers
- Search code across all public repositories and the private ones a user can see
"Leave out Copilot, Codespaces, Pages, packages and the issue tracker. Billing only matters where it shapes Actions."
Phase 1: Requirements Clarification (5 minutes)#
"A code host is three systems sharing one event: a replicated storage system for Git data, an event pipeline that reacts to every push, and a compute platform that runs strangers' code. I'll ask about each, starting with the write path, because everything hangs off it."
Your Questions#
"How many repositories, and how busy are they?"
"GitHub wrote this month that Spokes serves a billion repositories. Pushes grew 4.9 times in a year, to 3.35 billion a month, and the busiest repository saw roughly a billion requests in August 2026."
"What's the read-to-write ratio?"
"In 2017 GitHub wrote that reads outnumber writes about 100 to 1. In October 2026 it said most repositories see between 100 and 1,000 reads for every write. CI and code scanning clone or fetch the same branch tip thousands of times a minute."
"What does 'the push succeeded' promise?"
"That's your call, but here's GitHub's: Spokes doesn't accept a write unless a strict majority of replicas can apply it and get the same result, and always at least two."
"What happens after a push?"
"In 2024 GitHub counted over 60 pieces of logic, owned by 20 services, that run on every push: pull request sync, push webhooks, workflow triggers, Pages and more."
"And the CI side?"
"Actions ran 124 million jobs a day over the past year. Each GitHub-hosted runner is a new virtual machine, except single-CPU runners, which run in a container on a shared VM. A job can run for up to 6 hours. Standard runners are free for public repositories; a Linux 2-core minute lists at $0.006."
"Webhooks?"
"Receivers must answer with a 2XX within 10 seconds or the delivery counts as failed. GitHub doesn't redeliver failed deliveries automatically; receivers can redeliver anything from the past 3 days. Payloads are capped at 25 MB."
Functional Requirements#
- Git: push, clone and fetch over SSH and HTTPS; branches, tags, force pushes and deletes; partial and shallow clones
- Pull requests: diffs against the base, review comments, mergeability ("can this merge cleanly?"), a merge queue that tests changes against the newest base
- Events: every push, pull request, review and CI state change becomes an event that updates the UI and triggers workflows and webhooks
- Webhooks: signed deliveries to endpoints that subscribe, a unique delivery id, a delivery log, manual and API redelivery
- Actions: workflows triggered by events, a job queue per customer, hosted runners (one VM per job) and self-hosted runners, logs, caches and artifacts
- Search: substring, regex and symbol search over code, filtered by language, owner and path, showing only what the user may see
Non-Functional Requirements#
Scale (published, then estimated)
- ~1,300 pushes a second on average (3.35 billion a month, published); design for ~5,000 at peak
- 130,000 to 1.3 million Git reads a second (100 to 1,000 reads per write)
- ~1,440 CI jobs a second started on average (published); design for ~5,000
Latency (design targets)
- Push acknowledged in < 1 s at p99 for ordinary repositories, even with replicas in different regions
- Push to webhook sent and push to CI job queued in < 10 s at p99; hosted job started in < 30 s at p50
- Code search answered in < 1 s at p99
Durability and isolation
- Zero lost acknowledged pushes; a reference never goes back to an older value for a reader
- Zero hosted runners reused between jobs; one customer's burst never stops another customer's work
Phase 2: Back of the Envelope Estimation (5 minutes)#
"The published anchors are strong here: pushes a month, reads per write, Spokes operations a day, Actions jobs a day and code search's index. Storage sizes, job durations and webhook counts aren't published, so those are my assumptions, labelled."
Before the numbers: what does one push cost?
GitHub received 3.35 billion pushes in a month in 2026. How many is that a second? Most repositories see 100 to 1,000 reads for every write: how many Git reads a second does that imply, and does it agree with Spokes' "well over 100 billion operations a day"? And if every push triggers over 60 pieces of follow-up logic, how many background tasks a second is that?
One strong answer
- Pushes: 3.35 billion ÷ (30 × 86,400 s) ≈ 1,300 a second on average. Design for a peak of ~3× plus growth: ~5,000 a second.
- Reads: 1,300 × 100 to 1,000 = 130,000 to 1.3 million a second. Spokes' 100 billion operations a day ÷ 86,400 ≈ 1.2 million a second: the upper end, which also counts internal reads by the web app, pull requests and CI.
- Follow-up tasks: 1,300 × 60 ≈ 78,000 background tasks a second before any CI job runs.
- So the write rate is modest; what makes a push expensive is that each one needs agreement among replicas, then fans out into dozens of tasks and hundreds of reads.
Git Traffic#
| Value | |
|---|---|
| Pushes a month (GitHub, 2026) | 3.35 billion |
| Average push rate | ~1,300 a second |
| Peak (assumed 3× average, plus growth) | ~5,000 a second |
| Reads per write (GitHub, 2026) | 100 to 1,000 |
| Git reads | 130,000 to 1.3 million a second |
| Spokes operations (GitHub, 2026) | 100 billion+ a day ≈ 1.2 million a second |
| Merged pull requests (Octoverse 2026) | 3.5 million a day ≈ ~40 a second |
Conclusion: reads outnumber writes by two to three orders of magnitude, so read capacity must scale without slowing writes. Writes are few, but each one is a small distributed transaction.
Storage#
- Repositories: ~1 billion (published); most are tiny, a few monorepos are hundreds of gigabytes
- Average packed size: assume ~20 MB → ~20 PB of Git data
- Replicas: five by default (published) → ~100 PB on fileservers
- Search: in 2023, 115 TB of searchable code shrank to 28 TB of unique content after deduplication, and the whole index, content included, was 25 TB (published)
Conclusion: the copies are the cost; content duplicated across forks and vendored folders is the opportunity. Git's content addressing lets search and storage deduplicate for free.
Actions#
| Value | |
|---|---|
| Jobs a day (Octoverse 2026) | 124 million (~1,440 a second) |
| Average job length (assumed) | ~5 minutes |
| Runner-minutes a day | ~620 million |
| Runners busy at once, on average | 620 million ÷ 1,440 minutes ≈ ~430,000 VMs |
| Peak (assumed 2× average) | ~860,000 VMs |
| VM boot if started on demand (assumed) | ~30 s → 10% of a 5-minute job |
| List price, Linux 2-core (GitHub docs) | $0.006 a minute |
Conclusion: at list price, a day's runner-minutes would be worth ~$3.7 million, so a few percent of boot time, idle warm VMs or retried jobs is real money. Starting from a warm pool and deleting each VM after one job is the design's core trade between isolation and cost.
Webhooks and Events#
- Events: pushes, pull request activity, reviews, check runs and workflow jobs: assume ~1 billion a day
- Subscribed hooks per event: assume ~2 → ~2 billion deliveries a day ≈ 23,000 a second, ~70,000 at peak
- Slow receivers: if 5% of deliveries go to endpoints that hang until the 10-second timeout, ~11,500 connections are held open at any moment just waiting
Conclusion: the receivers, not GitHub, set the pace of delivery. Isolation per destination, and a decision about how long a delivery stays worth sending, matter more than raw throughput.
Key Metrics Summary#
| Metric | Value |
|---|---|
| Repositories (GitHub, 2026) | ~1 billion, five replicas by default |
| Pushes (GitHub, 2026 / design peak) | ~1,300 a second / ~5,000 a second |
| Git reads (100 to 1,000 per write) | 130,000 to 1.3 million a second |
| Actions jobs (Octoverse 2026) | 124 million a day, ~1,440 a second |
| Busy runner VMs (assumed 5-minute jobs) | ~430,000 average, ~860,000 peak |
| Code search (GitHub, 2023 beta) | 15.5 billion documents, 25 TB index, ~100 ms p99 per shard |
Requirements and estimation
Q1/3 GitHub received 3.35 billion pushes in a month. Roughly how many a second?
Answer & explanation
Answer: B
3.35 billion ÷ 2.6 million seconds in a month ≈ 1,300 a second.
Q2/3 Most repositories see 100 to 1,000 reads per write. What does that mean for the design?
Answer & explanation
Answer: B
130,000 to 1.3 million reads a second against ~1,300 pushes. Adding read capacity must not add work to every push.
Q3/3 124 million CI jobs a day averaging an assumed 5 minutes each keeps roughly how many runner VMs busy at once?
Answer & explanation
Answer: C
620 million runner-minutes ÷ 1,440 minutes in a day ≈ 430,000.
Phase 3: High-Level Design (10 minutes)#
Sketch it first: Nandini's tag push
Nandini runs git push origin v4.0.0 over SSH. Which component checks her key, and how does the push find her repository's replicas? What has to be true before Git prints [new tag] on her screen? Then: what turns the new tag into 24 CI jobs and two webhooks, and why shouldn't one slow webhook receiver delay her CI? Finally, where do the 24 jobs run, and what stops a malicious pull request's code from seeing her release secrets?
One strong answer
- The front door: a layer-4 load balancer spreads connections across Git proxies; the proxy checks her key and write permission and asks Spokes which fileservers hold the repository.
- Before "yes": the new objects go to the replicas, then the update to
refs/tags/v4.0.0goes through a vote; only when a majority of the five apply it with matching checksums is the push acknowledged. - After "yes": one push event goes to Kafka; independent consumers run pull request sync, webhooks, workflow triggers and indexing as separate jobs with their own retries.
- CI: 24 jobs enter her account's queue; a scheduler hands each to a fresh VM from a warm pool, with a token for this repository only; the VM is destroyed afterwards.
- Webhooks: each destination has its own queue and concurrency limit, so a receiver that hangs for 10 seconds only slows its own deliveries.
- Secrets: workflows triggered by pull requests from forks get no repository secrets and a read-only token; release secrets are only available to the tag-triggered workflow.
"Three planes around one event. The storage plane decides, by vote, whether a write happened. The event plane turns each accepted write into isolated follow-up jobs. The compute plane runs CI on disposable machines. None waits for the next."
System Architecture#
%% title: A code host: vote on the write, fan out the event, run CI on disposable machines
flowchart TD
subgraph L1["Layer 1 · Clients"]
direction LR
DEV@{ icon: "sd:laptop", label: "Developers and agents<br/>git over SSH or HTTPS" }
WEB@{ icon: "sd:browser", label: "Web and API<br/>pull requests, search" }
end
subgraph L2["Layer 2 · Edge"]
direction LR
LB@{ icon: "sd:load-balancer", label: "GLB, layer 4<br/>one IP, many proxies" }
GITP["Git proxies<br/>SSH and HTTPS, auth"]:::service
APP["Rails monolith<br/>web, API, PRs"]:::service
end
subgraph L3["Layer 3 · Git storage"]
direction LR
SPK["Spokes<br/>route, vote, repair"]:::hot
FS@{ icon: "sd:servers", label: "Fileservers<br/>five replicas, several sites" }
BLOB@{ icon: "sd:object-storage", label: "Azure Blob Storage<br/>virtual replica" }
end
subgraph L4["Layer 4 · Metadata and events"]
direction LR
MY@{ icon: "tech:mysql", label: "MySQL and Vitess<br/>PRs, issues, users" }
K@{ icon: "tech:kafka", label: "Kafka<br/>one event per push" }
JOBS@{ icon: "sd:worker", label: "Background jobs<br/>60+ push tasks" }
end
subgraph L5["Layer 5 · Outward"]
direction LR
HOOK@{ icon: "sd:webhook", label: "Webhook delivery<br/>per-endpoint queues" }
ACT["Actions control plane<br/>queues, fair share"]:::core
RUN@{ icon: "sd:server", label: "Runner VMs<br/>one job each" }
SRCH@{ icon: "sd:search", label: "Code search<br/>sharded by blob" }
end
DEV ==> LB
WEB ==> LB
LB ==> GITP
LB --> APP
GITP ==> SPK
APP --> SPK
APP --> MY
SPK ==> FS
SPK -.->|"durable copy"| BLOB
SPK ==>|"ref updated"| K
K ==> JOBS
JOBS ==> HOOK
JOBS ==> ACT
ACT ==> RUN
RUN -.->|"clone"| GITP
K --> SRCH
class DEV,WEB client
class LB edge
class FS,BLOB,MY store
class K queue
class JOBS,RUN,HOOK,SRCH serviceKEY INSIGHTS:
- Git data and metadata live in different systems. Repositories are files on fileservers replicated by Spokes; pull requests, issues, users and permissions are rows in MySQL. They fail differently and are replicated differently
- The write is a vote, the rest is an event. Only the reference update needs agreement among replicas. Everything that follows (pull requests, webhooks, CI, search) reads from one event and can lag, retry or fail on its own
- Strangers' code runs on machines that are thrown away. The compute plane is multi-tenant by design, so isolation is per job, not per customer
Data Flow: Nandini Pushes the Tag#
%% title: One tag push, from Nandini's laptop to a majority of replicas and onwards sequenceDiagram autonumber participant N as Nandini's laptop participant P as Git proxy, SSH participant S as Spokes participant R as Five replicas participant K as Kafka participant J as Push jobs N->>P: git push over SSH, key checked P->>S: Which replicas hold this repository S-->>P: Five replicas, preference order P->>R: Send pack, new objects only S->>R: Vote on ref update, old and new value R-->>S: Locked, applied, checksums match on 5 of 5 S->>R: Commit S-->>P: Committed on a majority P-->>N: new tag v4.0.0 S->>K: Push event, repo, ref, old and new SHA K->>J: Fan out to independent consumers J->>J: PR sync, webhooks, workflow trigger, search
MEANWHILE, IN PARALLEL:
- The release workflow's 24 jobs enter the Actions queue of Nandini's organization; warm VMs pick them up and clone the tag through the same Git proxies
- Two webhook deliveries go into the queues for her documentation host and her chat bot; each is signed and carries a delivery id
High-level design
Q1/3 Nandini's push reaches 3 of 5 replicas; 2 time out. Is the push acknowledged?
Answer & explanation
Answer: A
Spokes accepts a write when a strict majority applies it identically; losers of the vote are repaired from the others.
Q2/3 Why is the push event published to Kafka instead of running all follow-up work inside the push?
Answer & explanation
Answer: B
One event, many independent consumers: a smaller blast radius and per-task retry rules.
Q3/3 Where do pull requests and issues live, compared with Git data?
Answer & explanation
Answer: B
Git objects and references are on fileservers; metadata (pull requests, issues, users) is in MySQL.
Phase 4: Deep Dives (20 minutes)#
Deep Dive 1: Storing a Billion Repositories (Week 1 Concepts: Replication Trade-offs; Week 5: Consistency Models, Leader Election)#
"A Git repository is two things: a pile of immutable objects named by their hashes, and a small set of references (branch and tag names) that point at them. Objects never conflict; two copies of the same object are the same bytes. References are the only mutable state, so they're the only thing replicas must agree on."
This builds on Week 1, Day 2 · Replication Trade-offs, Week 5, Day 1 · Consistency Models and Week 5, Day 5 · Leader Election.
Five copies, one branch, two writers
Nandini's repository has five replicas. At the same moment, her co-maintainer deletes the branch release-3.x and an agent pushes a commit to it. One replica is busy and answers slowly; one is in another region, 25 ms away. Which write wins, and how do all five replicas end up agreeing on that? How many replicas must apply a write before you say "pushed"? Why not do what most databases do: one primary per repository that orders writes, with the others copying it?
One strong answer
- Serialize writes on a majority. Each write first takes a lock on a majority of the replicas. Two writes can't both hold a majority, so one goes first; the second then sees the branch's new value and is checked against it (the agent's push expects the old value, finds the branch gone, and is rejected).
- "Pushed" means a strict majority applied it and got the same result: 3 of 5, never fewer than 2. Replicas that disagree or time out lose the vote, are marked unhealthy and repaired from the others.
- No primary to fail over, at a price. With a primary, losing it means an election, and a bad one leaves two copies that each lack the other's writes. A vote per write removes the election, but every write waits on its replicas, so the slowest or most distant one sets write latency.
What is published: GitHub announced DGit in April 2016: three copies of every repository on three servers, with a write "only committed if at least two replicas confirm success". In September 2016 Patrick Reynolds renamed it Spokes: copies sit in different racks; no update is accepted "unless a strict majority of the replicas can apply the change and get the same result"; every write "acquires an exclusive lock on a majority of replicas", so "Spokes eliminates conflicts by eliminating concurrent writes entirely"; and rather than electing a primary, Spokes "treats every write as an election". Losers of a vote are marked unhealthy until repaired; on a typical day "a few dozen writes" aren't unanimous. In 2017 Michael Haggerty described a three-phase commit, with the replicas also used as a distributed lock, costing four round trips to distant replicas (60 to 80 ms each across the US) and about a third of a second per reference update, and a checksum per replica built from the XOR of a hash of every (reference, value) pair, so it can be updated incrementally.
%% title: One reference update as a vote across five replicas
flowchart TD
P["Push arrives<br/>pack already on replicas"]:::service
LOCK["Phase 1 · lock<br/>repository or reference on each replica"]:::core
CHECK{"Old value matches<br/>and new value valid?"}
APPLY["Phase 2 · apply tentatively<br/>compute new checksum"]:::core
VOTE{"Strict majority<br/>with equal checksums?"}
COMMIT(["Phase 3 · commit<br/>acknowledge the push"]):::ok
ROLL(["Roll back everywhere<br/>reject the push"]):::bad
REPAIR@{ icon: "sd:worker", label: "Sweeper<br/>repair the losers" }
P ==> LOCK ==> CHECK
CHECK ==>|"yes"| APPLY ==> VOTE
CHECK -->|"no"| ROLL
VOTE ==>|"yes"| COMMIT
VOTE -->|"no"| ROLL
COMMIT -.->|"minority disagreed"| REPAIR
class REPAIR service- Majority, not "two copies". Two copies survive one failure, but conflicting writes must be applied in the same order everywhere, and a majority lock makes concurrent writers impossible. With five replicas, a write needs three.
- Failure detection from real traffic. Spokes marks a fileserver offline after three requests fail in a row, and each application server keeps its own view, so one confused app server can't take a healthy fileserver out for everyone. Offline only moves a replica to the end of the read list.
- Repair is N-to-N, across racks. When a server dies, new copies of its repositories are made from any surviving replica onto any server, never two in one rack, so the bigger the fleet, the faster the repair. By 2021 GitHub kept five replicas "across our three data centers".
Compare: the database that did fail over#
The 43-second partition in October 2018 hit GitHub's MySQL clusters, which hold "non-Git metadata" such as pull requests and issues. Each had one primary, and Orchestrator promoted West Coast primaries during a 43-second network cut; the East still held a few seconds of writes the West never got, and service was degraded for 24 hours and 11 minutes. GitHub's first fix was to forbid promotions across regions. A single-primary design must decide where it may fail over to; a per-write majority never fails over, it just refuses writes when it can't reach a majority.
2026: one copy in another region, one in object storage#
What is published: In October 2026 GitHub wrote that a copy of 99.99999% of repositories now lives in Azure; most repositories hold two of their five replicas there and Azure serves about half of Git traffic, with reads sent to a replica in the same region and zone. But the quorum "waits for every healthy replica to respond (up to a timeout)" and each write "holds a repository-wide lock until it commits", so the slowest replica sets latency and throughput, and an Azure replica is about 25 ms away. GitHub's fix, now rolling out: change the quorum's scope "from the whole repository to individual references", since "Git's consistency requirements apply to each reference individually"; in internal testing with realistic traffic, writes to different references "consistently saw 3–4x higher throughput at lower latency". The next step makes Azure Blob Storage a member of the quorum, a "virtual replica", so a write isn't acknowledged until it's durable in object storage; read replicas then warm their caches from it without joining the quorum. In its 6 October post GitHub said its new architecture "has delivered up to 35 times higher write throughput" in internal benchmarks.
- Per-reference serialization keeps Git's guarantee (no reader sees a branch go back) while agents pushing to their own branches proceed in parallel; storage apart from compute fixes the old trap, where adding a replica for reads added a voter to every write.
# spokes/ref_update.py
"""
A reference update as a majority vote (illustrative, after Spokes' three-phase
commit), locked per reference as in GitHub's 2026 design. The checksum is the XOR
of hash(refname, value) over all references, so an update changes it by two hashes.
"""
import hashlib
def h(ref: str, sha: str) -> int:
return int.from_bytes(hashlib.sha256(f"{ref}\0{sha}".encode()).digest()[:8], "big")
def new_checksum(old: int, ref: str, old_sha: str, new_sha: str) -> int:
return old ^ h(ref, old_sha) ^ h(ref, new_sha)
def update_ref(replicas, ref: str, old_sha: str, new_sha: str, timeout_s: float = 0.5) -> bool:
need = len(replicas) // 2 + 1 # 3 of 5
locked = [r for r in replicas if r.lock(ref, timeout_s)]
if len(locked) < need:
for r in locked: r.unlock(ref)
return False # no majority: refuse, never diverge
votes = {}
for r in locked: # phase 2: prepare
ok, checksum = r.prepare(ref, old_sha, new_sha) # checks old value, applies tentatively
if ok:
votes.setdefault(checksum, []).append(r)
winners = max(votes.values(), key=len, default=[])
if len(winners) >= need: # phase 3: commit
for r in winners: r.commit(ref)
for r in set(locked) - set(winners): r.rollback(ref); r.mark_unhealthy()
return True
for r in locked: r.rollback(ref)
return FalseWhen one repository is the whole company#
A monorepo that won't take pushes
A customer's monorepo has thousands of engineers, hundreds of thousands of references and a push every few seconds. Pushes start failing. You find three things: background maintenance (repacking objects into packfiles) keeps hitting its time limit; the checksum check holds the write lock for 20 to 30 seconds whenever a replica is repaired; and a per-server cap on push concurrency, added years ago to protect other tenants, is queueing pushes. What do you change?
One strong answer
- Make maintenance proportional to new data: keep packs in a geometric progression (each at least twice the next) and merge only the small, recent ones; repack fully now and then; retry failures within hours.
- Move expensive work out of the lock. Compute the replicas' checksums before taking the lock, so the lock covers only the comparison and the update.
- Revisit old limits: a tenant-protection cap built for older hardware may now only hurt; raise it gradually while watching neighbours.
- For agent-heavy repositories, per-reference locking lets pushes to different branches proceed in parallel; only merges into the same branch must take turns.
What is published: In March 2021 GitHub described Project Cyclops, after its largest monorepo customers and its own github/github saw push failures. Maintenance runs "after every 50 git push operations, or after we receive 40MB of unpacked files"; limiting expensive delta comparisons in git repack "eliminated nearly all" maintenance failures; failed maintenance was retried every four hours instead of after seven days; an old per-fileserver push cap was removed; and replica checksums, which took 20 to 30 seconds inside the lock during repairs, were precomputed, cutting the lock "to under 1 second". In April 2021 Taylor Blau described geometric repacking with multi-pack reachability bitmaps, which cut average repack time "from around 1 minute... to just 15 seconds". Bitmaps let a server work out what to send by bitwise operations: walking the Linux kernel's objects fell from over 33 seconds to 1.57.
Deep Dive 2: The Front Door: Push, Clone and Fetch over SSH and HTTPS (Week 4 Concepts: Multi-Tier Caching; Week 1: Rate Limiting; Week 2: Timeouts)#
"In Git's protocol the server advertises its references, the client says what it wants and has, and the server builds a pack of exactly the missing objects. The front door makes that cheap, routes it to a good replica, and keeps long transfers alive."
This builds on Week 4, Day 5 · Multi-Tier Caching, Week 1, Day 3 · Rate Limiting at Scale and Week 2, Day 1 · Timeout Hell.
Ten thousand pipelines fetch the tag
Within an hour of Nandini's release, thousands of other projects' CI jobs fetch her repository, most of them full clones of the same commit, many more than once a minute. Her own release jobs clone it too. Meanwhile a contributor in Chennai is halfway through a 40-minute clone of a large monorepo on a slow connection when the fileserver holding his replica needs a kernel update. How do you serve the burst cheaply, and how do you take that fileserver down without breaking his clone?
One strong answer
- Spread reads over every in-sync replica, nearest first. All five copies (and, in the new design, cache-only read replicas) can answer; the writes don't care how many readers there are.
- Make the expensive part cheap: reachability bitmaps turn "which objects does this client need?" into bitwise operations, and a pack built for a popular commit can be cached for the next identical request (our design; GitHub hasn't published a pack cache).
- Ask CI to fetch less, and rate-limit: blobless or shallow clones transfer a fraction of the data; per-client and per-repository limits stop one runaway pipeline.
- Drain, don't kill: mark the fileserver offline for new reads but let running clones finish (quiescing), keep sending it writes so it stays in sync, then reboot. His clone completes; the next one goes elsewhere.
What is published: GitHub's GLB (2018) "powers the majority of GitHub's public web and git traffic": a layer-4 load balancer with stateless directors that pick a primary and a secondary proxy per connection, so directors can change without breaking open connections. Spokes returns "up-to-date replicas, sorted in preference order" and routes reads "to the closest replica that is in sync". Before planned reboots, fileservers are quiesced: no new reads, but "existing read operations, including clones, are allowed to finish", which "can take anywhere from a few seconds to many hours". A 2020 GitHub post by Derrick Stolee recommends blobless clones for developers and long-lived build machines, and shallow clones only for builds deleted after one use. On 9 February 2026 a burst of cache rewrites caused "connection exhaustion in the service proxying Git operations over HTTPS"; HTTPS pushes and pulls failed while "SSH-based Git operations were not affected".
%% title: A fetch, from the edge to the nearest in-sync replica
flowchart TD
C@{ icon: "sd:terminal", label: "git fetch<br/>SSH or HTTPS" }
LB@{ icon: "sd:load-balancer", label: "GLB<br/>stateless directors" }
PX["Git proxy<br/>auth, rate limits"]:::service
RT{"Spokes route<br/>in-sync replicas, nearest first"}
PC@{ icon: "sd:cache", label: "Pack cache<br/>hot commits, our design" }
FS1@{ icon: "sd:server", label: "Replica, same zone<br/>bitmaps answer what to send" }
FS2@{ icon: "sd:server", label: "Next replica<br/>after 3 failures in a row" }
C ==> LB ==> PX ==> RT
RT ==>|"popular tip"| PC
RT ==>|"first choice"| FS1
RT -.->|"failover"| FS2
class C client
class LB edge
class PC cache
class FS1,FS2 store- Two protocols, two proxy fleets (our design). GitHub's February 2026 report names a separate "Git HTTPS proxy" layer, and SSH Git kept working. Here SSH and HTTPS share replica routing but run on separate proxy fleets, so one can fail without the other.
- Authenticate, then authorize per repository (read, write, protected branch) before Spokes is asked anything.
- Timeouts sized to the operation. A reference advertisement takes milliseconds; a clone can stream for an hour. One idle timeout for both either kills big clones or lets stuck connections pile up.
- Shed by cost. A full clone of a monorepo isn't a
git ls-remote; when fileservers saturate, admit cheap operations and shed the most expensive first.
# gitproxy/route.py
"""
Picking a replica for a read (illustrative, after Spokes' published rules): in-sync
replicas nearest first; three failures in a row moves a replica to the end of the
list, not off it; a quiescing replica takes no new reads.
"""
FAILS_TO_DEMOTE = 3
def read_order(replicas: list, fail_streak: dict, draining: set) -> list:
usable = [r for r in replicas if r.in_sync and r.name not in draining]
healthy = [r for r in usable if fail_streak.get(r.name, 0) < FAILS_TO_DEMOTE]
suspect = [r for r in usable if fail_streak.get(r.name, 0) >= FAILS_TO_DEMOTE]
return sorted(healthy, key=lambda r: r.distance) + suspect # try suspects last, not never
def serve_fetch(repo, replicas, fail_streak, draining, request):
for r in read_order(replicas, fail_streak, draining):
try:
pack = r.upload_pack(repo, request, timeout_s=request.budget_s)
fail_streak[r.name] = 0
return pack
except (ConnectionError, TimeoutError):
fail_streak[r.name] = fail_streak.get(r.name, 0) + 1
raise RuntimeError("no replica could serve the fetch")Storage and the front door
Q1/3 Why does Spokes lock a majority of replicas for each write rather than just copying to two?
Answer & explanation
Answer: B
Two copies survive one failure; the majority lock is what serializes conflicting writes.
Q2/3 In GitHub's 2018 incident, MySQL primaries were promoted across regions. Why can't the same thing happen to a Spokes repository?
Answer & explanation
Answer: B
A per-write majority stops accepting writes rather than electing a new writer in the wrong place.
Q3/3 Why is GitHub moving to per-reference serialization in 2026?
Answer & explanation
Answer: B
Git's guarantees are per reference, so only writes to the same reference must be ordered.
Deep Dive 3: After the Push: Pull Requests, Test Merges and the Merge Queue (Week 3 Concepts: Queue vs Stream, Dead Letters; Week 1: Hot Keys)#
"Once the push is acknowledged, dozens of things must happen with different deadlines and retry rules. A webhook an hour late is useless; a push record an hour late is fine. In one job, they share each other's worst failure modes."
This builds on Week 3, Day 1 · Queue vs Stream, Week 3, Day 4 · Dead Letters and Poison Pills and Week 1, Day 4 · Hot Keys and Skew.
One push to main, two hundred open pull requests
Someone merges into main of a busy repository with 200 open pull requests targeting it. Every one of those pull requests now shows a stale "can be merged" badge and a stale diff. Meanwhile the push-processing job that should update them crashes halfway, after sending the push webhook but before syncing the pull requests. What should recompute, when, and how do you retry without sending the webhook twice? Then: 40 engineers want to merge into main in the next hour, and each merge must pass CI against the main that includes everyone ahead of them. How do you avoid testing against a main that's already stale?
One strong answer
- One event, many consumers, each with its own retry policy. Pull request sync retries freely; the push webhook is sent once, by its own job, keyed by the event id.
- Mergeability is derived: a test merge of each pull request with the new
main, stored as a hidden reference. 200 of them are bookkeeping writes: coalesce them, put user pushes first, and compute first for the pull requests people are looking at. - The merge queue tests each pull request on a temporary branch of the newest
mainplus everything ahead of it; it merges when that passes, and a failure is removed and the rest re-tested.
What is published: In June 2024 Will Haltom described how one huge background job, RepositoryPushJob, ran every piece of push processing in sequence. Retries were avoided because some steps tolerate duplicates and others don't ("it is also not desirable to dispatch multiples of the same webhook"), and an error in the Pushes MySQL cluster at the start could stop pull requests syncing. GitHub replaced it with a Kafka event per push, fanned out by independent consumers to jobs grouped by owner and retry needs, with a publisher "that would retry until broker acknowledgement". Ownership spread to 15+ teams, and fully processed pushes rose from about 99.897% to at least 99.999%. In 2017 GitHub explained that each push to a pull request branch creates "a test merge and test rebase", and a push to master does so "for every active pull request whose target is master", sometimes "more than a hundred references", keeping a repository busy for about 30 seconds; it coalesced these updates and gave user pushes priority.
%% title: One push event, many isolated consumers
flowchart TD
S["Spokes<br/>ref updated"]:::hot
K@{ icon: "tech:kafka", label: "Kafka<br/>push event, reliable publisher" }
PR@{ icon: "sd:worker", label: "PR sync<br/>test merges, retry freely" }
WH@{ icon: "sd:webhook", label: "Push webhooks<br/>at most once, time-bound" }
WF@{ icon: "sd:worker", label: "Workflow trigger<br/>keyed by event id" }
IDX@{ icon: "sd:index", label: "Search indexing<br/>new blobs only" }
DB@{ icon: "tech:mysql", label: "Push records<br/>idempotent rows" }
DLQ["Dead letters<br/>alert the owner"]:::bad
S ==> K
K ==> PR
K ==> WH
K ==> WF
K --> IDX
K --> DB
PR -.->|"retries exhausted"| DLQ
class K queue
class PR,WF,IDX service
class WH service
class DB store- Group by retry semantics. Tasks that tolerate duplicates retry for hours; must-happen-once tasks are keyed by the event id; time-bound tasks give up after a deadline.
- Each consumer keeps its own offset, so search indexing an hour behind doesn't delay pull request sync. A task that exhausts its retries goes to a dead-letter queue with an owner and an alert, not into a "catch everything" block.
- Merges contend on one reference. GitHub wrote in 2026 that merge queues and release trains "funnel all that work onto a single ref", and pull request merges grew to nearly 4 times their volume of a year earlier. A busy
mainis a hot key that per-reference locking can't split. - The merge queue turns contention into batches. GitHub's docs describe temporary
gh-readonly-queue/{base_branch}branches, amerge_groupevent for CI, and a build concurrency of 1 to 100 groups. GitHub's own monorepo merges about 2,500 pull requests a month through it, with a 33% shorter average wait (March 2024). - Degrade, don't fail. By October 2026, half of sampled pull request traffic could "continue with limited functionality when a shared dependency fails".
# push/consumers.py
"""
Push follow-up work as independent consumers (illustrative): each task group has
its own retry policy; once-only tasks are keyed by the event id, and time-bound
tasks give up when the event is too old to be useful.
"""
import time
POLICIES = {
"pr_sync": {"max_attempts": 20, "deadline_s": 6 * 3600, "once": False},
"push_webhooks": {"max_attempts": 3, "deadline_s": 10 * 60, "once": True},
"workflows": {"max_attempts": 10, "deadline_s": 3600, "once": True},
"push_records": {"max_attempts": 50, "deadline_s": 24 * 3600, "once": False},
}
def handle(task: str, event: dict, attempt: int, done_keys, run, dead_letter, retry_later):
p = POLICIES[task]
key = f"{task}:{event['id']}"
if p["once"] and done_keys.contains(key):
return # already done by an earlier attempt
if time.time() - event["pushed_at"] > p["deadline_s"]:
return dead_letter(task, event, reason="too old to be useful")
try:
run(task, event)
if p["once"]:
done_keys.add(key)
except Exception as err:
if attempt + 1 >= p["max_attempts"]:
dead_letter(task, event, reason=repr(err))
else:
retry_later(task, event, attempt + 1, backoff_s=min(2 ** attempt, 300))Deep Dive 4: Webhooks to the World (Week 2 Concepts: Webhook Delivery, Idempotency, Circuit Breakers)#
"A webhook is a promise to a server we don't run, which may be fast, slow or down for a week. Deliver quickly to healthy receivers, never let an unhealthy one slow the rest, and give receivers what they need to handle duplicates and gaps."
This builds on Week 2, Day 4 · Webhook Delivery System, Week 2, Day 2 · Idempotency in Practice and Week 2, Day 3 · Circuit Breakers.
A receiver that answers in 40 seconds
Nandini's documentation host does a full rebuild before replying to each push webhook, which takes 40 seconds. Her chat bot replies in 50 ms. A large CI vendor that receives 5% of all deliveries is down for four hours. What happens to each delivery, and what must not happen to everyone else's? When the vendor comes back, should you replay four hours of events to it? And how does any receiver tell a retried delivery from a new event?
One strong answer
- The docs host: at 10 seconds the delivery is cut off and recorded as failed. The fix is on the receiver: answer 2XX at once, then rebuild from a queue.
- Isolation: each destination has its own queue and concurrency cap; a breaker stops calling the dead vendor and probes it now and then, so it can't hold thousands of connections.
- Replay is a choice: a backlog dumped on a recovering receiver can knock it over again. Give receivers a delivery log and a redelivery API, and bound how long anything stays queued.
- Duplicates: a delivery id that stays the same on redelivery lets receivers skip repeats; an HMAC-SHA256 signature lets them reject forgeries.
What is published: GitHub's docs ask receivers to "respond with a 2XX response within 10 seconds", or "GitHub terminates the connection and considers the delivery a failure", and suggest a queue to process later. "GitHub does not automatically redeliver failed webhook deliveries"; receivers can redeliver from the past 3 days. The X-GitHub-Delivery GUID "will be the same as in the original delivery" on redelivery; X-Hub-Signature-256 is an HMAC-SHA256 of the body; payloads over 25 MB aren't delivered. After the 2018 partition, "over five million hook events" were queued; GitHub weighed recovery against "potentially overloading our ecosystem partners", and ~200,000 payloads "that had outlived an internal TTL" were dropped.
%% title: Deliveries isolated per destination
flowchart TD
EV@{ icon: "tech:kafka", label: "Events<br/>push, PR, workflow" }
FAN["Fan-out<br/>subscriptions, sign, delivery id"]:::core
Q1@{ icon: "sd:queue", label: "Queue · docs host<br/>4 in flight" }
Q2@{ icon: "sd:queue", label: "Queue · chat bot<br/>4 in flight" }
Q3@{ icon: "sd:queue", label: "Queue · CI vendor<br/>breaker open" }
SEND["Senders<br/>10 s timeout, log result"]:::service
LOG@{ icon: "sd:log", label: "Delivery log<br/>3 days, redeliver API" }
EXT@{ icon: "sd:globe", label: "Receivers<br/>verify, dedupe, queue" }
EV ==> FAN
FAN ==> Q1
FAN ==> Q2
FAN -.-> Q3
Q1 ==> SEND
Q2 ==> SEND
SEND ==> EXT
SEND --> LOG
class EV queue
class Q1,Q2,Q3 queue
class LOG store
class EXT external- Per-destination queues and caps. A destination is a host, not a hook: thousands of repositories may point at one CI vendor, so cap in-flight deliveries per host.
- Breakers per host. After a run of timeouts or 5xx responses, stop sending, keep queueing for a bounded time, probe occasionally, close on success.
- A delivery has a shelf life, set per event type and recorded in the log when it expires. In 2018 an internal TTL dropped ~200,000 payloads until GitHub raised it: make the shelf life explicit, visible in the log and recoverable by redelivery.
- Order is not promised; ramp the recovery. After a retry, two events for one pull request can arrive in either order, so receivers fetch current state when order matters. A host that comes back is drained at a ramped rate.
# receiver/github_hook.py
"""
A receiver that does what GitHub's docs ask (illustrative): verify the signature,
answer within 10 seconds by queueing, and use X-GitHub-Delivery as an idempotency key.
"""
import hashlib
import hmac
def handle(headers: dict, body: bytes, secret: bytes, seen, queue) -> int:
sig = headers.get("X-Hub-Signature-256", "")
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
return 401 # forged or tampered
delivery = headers["X-GitHub-Delivery"] # same GUID on redelivery
if not seen.add_if_absent(delivery, ttl_s=7 * 86400):
return 200 # already handled: acknowledge, do nothing
queue.put({"event": headers["X-GitHub-Event"], "delivery": delivery, "body": body})
return 202 # well inside 10 s; the rebuild happens laterDeep Dive 5: GitHub Actions: Queueing, Runners, Isolation and Cost (Week 9 Concepts: Tenant Isolation, Noisy Neighbor; Week 3: Backpressure; Week 10: Capacity Planning)#
"Actions runs arbitrary code from the internet for many tenants. The hard parts: admitting work faster than the fleet grows without collapsing, sharing the fleet fairly, and making sure a job can reach nothing it wasn't given, including what the previous job left behind."
This builds on Week 9, Day 1 · Tenant Isolation, Week 9, Day 2 · Noisy Neighbor Prevention, Week 3, Day 3 · Backpressure and Flow Control and Week 10, Day 4 · Capacity Planning.
A 256-job matrix from a stranger's fork
A first-time contributor opens a pull request against Nandini's repository from a fork. Its workflow file has been edited to a 256-job matrix, each job running a crypto miner for 6 hours. At the same moment, an enterprise with 10,000 engineers starts its nightly builds, and Nandini's 24 release jobs are waiting. Who runs first? What can the fork's jobs see? What stops the next job on the same machine from finding the miner, or the release signing key? And what does all this cost you?
One strong answer
- Admission: workflows from first-time contributors' forks can be held until a maintainer approves them. Even when they run, the fork's jobs get no repository secrets and a read-only token.
- Fair share: every account has a concurrency cap (20 jobs at once on the Free plan); jobs beyond it wait in that account's own queue. The scheduler picks the next job across accounts, not from one global line, so the enterprise's 10,000 jobs can't push Nandini's 24 to the back.
- Isolation: each hosted job runs on a new VM that's destroyed afterwards; nothing persists into the next job. Release secrets are scoped to the tag-triggered workflow (and can be gated by an environment with required reviewers).
- Limits and cost: 6 hours per job, 256 jobs per matrix, mining pools blocked in the hosts file. At an assumed 5 minutes a job, ~430,000 VMs are busy, so every second of boot or wasted retry is multiplied by that.
What is published: Actions ran about 23 million jobs a day in early 2024; GitHub rebuilt its core backend aiming "to scale 10x over existing usage", and by December 2025 the new architecture handled 71 million a day, with individual enterprises able "to start 7x more jobs per minute"; Octoverse 2026 counts 124 million a day on average over the year to September 2026. GitHub's docs: "each GitHub-hosted runner is a new virtual machine" (single-CPU runners share a VM in containers); eligible Linux jobs may run on AWS or Azure; hosted runners "execute code within ephemeral and clean isolated virtual machines", while self-hosted runners "should almost never be used for public repositories". Limits include 6 hours per job, 256 jobs per matrix, 1,500 triggering events per 10 seconds per repository, and 20 (Free), 60 (Team) or 500 (Enterprise) concurrent standard jobs. Forks get no secrets and a read-only GITHUB_TOKEN. In 2022 GitHub described its run orchestrator as "a durable state machine" that, each time it wakes up, replays execution from the beginning to rebuild its state.
GitHub's availability reports show where it bends. On 2 February 2026, a policy wrongly applied in the compute provider "blocked access to critical VM metadata", so no VM could be created and hosted runners were down for hours in every region, while self-hosted runners elsewhere kept working. On 6 August 2026, a routine deployment briefly cut capacity; during recovery "runners were handed jobs that had already been revoked, then got stuck retrying them... which created a self-amplifying backlog". On 26 August 2026, a burst of events saturated a shared database and "there was no automatic circuit breaker to throttle inbound actions load". GitHub's summary of the 26 August incident: "our shared infrastructure services have not kept up with our month-over-month actions growth and peak load."
%% title: A hosted job's life stateDiagram-v2 [*] --> PENDING_APPROVAL: fork PR, first-time contributor [*] --> QUEUED: trigger admitted PENDING_APPROVAL --> QUEUED: maintainer approves PENDING_APPROVAL --> EXPIRED: 30 days QUEUED --> ASSIGNED: fair-share pick, lease epoch n ASSIGNED --> QUEUED: lease lapsed, epoch n+1 ASSIGNED --> RUNNING: VM accepts, epoch matches RUNNING --> COMPLETED: steps done RUNNING --> FAILED: step failed or 6 h limit QUEUED --> CANCELLED: user or concurrency group COMPLETED --> [*] FAILED --> [*] CANCELLED --> [*] EXPIRED --> [*] class COMPLETED ok class FAILED bad class ASSIGNED queue
- Admit, then queue per account. Triggers pass per-repository and per-account rate limits; admitted jobs wait in the account's queue, and its concurrency cap is its share of the fleet.
- Schedule across accounts. Take the next job from the account furthest below its share (deficit round robin), per runner label. One enterprise's burst raises its own queue time, not everyone's.
- Warm pools per label. Keep booted, never-used VMs sized from a 15-minute forecast per image and size, top up reactively, and destroy each VM after its job. The pool is the price of isolation; size it with Week 10's capacity planning.
- Leases with epochs. An assignment carries an epoch the VM must present to start and report. A lapsed lease returns the job to the queue with a new epoch; a runner holding the old one is refused at once. That turns the August 2026 pattern into one fast rejection.
- A breaker on intake. When the job database's latency or lag crosses a threshold, slow intake automatically and let queues absorb the burst; delayed jobs beat a saturated primary.
- Cells and narrow tokens. Split the control plane into cells of accounts with their own databases and queues; give each job a token scoped to its repository that expires with the job.
# actions/scheduler.py
"""
Fair-share job assignment with leases (illustrative): the next job comes from the
account furthest below its share, and a lease carries an epoch so a runner holding
a revoked assignment is refused at once.
"""
import time
LEASE_S = 120
def pick_next(accounts: dict, label: str):
ready = [a for a in accounts.values()
if a.queue(label) and a.running < a.max_concurrent]
if not ready:
return None
a = min(ready, key=lambda a: a.used_recently / a.weight) # deficit-style fairness
return a.queue(label).pop()
def assign(job, vm, store) -> dict:
epoch = store.incr(f"job:{job.id}:epoch")
store.put(f"lease:{job.id}", {"vm": vm.id, "epoch": epoch, "until": time.time() + LEASE_S})
return {"job": job.id, "epoch": epoch, "token": mint_token(job.repo, job.permissions, ttl_s=job.max_s)}
def runner_report(store, job_id: str, epoch: int, vm_id: str, status: str) -> str:
lease = store.get(f"lease:{job_id}")
if lease is None or lease["epoch"] != epoch or lease["vm"] != vm_id:
return "REVOKED" # stop now, take new work
store.put(f"job:{job_id}:status", status)
return "OK"Pull requests, webhooks and Actions
Q1/4 Why did GitHub split RepositoryPushJob into many jobs fed by one Kafka event?
Answer & explanation
Answer: B
Fully processed pushes went from about 99.897% to at least 99.999%.
Q2/4 A receiver takes 40 seconds to answer each webhook. What happens, and what's the fix?
Answer & explanation
Answer: B
GitHub cuts the connection after 10 seconds and doesn't automatically redeliver.
Q3/4 An enterprise starts 10,000 jobs while Nandini has 24 waiting. What keeps her jobs from waiting behind all of them?
Answer & explanation
Answer: B
Fair share bounds what one tenant's burst does to everyone else.
Q4/4 In August 2026, runners got stuck retrying jobs that had already been revoked. What design prevents that?
Answer & explanation
Answer: C
Fencing with an epoch turns a stuck retry into a fast rejection.
Deep Dive 6: Searching All the Code (Week 7 Concepts: Search Fundamentals, Indexing Pipeline, Search Operations)#
"Code search breaks search engine habits: punctuation matters, no stemming, no stop words, regular expressions, and every result filtered by who may see it. But code is massively duplicated, and Git already names every file by its content."
This builds on Week 7, Day 1 · Search Fundamentals and Architecture, Week 7, Day 2 · Indexing Pipeline and Week 7, Day 5 · Search Operations and Scale.
Why not just grep?
At 11:20 someone searches all public code for parse_pin(. The index covers 115 TB of code. ripgrep scans about 0.6 GB a second per core. On a cluster of 32 machines with 64 cores each, how long does one exhaustive scan take, and how many queries a second can the cluster serve that way? What would you build instead, how would you shard it, and how do you make sure a query never returns code from a private repository the searcher can't see?
One strong answer
- Grep: 2,048 cores × 0.6 GB/s ≈ 1.2 TB/s, so 115 TB takes ~96 seconds: about 0.01 queries a second for the whole cluster.
- Index instead: an n-gram inverted index (substrings of code → sorted lists of document ids). A query becomes intersections of a few posting lists, then a check of the candidate files.
- Shard by content: use the Git blob id (the hash of the file's content) as the shard key. Identical files in a thousand forks are one document, stored once, and shards stay even because hashes are uniform.
- Permissions: rewrite every query to add "public, or one of the repositories this user can read", and check permissions again on the results before returning them.
What is published: GitHub built Blackbird "from scratch, in Rust" because general text search gave a poor experience for code, indexed slowly and cost too much; its first Elasticsearch index of about 8 million repositories "took months". At the 2023 beta it covered "almost 45 million repositories, representing 115 TB of code and 15.5 billion documents". It is sharded by Git blob id, delta-encodes similar repositories so only unique blobs are crawled, and uses Kafka so that "each shard consumes a single Kafka partition", giving results "consistent on a commit-level basis". It indexes variable-length "sparse grams" rather than plain trigrams. Queries are rewritten with permission clauses, fanned out to every shard, re-sorted and permission-checked again. Per-shard p99 is on the order of 100 ms (an upper bound of about 640 queries a second per 64-core host); ingest runs at about 120,000 documents a second, re-indexing everything in about 18 hours; 115 TB became 28 TB of unique content and a 25 TB index. On 1 April 2026 code search was fully down for 2 hours 20 minutes; GitHub reset the index to an earlier point and re-indexed, since "the search index is a secondary index derived from Git repositories".
%% title: Indexing code: from a push to every shard
flowchart TD
EV@{ icon: "tech:kafka", label: "Push events<br/>repo, new commit" }
CR["Crawlers<br/>diff against delta parent, new blobs only"]:::service
SYM["Symbol extraction<br/>definitions, references"]:::service
DOC@{ icon: "tech:kafka", label: "Documents topic<br/>partitioned by blob id" }
SH["Index shards<br/>one partition each, sparse grams"]:::core
CMP@{ icon: "sd:database", label: "Compaction<br/>merge segments, rank order" }
Q["Query service<br/>parse, add permissions, fan out"]:::hot
EV ==> CR ==> SYM ==> DOC ==> SH ==> CMP
Q ==>|"every shard"| SH
class EV,DOC queue
class CMP store- Content-addressed sharding means a fork adds only metadata (one more location for the same blob), not another document. The price: every query touches every shard, so tail latency comes from the slowest one; replicate shards and hedge slow requests.
- Consistency by commit. Because each shard consumes its Kafka partition in order, a repository's results reflect one commit or the previous one, never half of each.
- Permissions twice: in the query, so shards skip invisible documents, and on the results, so a stale permission cache can't leak a private file.
- A secondary index must be cheap to rebuild: re-index from Git at a known rate and keep point-in-time rollbacks.
- Retries need a budget, in any search system. On 12 August 2025 "GitHub search" (the report describes broken issue, pull request and project pages and filters, not code search) was degraded for nearly four hours after retry queues "eventually overwhelmed the load balancers".
# search/query.py
"""
One shard's part of a code search (illustrative): n-gram posting lists are
intersected, locations are filtered to what the searcher may see, and candidates
are verified against the content.
"""
def grams(s: str, n: int = 3) -> set:
return {s[i:i + n] for i in range(len(s) - n + 1)}
def search_shard(shard, needle: str, visible_repos: set, limit: int = 100) -> list:
postings = [shard.postings(g) for g in grams(needle)] # sorted doc ids, best first
candidates = set.intersection(*map(set, postings)) if postings else set()
hits = []
for doc in sorted(candidates): # ids are assigned by rank
locations = [loc for loc in shard.locations(doc) # (repo, path, ref) for this blob
if loc.public or loc.repo_id in visible_repos]
if locations and needle in shard.content(doc): # drop trigram false positives
hits.append((doc, locations))
if len(hits) == limit:
break
return hitsCode search
Q1/3 Grepping 115 TB on 2,048 cores at ~0.6 GB/s per core gives roughly how many queries a second?
Answer & explanation
Answer: C
115 TB ÷ 1.2 TB/s ≈ 96 s per query.
Q2/3 Why shard the code index by Git blob id rather than by repository?
Answer & explanation
Answer: B
The cost is that every query must fan out to every shard.
Q3/3 Code search was down and stale in April 2026, yet no data was lost. Why?
Answer & explanation
Answer: B
A derived index is rebuildable as long as the source of truth is intact.
Phase 5: Scaling and Edge Cases (5 minutes)#
"Walk me through Nandini's release day from the platform's side, then the cases that bite."
Nandini's Release Day#
- 10:40 · Merge queue
- Her last pull request joins the queue; a temporary
gh-readonly-queue/main/...branch withmainplus the two pull requests ahead is built and tested, then merged
- Her last pull request joins the queue; a temporary
- 11:02 · Tag push over SSH
- The proxy checks her key and write permission; the pack goes to all five replicas; the tag reference commits when a majority agree with matching checksums
- 11:03 · 24 jobs
- The release run's jobs wait in her organization's queue and start on fresh VMs from warm pools; each gets a token scoped to her repository
- 11:10 to 12:00 · The fan-out
- Thousands of downstream pipelines fetch the tag; reads go to the nearest in-sync replicas, mostly answered from reachability bitmaps
Edge Cases#
Edge case 1: A force push races a fetch
Problem: A maintainer force-pushes main back two commits while CI is fetching it.
Solution: The reference update is one vote, so a fetch sees the old tip or the new one; unreachable objects stay until garbage collection, so the fetch completes. Branch protection can forbid force pushes to main.
Edge case 2: A leaked host key
Problem: The private key that proves "this really is GitHub" for SSH is exposed.
Solution: Rotate it at once, publish the new fingerprints, and accept a wave of "host identification has changed" warnings. GitHub did exactly this for its RSA SSH host key on 24 March 2023, after it was "briefly exposed in a public GitHub repository"; ECDSA and Ed25519 users, and HTTPS, were unaffected.
Edge case 3: A receiver down for four days
Problem: A team's webhook endpoint is down from Friday to Tuesday, longer than the 3-day redelivery window.
Solution: Webhooks are a notification, not the record. Receivers reconcile on recovery by reading current state from the REST API (open pull requests, latest commits, workflow runs) and redeliver what's still in the window.
Scaling and edge cases
Q1/2 A webhook receiver was down for four days. How does it catch up?
Answer & explanation
Answer: B
Webhooks notify; the API is the record. Treat them as hints and reconcile.
Q2/2 A force push to main happens during a CI fetch. What can the fetch see?
Answer & explanation
Answer: B
A reference update is atomic, and objects aren't deleted under a running reader.
Phase 6: Monitoring and Operations#
"Four questions, all day: are pushes accepted, is every push fully processed, do CI jobs start on time for every customer, and are we a good citizen to receivers?"
Monitoring Dashboard#
Git
| Metric | Now |
|---|---|
| Pushes | 1,420/s |
| Push acknowledged, p99 | 640 ms |
| Votes not unanimous (last hour) | 3 |
| Replicas below target count | 0.002% of repos |
| Fetches | 910,000/s |
Events and webhooks
- Pushes fully processed (24 h): 99.9993%; webhook success 97.1%, breakers open on 1,206 hosts
Actions
- Jobs started: 1,610/s; queue time p50 / p95: 6 s / 41 s
- Warm VMs idle: 6.8%; runner leases refused as stale: 12/min
- Job database primary CPU: 58%; intake throttle: off
Search
- Query p99: 420 ms; index lag p95: 38 s
SLOs for the Platform#
SLO 1: Pushes land
- Target: 99.95% of pushes acknowledged within 1 s; zero acknowledged pushes lost; zero reference regressions seen by readers
SLO 2: Every push is fully processed
- Target: 99.999% of pushes have every follow-up task done (GitHub's 2024 pipeline measured at least this); p99 push-to-webhook-sent under 10 s
SLO 3: CI starts on time, for everyone
- Target: p95 queue time under 60 s for hosted jobs within an account's concurrency limit, measured per account, not just in aggregate
SLO 4: Search answers and stays fresh
- Target: 99.9% of code searches under 1 s; 95% of pushes searchable within 2 minutes
Reading the dashboard
Q1/2 Aggregate Actions queue time is fine, but one enterprise's p95 is 20 minutes and it's within its concurrency limit. What does that suggest?
Answer & explanation
Answer: B
Fairness bugs hide in averages. Measure per account and per runner label.
Q2/2 "Runner leases refused as stale" jumps from 12 a minute to 40,000. What's happening?
Answer & explanation
Answer: B
Without the epoch check, this is the August 2026 self-amplifying backlog.
Final Challenge: Two Thousand Agents on One Monorepo, During a Migration#
The interviewer leans back. "One more."
"A customer turns on a fleet of 2,000 coding agents against their monorepo. Each agent pushes to its own branch every few minutes, opens a pull request, and its CI fetches the repository each time. All of them merge through one merge queue into main. Meanwhile, you're halfway through moving this repository's replicas to a new region 25 ms away. Tell me what breaks first and what you change."
- 2,000 agents, each pushing about every 3 minutes → ~11 pushes a second to one repository (assumption)
- Each push triggers CI that fetches the repository: ~11 full fetches a second, plus test merges for every open pull request
- ~300 merges an hour into
mainthrough the merge queue (assumption) - Replicas: three in the old region, two in the new one, 25 ms apart
- The monorepo has hundreds of thousands of references and receives maintenance every 50 pushes
What do you change for the agent fleet?
Decide what happens to the write path, the read path, the merge queue, maintenance and CI.
One strong answer
- Writes: with a repository-wide lock at ~0.3 s per cross-region update, 11 pushes a second need 3.3 s of lock time every second. Per-reference serialization lets 2,000 branches move in parallel; only
mainstays serialized. Coalesce test-merge updates and put pushes first. - Reads: serve repeated CI fetches from read replicas or caches near the runners, with blobless or shallow clones; grow reads without adding voters.
- The merge queue:
mainis the hot key. Batch several pull requests per merge group, raise build concurrency, eject failures fast. - Maintenance: 50 pushes arrive in under 5 seconds, so it must be geometric, off the serving path, and retried in quiet hours.
- The migration: keep a voting majority in one region until per-reference quorums are on; add far replicas as non-voting first.
- CI: the account's cap, queue and cell keep 2,000 agents from crowding out others; size its warm pools from its own forecast.
Final challenge · Quest check
Q1/2 2,000 agents push to their own branches about 11 times a second. What change helps most on the write path?
Answer & explanation
Answer: B
Git's guarantee is per reference; only writes to the same branch need ordering.
Q2/2 Why add the far-region replicas as non-voting first?
Answer & explanation
Answer: B
GitHub Enterprise's geo-replicas were already non-voting in 2017, so that pushes keep working if they become unreachable.
Interview Conclusion#
"Why not give each repository a primary fileserver and replicate to the others asynchronously? Databases do it."
"Because losing the primary means an election, and a bad one splits history: GitHub's MySQL clusters showed that in 2018, when primaries moved across the country during a 43-second partition. Spokes makes every write its own majority vote, so there's nothing to fail over; without a majority it refuses the push, which is the right failure for source code."
"And why not retry webhooks until they succeed?"
"Because a push webhook delivered an hour late is often worse than none, and a recovering receiver can't take a day's backlog at once. Bounded attempts with a shelf life, per-host breakers, a 3-day delivery log with redelivery, and a delivery id that makes repeats harmless put the receiver in control of catching up."
"The single most important decision?"
"Splitting the push into one small thing that needs agreement, the reference update, and everything else, which only needs an event. That's what lets the storage vote stay fast, lets a webhook outage leave CI alone, and lets 124 million jobs a day run without touching the write path."
Summary: Concepts Applied from 10-Week Course#
- Week 1: Data at scale
- Five replicas, majority writes, nearest in-sync reads;
mainas a hot key; rate limits at the Git proxies
- Week 2: Failure-first design
- Webhooks with a 10-second timeout, delivery ids and per-host breakers; timeouts sized per Git operation
- Week 3: Messaging and async processing
- One Kafka event per push, isolated consumers, dead letters; back-pressure on CI intake
- Week 4: Caching
- Read replicas as caches; reachability bitmaps and cached packs for hot commits
- Week 5: Consistency and coordination
- Every write a majority vote, per-reference serialization; the 2018 cross-region failover as the counter-example
- Week 7: Search
- An n-gram index sharded by blob id, ordered ingestion, a rebuildable secondary index
- Week 9: Multi-tenancy and security
- One VM per CI job, scoped tokens, no secrets for forks, fair share, cells
- Week 10: Production readiness
- Warm pools from forecasts; per-account SLOs
Why GitHub Matters#
Most of the data is immutable
- Git objects are named by their content, so replicas, caches, forks and the search index share them freely; only references need agreement, and GitHub's 2026 design narrows that to one reference at a time
One write fans out into a city of work
- A push becomes pull request updates, webhooks, CI jobs and index updates. Keeping each one isolated, with its own retries and deadlines, is what moved fully processed pushes from about 99.897% to at least 99.999%
The workload changed under the system
- Write volume multiplied in a year as agents arrived (pushes 4.9×, Actions to 124 million jobs a day) while GitHub moved to Azure; its 2026 availability reports show the strain in the open: capacity, retries and shared databases
"Agree on the reference, broadcast the event, and run strangers' code on machines you throw away."
Self-Assessment Checklist#
After studying this case study, you should be able to:
Storage and Git traffic:
- Explain why Spokes writes to a majority of replicas and treats every write as a vote, and contrast it with single-primary failover
- Route reads to in-sync replicas, drain a server without breaking long clones, and choose clone filters for CI
Events, pull requests and webhooks:
- Split push processing into isolated consumers with their own retry rules and dead letters
- Design webhook delivery with per-host isolation, a timeout, a shelf life, signatures and idempotent receivers
Actions and search:
- Schedule CI jobs fairly across accounts with concurrency caps, warm pools and leases with epochs
- Explain an n-gram code index sharded by blob id, with permissions applied twice
Sources#
GitHub Engineering: Git storage and traffic:
- Blog
Patrick Reynolds — Introducing DGit, GitHub Blog, 5 April 2016: https://github.blog/engineering/infrastructure/introducing-dgit/
- Blog
Patrick Reynolds — Building resilience in Spokes, GitHub Blog, 7 September 2016: https://github.blog/engineering/infrastructure/building-resilience-in-spokes/
- Blog
Michael Haggerty — Stretching Spokes, GitHub Blog, 13 October 2017: https://github.blog/engineering/infrastructure/stretching-spokes/
- Blog
Scott Arbeit — Improving large monorepo performance on GitHub, GitHub Blog, 16 March 2021: https://github.blog/engineering/infrastructure/improving-large-monorepo-performance-on-github/
- Blog
Taylor Blau — Scaling monorepo maintenance, GitHub Blog, 29 April 2021: https://github.blog/engineering/infrastructure/scaling-monorepo-maintenance/
- Blog
Derrick Stolee — Get up to speed with partial clone and shallow clone, GitHub Blog, 21 December 2020: https://github.blog/open-source/git/get-up-to-speed-with-partial-clone-and-shallow-clone/
- Blog
Theo Julienne — GLB: GitHub's open source load balancer, GitHub Blog, 8 August 2018: https://github.blog/engineering/infrastructure/glb-director-open-source-load-balancer/
- Blog
Brian Celenza — Building Git infrastructure for agent-scale development, GitHub Blog, 6 October 2026: https://github.blog/engineering/architecture-optimization/building-git-infrastructure-for-agent-scale-development/
- Blog
Eleanor White and Victoria Dye — The bridge to agent-scale Git infrastructure, GitHub Blog, 21 October 2026: https://github.blog/engineering/architecture-optimization/the-bridge-to-agent-scale-git-infrastructure/
- Blog
Mike Hanley — We updated our RSA SSH host key, GitHub Blog, March 2023: https://github.blog/news-insights/company-news/we-updated-our-rsa-ssh-host-key/
GitHub Engineering: events, pull requests, metadata:
- Blog
Will Haltom — How we improved push processing on GitHub, GitHub Blog, 11 June 2024: https://github.blog/engineering/architecture-optimization/how-we-improved-push-processing-on-github/
- Blog
GitHub — How GitHub uses merge queue to ship hundreds of changes every day, GitHub Blog, 6 March 2024: https://github.blog/engineering/engineering-principles/how-github-uses-merge-queue-to-ship-hundreds-of-changes-every-day/
- Blog
Thomas Maurer — Partitioning GitHub's relational databases to handle scale, GitHub Blog, 27 September 2021: https://github.blog/engineering/infrastructure/partitioning-githubs-relational-databases-scale/
- Blog
GitHub — Upgrading GitHub.com to MySQL 8.0, GitHub Blog, 7 December 2023: https://github.blog/engineering/infrastructure/upgrading-github-com-to-mysql-8-0/
- Blog
Jason Warner — October 21 post-incident analysis, GitHub Blog, October 2018: https://github.blog/news-insights/company-news/oct21-post-incident-analysis/
GitHub Actions:
- Talk
Ben De St Paer-Gotch — Let's talk about GitHub Actions, GitHub Blog, 11 December 2025: https://github.blog/news-insights/product-news/lets-talk-about-github-actions/
- Blog
Aiqiao Yan — How we tripled max concurrent jobs to boost performance of GitHub Actions, GitHub Blog, 16 September 2022: https://github.blog/engineering/architecture-optimization/how-we-tripled-max-concurrent-jobs-to-boost-performance-of-github-actions/
- Docs
GitHub Docs — GitHub-hosted runners: https://docs.github.com/en/actions/concepts/runners/github-hosted-runners
- Docs
GitHub Docs — Secure use reference: https://docs.github.com/en/actions/reference/security/secure-use
- Docs
GitHub Docs — Actions limits: https://docs.github.com/en/actions/reference/limits
- Docs
GitHub Docs — Events that trigger workflows: https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows
- Docs
GitHub Docs — Approving workflow runs from forks: https://docs.github.com/en/actions/how-tos/manage-workflow-runs/approve-runs-from-forks
- Docs
GitHub Docs — Actions runner pricing: https://docs.github.com/en/billing/reference/actions-runner-pricing
Webhooks and merge queue:
- Docs
GitHub Docs — Best practices for using webhooks: https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks
- Docs
GitHub Docs — Handling failed webhook deliveries and Redelivering webhooks: https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries
- Docs
GitHub Docs — Webhook events and payloads: https://docs.github.com/en/webhooks/webhook-events-and-payloads
- Docs
GitHub Docs — Managing a merge queue: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue
Code search:
- Blog
Timothy Clem — The technology behind GitHub's new code search, GitHub Blog, 6 February 2023: https://github.blog/engineering/architecture-optimization/the-technology-behind-githubs-new-code-search/
Octoverse and availability reports:
- Blog
GitHub — Octoverse: Commits, pushes, reach new highs in 2026 as developers build with agents, 28 October 2026: https://github.blog/news-insights/octoverse/octoverse-commits-pushes-reach-new-highs-in-2026-as-developers-build-with-agents/
- Blog
GitHub — Octoverse 2025: A new developer joins GitHub every second, 28 October 2025: https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/
- Blog
Jakub Oleksy — GitHub availability report: September 2026, 14 October 2026: https://github.blog/news-insights/company-news/github-availability-report-september-2026/
- Blog
Jakub Oleksy — GitHub availability report: August 2026, 9 September 2026: https://github.blog/news-insights/company-news/github-availability-report-august-2026/
- Blog
GitHub availability report: April 2026, 14 May 2026: https://github.blog/news-insights/company-news/github-availability-report-april-2026/
- Blog
GitHub availability report: February 2026, 11 March 2026: https://github.blog/news-insights/company-news/github-availability-report-february-2026/
- Blog
GitHub availability report: August 2025, 11 September 2025: https://github.blog/news-insights/company-news/github-availability-report-august-2025/
Design note: the architecture, code, thresholds, dashboards and every number labelled as an assumption or estimate are an illustrative design for the problem, not a description of GitHub's internal systems beyond what GitHub has published. The peak multipliers, average repository size, average job length, VM boot time, webhook event and delivery counts, the scheduler, the pack cache, the webhook queueing design and the final challenge's figures are assumptions. Nandini, her library and her co-maintainer are fictional.
Further Reading#
Related systems to study:
- Book
AWS S3 (Bonus Problem 8 in this series): durability by copies and erasure coding, for objects that never change
- Read
Google Search (Bonus Problem 5): an inverted index over the web, sharded and served in milliseconds
- Read
Razorpay (Bonus Problem 18): webhooks to merchants, signatures and idempotent receivers from the payments side
- Read
ChatGPT (Bonus Problem 14): another fleet of expensive machines shared fairly between tenants
War story:
- Read
A 43-second network blip gave GitHub 24 hours of trouble: cross-region failover of MySQL primaries, and why webhooks were paused
Foundational concepts (this course):
- Read
Week 1, Day 2 — Replication Trade-offs
- Read
Week 1, Day 4 — Hot Keys and Skew
- Read
Week 2, Day 2 — Idempotency in Practice
- Read
Week 2, Day 3 — Circuit Breakers
- Read
Week 2, Day 4 — Webhook Delivery System
- Read
Week 3, Day 1 — Queue vs Stream
- Read
Week 3, Day 4 — Dead Letters and Poison Pills
- Read
Week 4, Day 5 — Multi-Tier Caching
- Read
Week 5, Day 5 — Leader Election
- Read
Week 7, Day 2 — Indexing Pipeline
- Read
Week 9, Day 1 — Tenant Isolation
- Read
Week 9, Day 2 — Noisy Neighbor Prevention
- Read
Week 10, Day 4 — Capacity Planning
"Agree on the reference, broadcast the event, and run strangers' code on machines you throw away."
this lesson in shorts · 4
play them all →One idea from this lesson in about a minute each, made for your phone.
- 0/7stages cleared
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.



