Design Continuous Integration System: System Design & Architecture

Build a CI platform: a developer pushes code, and within seconds a pipeline of builds, tests, and checks runs across a fleet of isolated runners - in parallel where the pipeline allows, skipped where caching proves nothing changed - and reports pass or fail back to the pull request while the developer waits.

Scope

In Scope

  • Triggering: webhooks from code hosts (GitHub/GitLab) start pipelines defined by config files versioned in the repo itself
  • Pipeline execution: jobs with dependencies form a DAG - independent jobs run in parallel, dependents wait
  • Isolated runners: every job in a fresh container with its requested image and resources
  • Caching: dependencies and build outputs keyed by the content that produced them, so unchanged work is skipped
  • Test execution with parsed reports, live log streaming, and durable log retention
  • Status reporting: pass/fail checks posted back to the PR, and retries (automatic for infrastructure failures, manual re-runs for everything else)

Out of Scope

  • Deployment: this system ends at "a validated artifact exists" - promoting it to environments is Design a CD System, and the artifact handoff is the explicit boundary between the two
  • Hosting the git repositories: we integrate with code hosts, we are not one
  • Security scanning marketplaces (SAST/dependency audit vendors): pipelines can run them as jobs; the integrations are not our design
  • A flaky-test analytics product: we keep the retry hooks and history it would need
  • Billing, usage quotas, and self-hosted runner management (the design assumes our hosted fleet)

💡 What is a pipeline, and why is it a DAG?

A pipeline is the recipe a repo declares for validating a change: a set of jobs (build the code, run unit tests, run the linter, run integration tests, package the artifact), each running in its own container, with dependencies between them - integration tests need the build's output; the linter needs nothing but the source. Those dependencies make the pipeline a DAG - a directed acyclic graph. Directed: arrows point from a job to the jobs that need it. Acyclic: no loops, because "A needs B needs A" can never start. The shape is the whole point: everything not connected by an arrow can run at the same time. A pipeline of build → [unit tests, lint in parallel] → integration tests finishes in the time of its longest path (the critical path), not the sum of all jobs - a 25-minute pile of jobs can finish in 12. The two levers this design obsesses over both come from that observation: parallelism shrinks the wall-clock toward the critical path, and caching removes jobs from the path entirely when their inputs did not change. Everything else - queues, runners, schedulers - is machinery in service of those two levers.

Functional Requirements

  • A push or pull request automatically triggers the pipeline defined by the config file in that repo, at that commit
  • Jobs run respecting their declared dependencies, in parallel wherever the DAG allows
  • Every job runs in a clean, isolated container with its requested image; artifacts pass between dependent jobs
  • Dependency and build caches are reused across builds when their inputs are unchanged
  • Developers can watch logs stream live, and browse parsed test reports after
  • The PR shows pass/fail per check and can block merging on failure
  • Infrastructure failures retry automatically; developers can re-run failed jobs manually
  • Build artifacts (binaries, coverage reports) are stored and downloadable

Non-functional Requirements

  • Start latency: p95 < 30 seconds from push to first job running - a human is actively waiting
  • Throughput: ~500K builds/day, with ~50K builds running concurrently at peak (derived in the estimates, not asserted)
  • Cache effectiveness: > 80% hit rate for unchanged dependencies; a fully-cached step completes in seconds, not minutes
  • Isolation: jobs never see other customers' code, caches, or secrets - this is a multi-tenant platform running semi-trusted code
  • Reliability: an infrastructure failure never permanently fails a build (classified retries); results and logs are durable for 90 days
  • Logs: visible live within 1-2 seconds of being written

💡 Why start latency gets a hard budget: CI latency is a tax on the whole organization

Most systems in this practice set serve users who glance and move on. CI serves a developer who is synchronously waiting - they pushed, and until the checks go green they cannot merge, and often cannot mentally move on. Do the tax math: a developer triggers ~20 builds a day. If the platform wastes one minute per build - slow queue, cold runner, un-cached install - that is 20 minutes per developer per day, times every developer in every customer org. A CI platform's latency is multiplied by everyone's merge frequency, which is why "p95 < 30s to first job" is a hard budget and not a nice-to-have, and why the two big levers (parallelism and caching) are worth real engineering: they buy back human minutes at organizational scale. The budget also decomposes usefully, and each term gets its own fix: webhook processing (milliseconds), fetching config at the commit (a second, cached), compiling the DAG (milliseconds), waiting for a runner (THE variable term - bounded by pre-warmed pools and autoscaling), container start (bounded by pre-pulled images). When an interviewer asks where the 30 seconds goes, walking that decomposition is the answer.

Back-of-the-Envelope Estimates

  • Assume ~1M repositories, ~500K builds/day, average pipeline ~15 minutes with ~8 jobs
  • A write is a triggered build, a job state change, a cache upload, or a log line; a read is a status page, a log tail, a cache download
  • Arrival rate: 500K builds/day ≈ 6/sec average - but builds follow working hours across time zones, so sustained business-hours rate is ~2x and the 9am peak is ~10x → ~60 builds starting/sec at peak
  • Concurrency via Little's Law (concurrency = arrival rate x duration): 60 builds/sec x ~900 seconds ≈ ~54K builds in flight at peak - the "50K concurrent builds" figure is derived, not asserted
  • Job compute: 500K builds x 8 jobs x ~5 min average ≈ 33M job-minutes/day ≈ ~23K jobs running at any average moment, ~70K at peak → a few hundred thousand vCPUs of burst fleet. Like Design CodeSignal, the load is CPU-seconds, not queries
  • Cache storage: ~2GB of caches per active repo x ~500K active ≈ ~1PB of content-addressed cache in object storage, LRU-evicted - and eviction is always safe, because the worst case is re-doing the work
  • Logs: ~10MB per build x 500K ≈ 5TB/day, retained 90 days ≈ ~450TB rolling in object storage
  • The database by contrast is tiny: build and job state rows at ~500K builds/day is single-digit writes/sec - PostgreSQL without ceremony, exactly like Design CodeSignal

💡 What the estimates mean: cost in CPU-seconds, value in developer-minutes

The numbers put this system in the same family as Design CodeSignal - a compute platform, not a data platform. The database tier is deliberately boring (PostgreSQL holding build/job state at trivial rates); the engineering lives in the pipeline between "push" and "green check." Two derived numbers shape everything. First, Little's Law sizes the fleet: concurrency equals arrival rate times duration, so ~60 builds/sec peaking at ~15 minutes each means ~50K concurrent builds and ~70K concurrent jobs - which is why the runner fleet autoscales on queue depth and why the daily business-hours curve (known in advance, like CodeSignal's hiring seasons) justifies pre-provisioning. Second, the same law shows the leverage of the two big levers. Cut average build duration from 15 minutes to 8 - via DAG parallelism and cache hits - and concurrency (and therefore fleet cost) nearly halves AT THE SAME TIME as every developer waits half as long. Speed and cost point the same direction here, which is rare and worth saying in an interview: caching is not an optimization garnish, it is simultaneously the biggest cost lever and the biggest experience lever in the system. Storage splits by temperature like everything in this set: hot little state rows in PostgreSQL; a petabyte of content-addressed cache and half a petabyte of logs in object storage, both safely evictable/expirable because neither is the truth about anything - the repo is the truth, and every byte we hold can be regenerated from it.

High-level Architecture

  • Webhook Service: receives push/PR events from code hosts, verifies signatures (HMAC), dedupes redeliveries, and creates the build record
  • Pipeline Compiler: fetches the config file from the repo AT THAT COMMIT, validates it (unknown dependencies, cycles), and compiles it into a DAG - failing fast with a clear error before any compute is spent
  • Scheduler: a state machine over the DAG - enqueues jobs whose dependencies have all succeeded, reacts to completion events, marks downstream jobs skipped on failure, and drives retries. Stateless workers over durable state in PostgreSQL
  • Job Queues (per resource class): small/large/GPU queues with per-org fairness, feeding the fleet
  • Runner Fleet (autoscaling): fresh container per job, pre-warmed pools and pre-pulled images to protect the start-latency budget - the Design CodeSignal skeleton with the trust dial adjusted
  • Cache Service: content-addressed storage in object storage - cache keys are hashes of the inputs (lockfiles, source), so a hit is proof the work is identical
  • Artifact Store: job outputs passed to dependent jobs and retained for download
  • Log Pipeline: runners stream lines to live WebSocket tails and append to object storage for retention
  • Status Reporter: posts per-check pass/fail to the code host's checks API - the green tick and the merge block

Data/User Flow Diagram

TRIGGER
[Dev pushes] -> [GitHub / GitLab] --webhook--> [Webhook Service]
                                                | verify HMAC, dedupe
                                                v
                                       [Pipeline Compiler]
                                        fetch .ci.yml at that commit
                                        validate -> compile to DAG
                                                |
                                                v
                                          [Scheduler]
              the DAG:   [build] ---> [unit tests] ---+
                             |                        +--> [integration]
                             +------> [lint] ---------+
              enqueue every job whose dependencies are all green
                                                |
                                                v
                              [Job Queues (per resource class,
                               per-org fairness)]
                                                |
                                                v
                              [Runner Fleet (autoscaling)]
                               per job: fresh container (pre-warmed)
                               restore cache by content key
                               run steps -> upload artifacts
                                   |               |
                             [Cache Store]   [Artifact Store]
                             (content-        (outputs flow to
                              addressed)       dependent jobs)
                                   |
              logs stream --> [Log Pipeline] --> live tail + 90d retention
                                                |
                                                v
                                    [Status Reporter] --> PR checks
                                     (pass / fail, blocks merge)

💡 What does the scheduler actually do? (It is smaller than it sounds)

Strip the word "orchestration" down and the scheduler is a state machine over job rows plus one loop. Every job moves through: pending (dependencies not yet met) → ready (all dependencies succeeded) → queued → running → succeeded / failed / skipped. The loop is: whenever any job finishes, re-evaluate - which pending jobs now have every dependency green? Enqueue exactly those. That single rule produces all the behavior: fan-out (build succeeds → unit tests AND lint both become ready and run in parallel) and fan-in (integration stays pending until BOTH unit tests and lint are green) fall out for free from "all dependencies satisfied." Failure is the same rule inverted: a failed job marks everything downstream of it skipped (there is no point testing a build that did not compile), and the build's final status aggregates the leaves. The important operational choice: the job states live in PostgreSQL, and scheduler workers are stateless processors of completion events. Any scheduler instance can handle any event, so the "brain" of the system has no memory of its own to lose - restart it, scale it, kill one mid-event; the truth is in the database and the loop resumes. The same truth-versus-worker split as everywhere else in this set, applied to the control plane itself.

Data Model & Storage

  • Builds (PostgreSQL): repo, commit SHA, branch, trigger (push/PR/manual), status, timing. One row per pipeline run - the fact that it happened and how it ended.
  • Jobs (PostgreSQL): per build - name, dependency list, state (pending/ready/queued/running/succeeded/failed/skipped), runner id, exit code, timings, retry count. The scheduler's state machine is these rows.
  • Test results (PostgreSQL): parsed summaries per job (passed/failed/skipped counts) with failure details for the report UI.
  • Cache index (PostgreSQL or Redis): cache key (a content hash) → object-storage location, size, last-hit time. The blobs themselves live in object storage; LRU eviction works off last-hit.
  • Artifacts + Logs (Object Storage): job outputs and streamed logs, retention-managed by lifecycle rules (the Design Instagram cleanup delegation).
  • Job Queues (Redis Streams, consumer groups per resource class): delivery, not truth - lost queues re-enqueue from jobs still marked ready, exactly the Design CodeSignal arrangement.
  • Pipeline config: deliberately NOT stored by us - read from the repo at the commit being built, every time. See the note below.

💡 Why the pipeline config lives in the repo, not in our database

It would be easy to store each repo's pipeline configuration in our own database with a settings UI. Every serious CI system refuses, and the reasons compound. Versioned together: the pipeline is code that builds code, and they change together. A PR that adds a dependency AND updates the install step is one atomic change - reviewable in one diff, revertible in one revert. With config in our database, every such change becomes two changes in two systems that can disagree. History stays honest: rebuilding an old commit uses the config as it was AT that commit, so last month's release rebuilds the way it built last month. A database holds only "the current config," which silently rewrites history. Branches get experiments for free: a branch can overhaul its pipeline without touching anyone else's builds, because the config rides the branch. The division of truth this creates is clean: git owns WHAT TO DO (the recipe, versioned with the code), our database owns WHAT HAPPENED (builds and jobs as immutable facts). The compiler bridges them by fetching the config at the exact SHA being built. This is the same "pin the data to its schema version" discipline as Design SurveyMonkey's survey versioning - the recipe that produced a result must remain attached to the result.

API Design

POST /v1/webhooks/:provider
Inbound push/PR events; HMAC-verified, deduplicated by delivery id.
POST /v1/builds
Manual trigger or full re-run for a repo + commit.
GET /v1/builds/:id
Build status with the live DAG state of every job.
GET /v1/builds/:id/jobs/:jobId/logs
Log tail; WS variant streams live.
POST /v1/builds/:id/retry
Re-run failed jobs only (successful jobs and their artifacts are reused).
GET /v1/builds/:id/artifacts
List and download build outputs (signed URLs).
GET /v1/repos/:id/builds?cursor=...
Build history for a repo, paginated.

Low-level Design

Trigger to first job - spending the 30-second budget (step by step):

  • 1) The webhook arrives; verify the HMAC signature first (an unverified endpoint would let anyone trigger builds - see security), then dedupe by the provider's delivery id, since code hosts redeliver on timeouts: same retry-plus-dedupe-by-id invariant as Design WhatsApp, applied to inbound events.
  • 2) Create the build row; fetch the pipeline config at that exact commit (shallow fetch, cached by SHA).
  • 3) Compile: validate references and reject cycles NOW - a broken pipeline definition should fail in milliseconds with a pointed error, not after provisioning compute.
  • 4) Mark root jobs (no dependencies) ready and enqueue them.
  • 5) A pre-warmed runner picks up within seconds - runner acquisition is the budget's variable term, which is exactly why the pools exist (the Design CodeSignal pre-warm move).
  • Every step is timed and budgeted; when start latency regresses, this decomposition is how you find the term that grew.

The scheduler loop - executing the DAG (the genuinely tricky part #1):

  • The entire orchestrator is one rule applied on every job-completion event: find pending jobs whose dependencies are now ALL succeeded, and enqueue them.
  • Fan-out and fan-in are free consequences: build finishing makes unit-tests and lint both ready (parallel); integration waits until both report green.
  • Failure inverts the rule: mark everything downstream skipped, fail the build at the end - unless the job is marked allowed-to-fail (a lint warning should not block a release).
  • Artifacts flow along the DAG edges: a job declares outputs, the runner uploads them, and dependent jobs download them into their workspace before starting - how the compiled build reaches the integration tests.
  • Wall-clock time equals the critical path, so pipeline authors are effectively optimizing a graph: the practical advice the platform should surface is "make it wide, not deep."
  • Scheduler workers are stateless over PostgreSQL job rows - any instance handles any event, so the control plane restarts without amnesia.

Content-addressed caching - deleting work instead of doing it faster (the genuinely tricky part #2, step by step):

  • 1) A job declares what it caches and what the cache depends on: key = "deps-" + hash(package-lock.json), path = node_modules.
  • 2) Runner start: compute the key, ask the cache index. Exact hit → download and mount the blob, and npm install becomes a no-op: the 10-minute install is now a 20-second download. Miss → do the install, upload the result under the key for everyone after you.
  • 3) The property doing all the work: the key IS a hash of the inputs, so a hit is cryptographic proof the cached content is what this job would have produced. There is no invalidation logic anywhere - change the lockfile and you simply have a different key; the old entry is not "stale," it is just unused, and LRU eviction collects it eventually. Content-addressing makes cache invalidation - famously one of the two hard problems - not solved but structurally absent.
  • 4) This is Design Dropbox's chunk-hash dedup wearing work clothes: name data by the hash of what produced it, and equality checks disappear.
  • 5) Pragmatics: restore-keys allow prefix fallback (no exact hit → take the most recent "deps-" entry and run an incremental install on top - most of the win, none of the risk, since the install step reconciles); Docker layer caching applies the identical idea to image builds.
  • 6) Eviction is always safe: the worst case of losing any cache entry is re-doing work, never wrong results - which is what makes a petabyte of cache operationally calm.

Runners - the CodeSignal skeleton with the trust dial moved (step by step):

  • The skeleton is identical to Design CodeSignal and deliberately not re-derived here: queue → consumer group → fresh container per job → run → report → destroy, with leases reclaiming jobs from dead runners.
  • What changes is trust, in two levels. Within a tenant, code is semi-trusted (a customer's own engineers), so jobs GET what CodeSignal forbids: network access (npm install needs the internet) and mounted caches (the whole point) - conveniences justified because the code's authors are accountable to the account paying for it.
  • Between tenants, isolation is CodeSignal-grade and absolute: separate runtime boundaries, caches namespaced per repo, secrets scoped per project - customer A's job must never smell customer B's existence.
  • The sharp edge is fork PRs: a pull request from a stranger's fork is UNTRUSTED code that runs when maintainers' CI triggers - so fork builds run with no secrets and restricted permissions. The classic attack is a one-line PR editing the pipeline to print the deploy keys; the defense is that fork builds never receive them.
  • Secrets themselves: injected as environment variables at job start from a secrets manager, pattern-masked in log output, and never written into caches or artifacts.

Logs, results, status, and retries - closing the loop (step by step):

  • 1) Runners stream log lines to the log pipeline: fan to live WebSocket tails (the developer watching their build) and append to object storage for the 90-day record.
  • 2) Test reports (JUnit XML and friends) are parsed into structured results, so the UI leads with "3 failures in auth_test.py" instead of 40MB of scroll.
  • 3) The Status Reporter posts each check's pass/fail to the code host's checks API - the green tick, and the merge block on red.
  • 4) Retries are classified, and the classification is the design: infrastructure failures (runner died, image pull timeout, network blip) auto-retry - they are our fault, and leases plus idempotent state transitions make re-running safe. Test failures do NOT auto-retry by default: silently retrying a failing test masks flakiness and teaches the org that red sometimes means "try again," which destroys the signal CI exists to produce.
  • 5) Instead, flakiness is surfaced: a job that fails then passes on manual re-run of the same commit is flagged in history - data for the (out-of-scope) flaky-test tooling, honesty in the meantime.

Database Types and Schemas

The pipeline config (lives in the REPO, read at the commit being built)

# .ci.yml
jobs:
  build:
    image: node:20
    steps: [npm ci, npm run build]
    cache:
      key: deps-{{ hash("package-lock.json") }}   # content-addressed
      path: node_modules
    artifacts: [dist/]
  lint:
    image: node:20
    steps: [npm run lint]
    allowed_failure: true          # red lint warns, does not block
  unit_tests:
    needs: [build]                 # DAG edges
    image: node:20
    steps: [npm test -- --reporter junit]
  integration:
    needs: [unit_tests, lint]      # fan-in: waits for BOTH
    image: node:20
    services: [postgres:16]
    steps: [npm run test:integration]

Database Schemas (PostgreSQL - the estimates say it is enough)

- builds
  build_id UUID PRIMARY KEY,
  repo_id UUID NOT NULL,
  commit_sha CHAR(40) NOT NULL,
  branch VARCHAR(255),
  trigger trigger_enum,            -- push | pull_request | manual
  status build_status_enum,        -- running | succeeded | failed | canceled
  created_at TIMESTAMP DEFAULT NOW(),
  started_at TIMESTAMP, finished_at TIMESTAMP,
  INDEX (repo_id, created_at DESC)

- jobs  (the scheduler's state machine IS this table)
  job_id UUID PRIMARY KEY,
  build_id UUID NOT NULL,
  name VARCHAR(255) NOT NULL,
  needs TEXT[],                    -- dependency names within the build
  state job_state_enum,            -- pending|ready|queued|running|
                                   -- succeeded|failed|skipped
  allowed_failure BOOLEAN DEFAULT FALSE,
  runner_id VARCHAR(100), exit_code INT,
  retry_count INT DEFAULT 0,
  queued_at TIMESTAMP, started_at TIMESTAMP, finished_at TIMESTAMP,
  logs_ref VARCHAR(512),
  INDEX (build_id), INDEX (state, queued_at)

- test_results
  job_id UUID, suite VARCHAR(255),
  passed INT, failed INT, skipped INT,
  failures JSONB                   -- [{test, message, trace_ref}]

- cache_index
  cache_key VARCHAR(128) PRIMARY KEY,   -- "deps-sha256:..." content hash
  repo_id UUID NOT NULL,                -- caches are namespaced per repo
  storage_ref VARCHAR(512), size_bytes BIGINT,
  last_hit_at TIMESTAMP,                -- LRU eviction reads this
  INDEX (repo_id), INDEX (last_hit_at)

Queues & storage
- Job queues: Redis Streams, one per resource class (small/large/GPU),
  consumer groups + leases; delivery not truth - jobs still 'ready'
  in PostgreSQL re-enqueue if a stream is lost (per Design CodeSignal)
- Object storage: /cache/{repo}/{key}, /artifacts/{build}/{job}/...,
  /logs/{build}/{job}.log  -- lifecycle rules expire logs at 90 days

API Request/Response Payloads

POST /v1/webhooks/github (inbound, abridged)
Request
{
  "delivery_id": "gh_72dfe1a0",
  "event": "push",
  "repository": "acme/backend-api",
  "commit_sha": "a1b2c3d4e5f6...",
  "branch": "feature/payment-retry",
  "pusher": "dev@acme.com"
}
Response
{
  "build_id": "b_8f3e2d1c",
  "status": "created"
}

// The HMAC signature on the raw payload was verified BEFORE parsing -
// an unverified webhook endpoint would let anyone trigger builds of
// arbitrary commits. delivery_id dedupes provider redeliveries: same
// id, same build, no duplicate pipeline.
GET /v1/builds/:id (the DAG, live)
Response
{
  "build_id": "b_8f3e2d1c",
  "repo": "acme/backend-api",
  "commit_sha": "a1b2c3d4",
  "status": "running",
  "jobs": [
    { "name": "build", "state": "succeeded", "duration_seconds": 141,
      "cache": { "key": "deps-sha256:7d86...", "hit": true,
                 "restored_in_seconds": 18 } },
    { "name": "lint", "state": "succeeded", "allowed_failure": true,
      "duration_seconds": 44 },
    { "name": "unit_tests", "state": "running", "started_seconds_ago": 95 },
    { "name": "integration", "state": "pending",
      "waiting_on": ["unit_tests"] }
  ],
  "critical_path": ["build", "unit_tests", "integration"]
}

// The cache hit tells the story: npm ci would have taken ~10 minutes;
// restoring node_modules by content key took 18 seconds. lint ran in
// parallel with unit_tests (fan-out); integration waits for both
// (fan-in) - and since lint is done, it waits only on unit_tests.
WS log tail (what the waiting developer sees)
Response
{ "job": "unit_tests", "line": 812,
  "ts": "2026-07-06T18:04:11.312Z",
  "text": "PASS  src/payments/retry.test.ts (4.2s)" }

{ "job": "unit_tests", "line": 813,
  "ts": "2026-07-06T18:04:12.008Z",
  "text": "Tests: 214 passed, 214 total" }

// Streamed live within 1-2s of being written; the same lines append to
// object storage for the 90-day record. Secret values are pattern-masked
// before the stream ever leaves the runner.
POST /v1/builds/:id/retry
Request
{
  "failed_only": true
}
Response
{
  "build_id": "b_8f3e2d1c",
  "retried_jobs": ["integration"],
  "reused_jobs": ["build", "lint", "unit_tests"],
  "note": "Successful jobs and their artifacts are reused; only failed jobs re-run."
}

// Infrastructure failures never need this - they auto-retried. This
// button exists for code-shaped failures, and a pass-on-retry at the
// same commit gets flagged as a flake candidate rather than hidden.
Outbound: status posted to the code host
Response
POST /repos/acme/backend-api/statuses/a1b2c3d4
{
  "context": "ci/integration",
  "state": "success",
  "description": "214 tests passed in 6m 12s",
  "target_url": "https://ci.example.com/builds/b_8f3e2d1c"
}

// One status per check, per commit - this is the green tick, and with
// branch protection enabled it is also the merge gate. The whole
// platform's output, reduced to its essence: a boolean with a URL.

💡 Three terms from the flows: critical path, workspace passing, restore keys

Critical path: the longest dependency chain through the DAG, and therefore the minimum possible wall-clock time for the build no matter how many runners you own. Adding parallelism off the critical path is free speed; shortening the path itself (caching a step on it, splitting a long job) is the only way to beat it. Pipeline optimization is graph optimization, and the platform should show authors their path. Workspace passing: how outputs travel along DAG edges. A job declares artifacts (dist/, a compiled binary); the runner uploads them at job end, and each dependent job downloads them into its workspace before starting. This is what makes "test the thing build produced" literal - the integration job tests the exact bytes, not a re-build. Restore keys: the pragmatic fallback when an exact cache key misses. The lockfile changed, so "deps-{new hash}" has no entry - but the most recent entry under the "deps-" prefix is probably 95% right, so restore it and let the install step reconcile the difference incrementally. Exact hits skip work entirely; prefix hits shrink it. Both are safe for the same reason all of this is safe: the final install step is authoritative, and the cache only ever changes where work starts, never what is correct.

Concluding Discussion

Potential Bottlenecks

  • Issue: The 9am wave - builds follow working hours, and Little's Law turns a 10x arrival spike into ~50K concurrent builds.
    Mitigation: autoscale runners on queue depth, pre-provision for the known daily curve (the calendar is public, cheat with it - the Design CodeSignal move), and per-org concurrency caps so one monorepo's storm queues against itself.
  • Issue: Cache storage grows without bound - every lockfile change mints a new content key, and nothing is ever "invalidated."
    Mitigation: LRU eviction on last-hit plus per-repo size caps, which is safe by construction: evicting any entry costs a re-install, never a wrong result.
  • Issue: Container image pulls dominate job start when a job requests an uncommon image.
    Mitigation: pre-pull the popular images onto every node, and run a pull-through registry mirror per zone so even a cold image fetches from next door - protecting the start-latency budget's worst term.

Single Points of Failure

  • The scheduler: it is the brain, but a deliberately stateless one - job states live in PostgreSQL and any scheduler worker can process any completion event, so instances restart or die without amnesia. The real single point is the database beneath it: hot standby, automatic failover.
  • The job queues: delivery, not truth (the Design CodeSignal arrangement) - a lost stream re-enqueues every job still marked ready in PostgreSQL. Replicated Redis makes this a minutes-of-delay event, never lost work.
  • The code host (GitHub/GitLab): an external dependency we cannot failover - no webhooks, no config fetches, no status posts during their outage. Mitigation is honest degradation: queue outbound status updates for replay, reconcile missed webhooks by API polling on recovery, and surface "code host unreachable" rather than pretending builds can start.

Security Improvements

  • Verify webhook HMAC signatures before anything else: an unverified endpoint lets an attacker trigger builds of arbitrary commits - with your customers' secrets in the environment.
  • Fork PRs are untrusted code that runs on maintainers' CI: they execute with no secrets and restricted permissions, because the classic one-line attack is a PR that edits the pipeline to print the deploy keys.
  • Secrets are injected at job start from a secrets manager, scoped per project, pattern-masked in logs, and never written to caches or artifacts - the places people forget they persist.
  • Tenant isolation is CodeSignal-grade between customers even though code is semi-trusted within one: caches namespaced per repo, runtime boundaries between tenants, and cross-tenant probes indistinguishable from nonexistence.

Critical Knowledge

If the interviewer asks "how does the scheduler decide what runs next?":

  • Deflate it first: the orchestrator is a state machine over job rows plus one rule - on every completion event, enqueue each pending job whose dependencies are now ALL succeeded.
  • Fan-out and fan-in are free consequences of that rule, not features: build finishing readies unit-tests and lint together; integration waits until both are green.
  • Failure inverts it: downstream jobs are marked skipped (never run a test against a build that did not compile), with allowed-failure jobs as the escape hatch.
  • The operational move that makes it robust: state in PostgreSQL, schedulers stateless and interchangeable - the control plane can die mid-event and resume, because the truth was never in its memory.
  • Close with the author-facing consequence: wall-clock = critical path, so pipelines should be wide, not deep.

If the interviewer asks "how does caching turn a 10-minute build into 2?":

  • The mechanism: cache keys are hashes of the inputs - key = hash(package-lock.json) - so an exact hit is cryptographic proof the cached node_modules is byte-identical to what the install would produce; download it in seconds and skip the work entirely.
  • The deep property: there is NO invalidation logic. A changed lockfile is a different key; the old entry is not stale, just unused, and LRU eviction collects it. Content-addressing makes cache invalidation structurally absent, not cleverly solved.
  • Name the lineage: this is Design Dropbox's chunk-hash dedup - name data by its content and equality becomes trivial - applied to build outputs.
  • Add the pragmatics: restore-keys give prefix fallback (last week's cache + incremental install = most of the win), and eviction is always safe because the worst case is re-doing work, never wrong results.

If the interviewer asks "is this the same sandbox as the code-assessment platform?":

  • Same skeleton, different trust dial - and saying that crisply is the answer. The skeleton (queue → consumer group → fresh container per job → destroy, leases reclaiming from dead runners) is Design CodeSignal verbatim; do not re-derive it.
  • Within a tenant, CI code is semi-trusted (the customer's own engineers), so jobs get network access and mounted caches - things CodeSignal absolutely forbids - because CI is useless without them and the code's authors are accountable.
  • Between tenants, isolation snaps back to absolute: namespaced caches, scoped secrets, hard runtime boundaries.
  • The sharp edge that impresses interviewers: fork PRs are strangers' code triggered on your CI - they run with NO secrets, because the classic attack is a one-line pipeline edit that prints the deploy keys. Trust is not one dial; it is per-relationship.

If the interviewer asks "when do you auto-retry a failed job?":

  • Classify first, because the classification IS the policy: infrastructure failures (runner died, image pull timeout, network blip) auto-retry - they are the platform's fault, and leases plus idempotent state transitions make re-runs safe (at-least-once + idempotent receipt, the Design WhatsApp invariant on compute jobs).
  • Test failures do NOT auto-retry by default: a platform that silently re-rolls red dice teaches the org that red means "try again," which destroys the entire signal CI exists to produce.
  • Instead surface flakiness: pass-on-manual-retry at the same commit gets flagged in history - data, not denial.
  • The principle: retries are for restoring the machine's promises, never for editing the code's answers.

If the interviewer asks "why such an aggressive start-latency budget, and how do you size the fleet?":

  • Latency: a developer is synchronously blocked on every build - 20 builds/dev/day times one wasted minute times the whole org is real payroll, so CI latency is a tax multiplied by merge frequency. The 30s budget decomposes (webhook ms → config fetch s → compile ms → QUEUE, the variable → container start), and each term has a named fix, with pre-warmed pools bounding the big one.
  • Sizing: Little's Law - concurrency = arrival rate x duration - turns 60 builds/sec x 15 min into ~50K concurrent builds, a derived number, not a vibe.
  • Then the elegant coupling: shortening average duration (parallelism + cache hits) cuts fleet cost AND developer wait together, since both are linear in duration. Speed and cost pointing the same direction is rare - name it when you see it.