Interview Setup
Interview Prompt
Design a CI/CD platform (GitLab CI / GitHub Actions style) for 5M pipelines per day, 500K peak concurrent jobs, and 30-minute average job duration. Support pipeline DAGs (stages, jobs, matrix builds, needs/dependencies), an autoscaling ephemeral runner fleet, work queues with fair multi tenant scheduling, artifact storage with Docker layer caching, and secure secrets injection at runtime. Pipelines are triggered by git push/PR webhooks from a code hosting platform.
Clarifying Questions (ask before designing)
| Question | Why it matters |
|---|---|
| Is git hosting in scope, or only CI/CD? | Code Hosting Platform covers Git storage, push/pull protocols, and pull requests. This problem focuses exclusively on CI/CD architecture, consuming push and pull request events via webhooks rather than redesigning Git storage engines. |
| Hosted runners vs self hosted agents? | Hosted ephemeral VMs represent the default scale path for 500K concurrent workloads. Self-hosted agents register long lived connections and poll for jobs, which is simpler but leaves sandboxing and isolation to the customer. |
| Exactly-once or at least once job execution? | At least once dispatch is paired with idempotent deployment effects. A runner can crash after performing work but before the scheduler observes completion, so arbitrary runner execution cannot provide true exactly once execution semantics end to end. Exactly once effects can still be achieved for sinks that support transactions or idempotency. |
| How are secrets provided to jobs? | Integrate with a dedicated Secrets Manager to issue short lived dynamic credentials and OIDC-to-cloud IAM federated tokens. Secrets must never be stored in pipeline YAML or runner base images. |
| What breaks first at 500K concurrent jobs? | Runner fleet capacity and fair share scheduling saturate before the API tier. Sizing the compute pool, queue depth, and autoscaling signals takes precedence over optimizing YAML parsing. |
Scope
In scope
- Pipeline definition (YAML) compilation into executable DAG
- Webhook triggers from push and pull request events
- Job scheduling with stages, needs, and matrix fan out
- Ephemeral runner fleet with autoscaling and isolation
- Work queue (Kafka/Redis) with priority and fair share scheduling
- Artifact store (S3) and Docker layer caching
- Secrets injection at runtime via secrets management and OIDC to cloud providers
- Job leasing, heartbeat, retry, cancellation, and stale attempt protection
Out of scope (state explicitly)
- Git object storage and receive-pack internals (Code Hosting Platform serves as the clone source)
- Issue tracking, code review UI, and merge conflict resolution (Code Hosting Platform)
- Building a custom container orchestrator from scratch (leverage Kubernetes under the hood)
- Full secrets manager design (integrate via runtime APIs)
Functional Requirements
Start by asking your interviewer whether git hosting is in scope, pointing to Code Hosting Platform (GitHub) as the boundary. Clarify ephemeral runner isolation, DAG scheduling with needs: dependencies, secrets injection via Secrets Manager & KMS, and fair share scheduling across tenants.
- Pipeline definition: Parse
.gitlab-ci.ymlor GitHub Actions workflow YAML into an executable DAG of jobs with stages,needs:dependencies, andif:conditions. - Triggers: Start pipelines on git push, pull request, tag, schedule (cron), and manual API triggers by consuming webhook events from Code Hosting Platform (GitHub).
- Matrix builds: Fan out one job definition across an axis (OS, language version, shard index).
- Job execution: Dispatch jobs to ephemeral runners, stream logs, and support cancellation, retry, and timeout policies.
- Runner fleet: Hosted ephemeral VMs and containers with autoscaling, alongside optional self hosted agent registration.
- Artifacts: Upload and download build outputs between jobs and after pipeline completion backed by object storage.
- Cache: Docker layer and dependency caches (npm, Maven) shared across runs per repository and organization.
- Secrets and variables: Inject runtime secrets via Secrets Manager & KMS, supporting OIDC federation to cloud IAM without stored long lived keys.
- Status reporting: Report commit check status back to Code Hosting Platform (GitHub) with pass or fail outcomes per job and pipeline.
- Multi-tenant fair scheduling: Per-organization concurrency limits, priority lanes, and queue fairness across tenants.
Non-Functional Requirements
Your interviewer will stress-test runner fleet sizing using Little's Law, since 500K concurrent jobs at a 30-minute average duration represents the dominant cost line rather than the control plane. In addition, handling secrets in pull requests from forks and enforcing idempotent deployments represent primary security and operational concerns.
- Scale: 5M pipelines/day, ~25M job runs/day, and 500K peak concurrent jobs.
- Isolation: Each job executes within an isolated, single use sandbox designed to prevent cross job filesystem, network, or credential leakage.
- Reliability: At least once dispatch paired with idempotent side effects following Idempotency and Exactly-Once Effects.
- Latency: Webhook to first job queued latency < 5s p99, and P0 queue wait time < 60s p99.
- Availability: 99.9% uptime for the job dispatch path, since CI/CD outages block deployment releases across the organization.
- Security: Zero secret injection into fork pull requests from untrusted contributors, automated log masking, short lived OIDC federated cloud credentials, and environment scoped secret policy for trusted jobs.
- Observability: Distributed tracing per pipeline via Observability and Distributed Tracing, combined with metrics for queue depth, runner utilization, and success rates per repository.
Capacity Estimations
Runner fleet RAM-hours dominate infrastructure costs, where Little's Law connects concurrent jobs, duration, and dispatch throughput. A log volume of 125 TB per day must stream directly to object storage, ensuring the control plane never proxies heavy log payloads.
| Metric | Calculation | Value |
|---|---|---|
| Pipelines triggered / day | Given | 5M |
| Avg jobs per pipeline | Stages + matrix (~5 jobs typical) | ~5 |
| Total job executions / day | 5M x 5 | ~25M |
| Avg job duration | Given | 30 min |
| Peak concurrent jobs | Planning capacity target | 500K |
| Implied average concurrency if 25M jobs/day each run for 30 min | (25M ÷ 86400) x 1800s | ~521K (consistency check) |
| Peak job completion rate (Little's Law) | 500K ÷ 1800s | ~278 jobs/sec |
| Avg job dispatch rate | 25M ÷ 86400 | ~289 jobs/sec |
| Peak dispatch rate (3x avg) | 289 x 3 | ~870 jobs/sec |
| Runner fleet at peak | 1 ephemeral runner per concurrent job | ~500K VMs/containers |
| Warm pool (pre provisioned) | ~10% of peak for latency | ~50K idle runners |
| Log volume / day | 25M jobs x 5 MB avg logs | ~125 TB/day |
| Artifact storage (7-day retention) | 20% jobs x 100 MB x 25M x 7 days | ~3.5 PB logical footprint before compression |
| Docker layer cache | 100K tenants x 500 GB hot per large tenant | ~50 PB upper bound |
| Pipeline metadata (PostgreSQL) | 25M jobs x 2 KB rows + DAG edges | ~50 GB/day logical row volume (partitioned, with indexes/WAL adding write amplification) |
| Kafka dispatch payload throughput | 870 dispatch/sec x 4 KB payload | ~3.5 MB/sec peak ingress |
The planning inputs are intentionally conservative rather than a mathematically self-consistent steady-state model. At 25M jobs per day and a 30-minute average duration, Little's Law implies roughly 521K concurrent jobs under a steady-state assumption, slightly above the stated 500K planning peak. Treat 500K as the operational fleet target and size burst capacity and effective average duration accordingly. The daily volume, average duration, and peak concurrency are therefore planning inputs rather than a claim that the system remains in steady state at every moment.
The runner fleet represents the dominant cost driver at 500K concurrent executions across a 30-minute average duration. The stated ~870 jobs/sec dispatch rate is modest relative to the runner workload, and the dispatch design spans 224 Kafka partitions across P0, P1, and P2 lanes. Actual Kafka and PostgreSQL limits still require benchmarking and sharding as state transitions grow. Log ingestion at 125 TB/day streams directly to S3 through runner held presigned URLs so control plane instances do not proxy log payloads. Artifact storage applies a 7-day hot retention tier followed by Glacier lifecycle transitions to manage the ~3.5 PB logical retained footprint.
Architecture Diagram
Establish the system boundary with Code Hosting Platform (GitHub) early: code hosting publishes git.push_event notifications, and this platform consumes them. The webhook ingestor compiles pipeline YAML into an execution DAG and passes ready jobs to a scheduler designed around Distributed Job Scheduler patterns.
Runners are strictly ephemeral, allocating one isolated VM or container per job that terminates completely upon teardown. Use hardened VM or microVM isolation for untrusted multi tenant execution when stronger isolation than a shared kernel provides is required. Runners fetch scoped credentials from Secrets Manager & KMS at initialization via OIDC tokens, stream logs and upload artifacts directly to Blob Storage (S3) using presigned URLs, and report terminal status back so the code hosting platform can update commit check statuses.
The platform operates across four decoupled stages: webhook ingestion and YAML compilation, DAG scheduling with PostgreSQL authoritative state plus Redis dependency counters, prioritized Kafka queues with multi tenant fair sharing, and an autoscaling runner fleet with atomic lease management. Durable state changes flow through the PostgreSQL outbox before asynchronous publication. While job dispatch follows at least once semantics, externally visible deployment effects require strict deduplication keys as described in Idempotency and Exactly-Once Effects.
In the room
Size runner capacity before discussing YAML syntax: "500K concurrent runners at a 30-minute average duration defines the infrastructure fleet, not PostgreSQL." If the interviewer probes duplicate deployments, explain atomic lease acquisition via Redis SETNX combined with idempotent execution tokens on deployment steps.
Pipeline DAG Execution
Runner Job Lifecycle
Component Deep Dives
1. Webhook Ingestor and VCS Integration
The pipeline spanning webhook ingestion, compilation, DAG scheduling, and runner execution forms the primary backbone. Matrix fan out and multi tenant fair sharing provide the deeper staff level scheduling discussion.
Push events from Code Hosting Platform (GitHub) are processed idempotently, ensuring duplicate webhook deliveries never create duplicate pipeline runs.
Git push triggers pipeline (boundary between VCS and CI/CD):
1. Developer: git push origin feature-branch
2. Code hosting Git service (Gitaly for GitLab): receive-pack updates the ref and triggers the repository hook
3. Code hosting event bus (Kafka in this design): publish git.push_event { repo_id, ref, sha, actor, event_type }
4. CI/CD Webhook Ingestor consumes push_event:
a. Validate provider signature, issuer metadata, timestamp, and delivery ID before parsing the payload
b. Reject duplicate delivery IDs using the durable webhook deduplication record, or return the existing pipeline ID for an already processed delivery
c. Match branch filter (e.g. refs/heads/main, refs/heads/feature/*)
d. Load pipeline config from repo (.gitlab-ci.yml / .github/workflows/ci.yaml)
e. Compile YAML into a DAG of jobs (stages, needs, matrix, if: conditions)
f. In one PostgreSQL transaction, insert pipeline_run, job rows, dependency rows, the webhook delivery dedup record, and outbox events
g. Outbox publisher emits each root job as a durable dispatch event to the priority lane selected by trigger and job priority
5. Scheduler dispatches when a runner is available and dependencies are satisfied
6. Outbox publisher emits durable pipeline and job events to Kafka, while status callbacks PATCH pipeline status back to the VCS commit checks API
PR events: pull_request.opened/synchronize trigger the same flow with the merge commit SHA
Manual: POST /repos/{id}/pipelines { ref, variables }: bypasses webhook ingestion but enters the same compiler, persistence, and scheduling pathIdempotent ingestion uses the provider delivery ID as the webhook deduplication key and persists that record in the same PostgreSQL transaction that creates the pipeline. This prevents retries from creating a second pipeline. The transactional outbox then publishes the resulting events to Kafka without requiring the database write and Kafka publish to be one distributed transaction.
2. Pipeline Compiler (YAML to DAG)
The compiler translates pipeline YAML into an executable directed acyclic graph at trigger time, verifying that all needs: dependencies form a valid acyclic structure before enqueueing jobs.
- Parse: Loads configuration from the repository at the trigger commit SHA via internal VCS APIs rather than public git clone operations.
- Validate: Confirms acyclic
needs:graph structures, resolves registered runner labels, and verifies that requested secret paths exist in the configured secret scope. - Expand: Evaluates matrix build axes, expands included configuration templates, and computes environment variable inheritance.
- Persist: Records
pipeline_runs,jobs, dependency rows, and webhook deduplication state in PostgreSQL before emitting durable outbox events. - Enqueue: Moves jobs with zero unsatisfied prerequisites to the READY state and publishes them to the Kafka dispatch topic.
3. DAG Scheduler Patterns ⭐
PostgreSQL stores the authoritative DAG state, while Redis dependency counters act as a derived fast path. Parent completion decrements a child counter atomically, and repair jobs can rebuild counters from persisted dependencies after Redis loss. Pipeline stages enforce sequential synchronization barriers between execution tiers.
- State machine: Manages transitions through PENDING, READY, QUEUED, RUNNING, and terminal states (SUCCEEDED, FAILED, SKIPPED, CANCELLED).
- Dependency resolution: Executes a guarded Redis
DECR deps:{child}after an idempotent parent completion event, automatically enqueueing the child when the counter reaches zero. Durable job state remains authoritative for recovery and reconciliation. - Stage barriers: Applies implicit dependencies across stages for jobs without explicit cross stage needs, ensuring those jobs wait for the preceding stage before becoming eligible.
- Failure propagation: Marks dependent children as SKIPPED when a parent fails, unless the job explicitly sets
allow_failure: true. - Concurrency groups: Enforces
concurrency: deploy-prodrules to guarantee only one active job runs per group per branch, managed through distributed locking.
4. Work Queue and Job Leasing ⭐
Priority lanes and lease based execution limit duplicate work by using durable dispatch records, a Redis duplicate publication guard, and atomic leases on worker claims, leveraging principles from Message Queues Fundamentals. The design still assumes at least once delivery, so externally visible side effects require idempotency.
Topic: pipeline-events
Partitions: 256 (partition by repo_id for ordering per repository)
Retention: 30 days
Events: pipeline.created, pipeline.started, job.queued, job.started, job.completed,
job.failed, job.cancelled, artifact.uploaded
Payload: { event_id, tenant_id, repo_id, pipeline_id, job_id, attempt_id?, status, trace_id }
Producer: transactional outbox publisher from PostgreSQL state changes
Idempotency: event_id is unique for publication and consumers tolerate redelivery
Consumer groups: lifecycle, webhook fanout, metrics indexer, and audit consumers use independent groups
Topic: jobs.dispatch (priority lanes)
jobs.dispatch.p0: merge queue, protected branch (32 partitions, key=repo_id)
jobs.dispatch.p1: default push/PR (128 partitions, key=tenant_id)
jobs.dispatch.p2: scheduled/nightly (64 partitions, key=tenant_id)
Dispatch envelope: { event_id, job_id, attempt_id?, tenant_id, repo_id, priority, trace_id }
Consumer: Runner Fleet Manager: partition consumers share work, while scheduler quotas provide tenant isolation
Consumer: Webhook fanout: notify code hosting integrations, Slack, and deployment APIs
Consumer: Metrics indexer: track queue wait time and success rate per repo in metrics storage
DLQ: jobs.dispatch.dlq: alert on any message and enable manual replay via console- Dispatch guard: Uses
SET job:dispatch:{id} NX EX 60only as a duplicate publication guard. The PostgreSQL outbox record remains authoritative and is retried until Kafka acknowledges publication, so a scheduler crash after acquiring the Redis guard but before Kafka publication does not lose the dispatch. - Execution lease: Runners claim jobs via atomic
SET job:lease:{id} NX EX 1800commands, then record the claim against the authoritative attempt using a conditional database update. If the database claim fails, the runner releases or abandons the Redis lease and does not execute the job. Every heartbeat and terminal status update includes the current attempt and lease token, and the scheduler accepts the update only when those values still match the authoritative attempt. The durable terminal state is committed before lease cleanup is attempted. - Reaper daemon: Scans active jobs with expired leases every 30 seconds. Before creating a replacement attempt, it checks the authoritative PostgreSQL state. A CANCEL_REQUESTED job becomes CANCELLED instead of being retried. Other expired attempts are requeued or failed according to retry policy.
5. Fair Share Queue Manager ⭐
Weighted fair share scheduling prevents high volume tenants from monopolizing the runner pool, while dedicated merge queue lanes reserve capacity for time sensitive integration checks.
Multi-tenant fair scheduling: Per-tenant limits (PostgreSQL policy + Redis counters): max_concurrent_jobs: 500 (enterprise: 5000) max_queue_depth: 2000 weight: 1 (free) | 10 (paid) | 100 (enterprise) Dispatch ordering: separate priority lanes select P0, P1, or P2 first within each tenant lane, encode virtual service time, enqueue_time, and job_id into a deterministic Redis ZSET score aging reduces effective virtual service time as wait time increases Global coordinator (every 1s, driven by the active tenant set rather than a scan of all 100K tenants): 1. Compute fair share credits from active tenant weights 2. fair_share_slot = total_available_runners / sum(active_tenant_weights) 3. Carry fractional credits across rounds, convert them into integer admissions, and redistribute unused slots among eligible tenants 4. Admit jobs up to each tenant's concurrency and queue limits 5. P0 jobs use a reserved 20% runner capacity budget, though idle reserved capacity may be loaned to P1/P2 6. Reconcile Redis running counters against authoritative PostgreSQL job states after failover Starvation: if job.wait_time > 2h and priority=P2: promote to P1
6. Runner Fleet Manager
Autoscaling policies track Kafka consumer lag, while pre provisioned warm pools eliminate cold start delays for latency-critical feedback loops.
- Ephemeral sandboxes: Provisions dedicated, single use Kubernetes pods or virtual machines per job that are destroyed immediately upon task completion.
- Warm pool: Maintains approximately 10% of peak capacity pre warmed to absorb burst traffic without container initialization latency.
- Autoscaling: Drives horizontal pod autoscaling on runner deployments based on Kafka consumer lag and queue depth.
- Self-hosted agents: Enables long lived customer hosted agents to register via secure tokens and poll
POST /v1/runners/claim, delegating compute costs and network isolation to the user. - Target labels: Matches job requirements such as
runs-on: [linux, gpu, large]against appropriate runner pools.
7. Runtime Secrets Injection ⭐
Secrets are never baked into runner images or stored in repository YAML. Runners obtain short lived credentials from Secrets Manager & KMS via OIDC federation. The runner agent performs best effort process cleanup on job completion, while withholding secret access on fork pull requests.
Runtime secrets injection (Secrets Manager and KMS integration):
Runner startup (before any user script):
1. Runner presents OIDC JWT: { sub: "repo:org/app:ref:refs/heads/main", aud: "cicd" }
2. Control plane validates JWT signature, issuer, audience, subject, and expiry, then maps it to secret scope: repo/org/app/env:production
3. Batch fetch from Secrets Manager: GET /v1/secrets/runtime?scope=...&ttl=1800
- Database password (dynamic lease 30 min)
- NPM_TOKEN (rotated, masked)
- AWS_ROLE_ARN: exchange OIDC for STS credentials with no stored AWS keys
- Refresh short lived cloud credentials before expiry while the job remains authorized
4. Inject secrets into the runner process namespace without writing them to persistent disk
5. Log scrubber registers secret values for runtime redaction
6. On job termination (success/fail/cancel): revoke dynamic leases and perform best effort process cleanup
Security note: environment variables can be inherited by child processes, so masking and sandboxing are defense in depth. Secret scope is based on the trusted repository, ref, environment, and approval policy rather than repository membership alone. Trusted job code must still be treated as able to exfiltrate any secret it is authorized to receive.
Fork PRs from untrusted contributors: secrets scope is empty, caches are isolated from trusted branches, and only read only repository tokens are available8. Artifact Store and Layer Cache
Artifact and log streaming bypasses the central control plane, using direct presigned uploads to Blob Storage (S3) with incremental chunk flushes to survive runner crashes.
- Artifact storage: Runners upload build outputs directly via short lived, job scoped presigned PUT requests to
s3://artifacts/{tenant}/{pipeline_id}/{job_id}/, with content length and checksum validation before completion is recorded. Finalized artifacts are immutable and referenced by an artifact manifest in PostgreSQL. - Cross-job passing: Dependent jobs download upstream artifacts using the pipeline ID and upstream job identifier.
- Layer caching: Restores content addressable cache archives at job initialization and persists updated layers on success when cache paths change.
- Log streaming: Flushes logical log checkpoints every 5 seconds during execution, batches them into larger ordered object-storage segments, and uses job and chunk sequence metadata so missing or duplicate chunks can be detected during assembly. Partial output therefore survives unexpected runner terminations without requiring one object-storage request per 5-second checkpoint.
API Design
Domain Types and Service Contracts
Keep the public API model separate from internal database rows. Lease and attempt identifiers make runner updates conditional, while idempotency keys make retried triggers safe to process more than once.
type RepoId = string;
type PipelineId = string;
type JobId = string;
type AttemptId = string;
type LeaseToken = string;
type PipelineRef = string;
type PipelineSha = string;
type RunnerLabel = string;
type IdempotencyKey = string;
type JobStatus =
| "PENDING"
| "READY"
| "QUEUED"
| "RUNNING"
| "SUCCEEDED"
| "FAILED"
| "SKIPPED"
| "CANCEL_REQUESTED"
| "CANCELLED";
type RunnerStatusUpdate = "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED";
interface TriggerPipelineRequest {
ref: PipelineRef;
sha?: PipelineSha;
variables?: Record<string, string>;
idempotencyKey: IdempotencyKey;
}
interface IdempotencyRecord {
key: IdempotencyKey;
requestHash: string;
pipelineId: PipelineId | null;
}
interface ClaimJobResponse {
jobId: JobId;
pipelineId: PipelineId;
attemptId: AttemptId;
leaseToken: LeaseToken;
labels: RunnerLabel[];
timeoutSeconds: number;
}
interface JobStatusUpdate {
status: RunnerStatusUpdate;
attemptId: AttemptId;
durationMs?: number;
conclusion?: "PASSED" | "FAILED" | "CANCELLED";
}
type JobStatusResult = "ACCEPTED";
interface CIPlatform {
triggerPipeline(request: TriggerPipelineRequest): Promise<{ pipelineId: PipelineId; status: "PENDING" }>;
claimJob(labels: RunnerLabel[]): Promise<ClaimJobResponse | null>;
reportJobStatus(jobId: JobId, update: JobStatusUpdate): Promise<JobStatusResult>;
}Trigger Pipeline (Manual)
Manual pipeline triggers and internal webhook ingestion share the same compiler pipeline. Manual requests persist a tenant scoped idempotency record with a request hash before creating a pipeline. Runners claim jobs using atomic lease tokens, extend leases through periodic heartbeat patches, and release them through conditional state transitions. Idempotency keys deduplicate retried events, while attempt IDs prevent stale runners from updating a newer retry. Reusing an idempotency key with a different request hash returns 409 Conflict.
POST /v1/repos/{repo_id}/pipelines
Authorization: Bearer <token>
Idempotency-Key: idem-uuid-12345
Content-Type: application/json
{
"ref": "refs/heads/main",
"sha": "abc123...",
"variables": { "DEPLOY_ENV": "staging" }
}
Response: 202 Accepted
{
"pipeline_id": "pipe_uuid",
"status": "PENDING",
"web_url": "https://ci.example.com/org/app/pipelines/pipe_uuid"
}Runner Claim Job
POST /v1/runners/claim
Authorization: Bearer <runner_registration_token>
X-Runner-Labels: linux,large
Response: 200 OK (job available)
{
"job_id": "job_uuid",
"pipeline_id": "pipe_uuid",
"attempt_id": "attempt_uuid",
"lease_token": "lease_secret",
"steps": [
{ "name": "checkout", "script": "git clone ..." },
{ "name": "test", "script": "npm test" }
],
"secrets_endpoint": "/v1/jobs/job_uuid/secrets",
"artifact_upload_url": "https://s3.../job-scoped-presigned",
"timeout_seconds": 1800
}
Response: 204 No Content (no jobs: poll again in 3s)Report Job Status
PATCH /v1/jobs/{job_id}
Authorization: Bearer <lease_token>
{
"status": "SUCCEEDED",
"conclusion": "PASSED",
"duration_ms": 342000,
"attempt_id": "attempt_uuid",
"outputs": { "image_tag": "app:v1.2.3" }
}
# Heartbeat (extends lease only for the current attempt):
PATCH /v1/jobs/{job_id}/heartbeat
Authorization: Bearer <lease_token>
X-Job-Attempt-Id: attempt_uuid
# Request a direct log upload URL:
POST /v1/jobs/{job_id}/logs/presign
Authorization: Bearer <lease_token>
# Runner uploads the log chunk directly to object storage using the returned URL:
PUT https://s3.../presigned-log-chunk
Content-Type: application/octet-stream
X-Log-Offset: 65536Cancel Job
POST /v1/jobs/{job_id}/cancel
Authorization: Bearer <token>
Response: 202 Accepted
{
"job_id": "job_uuid",
"status": "CANCEL_REQUESTED"
}
The scheduler performs a conditional state transition to CANCEL_REQUESTED. The runner stops on the next control heartbeat, and the terminal cancellation update is accepted only for the current attempt. If the lease expires first, the reaper checks CANCEL_REQUESTED before creating a replacement attempt and marks the job CANCELLED.Webhook Ingestion (Internal VCS Callback)
POST /v1/internal/git-events
X-Git-Event-Delivery: delivery_uuid
X-Git-Event-Type: push
X-Git-Event-Timestamp: 2026-09-12T19:40:00Z
X-Git-Signature: sha256=<hmac>
{
"repo_id": "repo_uuid",
"ref": "refs/heads/feature/auth",
"sha": "def456...",
"pusher": "developer@example.com",
"commits": [...]
}
Response: 202 Accepted
{ "pipeline_id": "pipe_uuid", "jobs_created": 8 }Common Error Responses
400 Bad Request: invalid input, missing required fields, or malformed JSON payload
401 Unauthorized: missing or invalid authentication token or API key
403 Forbidden: authenticated caller lacks required permissions for this resource
404 Not Found: requested resource ID does not exist
409 Conflict: duplicate write or version conflict, retry with a unique idempotency key
422 Unprocessable Entity: syntactically valid request failed semantic business validation
429 Too Many Requests: rate limit quota exceeded, client should honor Retry-After header
500 Internal Error: unexpected server failure, retry safely with an idempotency key
503 Service Unavailable: downstream dependency is unavailable or overloaded, retry with exponential backoff
202 Accepted: asynchronous job queued successfully, poll GET /jobs/{id} for completion status
408 Request Timeout: background job is still executing, continue polling status endpoint
403 Forbidden: pipeline blocked: fork from untrusted contributor or branch protection violation
429 Too Many Requests: tenant concurrency or queue quota exceeded
409 Conflict: job attempt is stale, already claimed, or no longer owns the execution lease, or the same idempotency key was reused with a different request
410 Gone: the pipeline run has been cancelled or the requested resource is no longer available
422 Unprocessable Entity: invalid pipeline YAML: cyclic needs dependency detectedData Model
PostgreSQL: Pipelines and Jobs
CREATE TABLE pipeline_runs (
pipeline_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
repo_id UUID NOT NULL,
ref VARCHAR(512) NOT NULL,
sha VARCHAR(64) NOT NULL, -- supports SHA-1 and SHA-256 object IDs
status VARCHAR(16) NOT NULL, -- PENDING, RUNNING, SUCCEEDED, FAILED, CANCEL_REQUESTED, CANCELLED
trigger_type VARCHAR(32) NOT NULL, -- push, pull_request, schedule, manual
created_at TIMESTAMPTZ NOT NULL,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
CREATE INDEX idx_repo_status ON pipeline_runs (repo_id, created_at DESC);
CREATE TABLE jobs (
job_id UUID PRIMARY KEY,
pipeline_id UUID NOT NULL REFERENCES pipeline_runs(pipeline_id),
tenant_id UUID NOT NULL,
name VARCHAR(256) NOT NULL,
matrix_instance_key VARCHAR(512) NOT NULL DEFAULT '',
stage VARCHAR(128),
status VARCHAR(16) NOT NULL,
runner_labels JSONB NOT NULL DEFAULT '[]'::jsonb,
idempotency_key VARCHAR(256), -- deploy deduplication key
attempt_id UUID,
lease_runner_id UUID,
lease_token_hash VARCHAR(128),
lease_expires TIMESTAMPTZ,
retry_count INT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
UNIQUE (pipeline_id, name, matrix_instance_key)
);
CREATE TABLE job_attempts (
attempt_id UUID PRIMARY KEY,
job_id UUID NOT NULL REFERENCES jobs(job_id),
runner_id UUID,
lease_token_hash VARCHAR(128),
status VARCHAR(16) NOT NULL,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
retry_number INT NOT NULL,
failure_reason TEXT
);
CREATE UNIQUE INDEX uq_job_attempt_retry ON job_attempts (job_id, retry_number);
CREATE INDEX idx_job_attempts_job ON job_attempts (job_id, retry_number DESC);
CREATE TABLE webhook_deliveries (
delivery_id VARCHAR(256) NOT NULL,
source VARCHAR(64) NOT NULL,
repo_id UUID NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
pipeline_id UUID,
PRIMARY KEY (source, delivery_id)
);
CREATE TABLE idempotency_records (
tenant_id UUID NOT NULL,
idempotency_key VARCHAR(256) NOT NULL,
request_hash VARCHAR(128) NOT NULL,
pipeline_id UUID,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
PRIMARY KEY (tenant_id, idempotency_key)
);
CREATE INDEX idx_idempotency_expiry ON idempotency_records (expires_at);
CREATE TABLE artifacts (
artifact_id UUID PRIMARY KEY,
job_id UUID NOT NULL REFERENCES jobs(job_id),
object_key VARCHAR(1024) NOT NULL,
sha256 CHAR(64) NOT NULL,
size_bytes BIGINT NOT NULL,
content_type VARCHAR(256),
status VARCHAR(16) NOT NULL, -- UPLOADING, FINALIZED, DELETED
created_at TIMESTAMPTZ NOT NULL,
finalized_at TIMESTAMPTZ,
UNIQUE (job_id, object_key)
);
CREATE INDEX idx_artifacts_job ON artifacts (job_id);
CREATE TABLE outbox_events (
event_id UUID PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id UUID NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
published_at TIMESTAMPTZ
);
CREATE TABLE job_dependencies (
child_job_id UUID NOT NULL REFERENCES jobs(job_id),
parent_job_id UUID NOT NULL REFERENCES jobs(job_id),
PRIMARY KEY (child_job_id, parent_job_id)
);
CREATE TABLE tenant_quotas (
tenant_id UUID PRIMARY KEY,
max_concurrent_jobs INT NOT NULL DEFAULT 500,
max_queue_depth INT NOT NULL DEFAULT 2000,
scheduling_weight INT NOT NULL DEFAULT 1,
artifact_quota_gb INT NOT NULL DEFAULT 100
);Redis: Leases and Fair Share Queues
job:lease:{job_id} -> { attempt_id, runner_id, token }, TTL = job_timeout + buffer
job:dispatch:{job_id} -> scheduler_id, TTL 60s (duplicate publication guard only, as outbox remains authoritative)
deps:{child_job_id} -> int counter (derived unsatisfied parent count)
queue:{priority}:{tenant_id} -> ZSET of job_ids (within-lane fair share ordering)
tenant:running:{tenant_id} -> int concurrent job count
concurrency:{group_key} -> distributed lock for mutex groups
idempotency:{tenant_id}:{key} -> optional cached result / request hash for manual triggers
repair: rebuild derived counters from PostgreSQL after Redis loss
Durable dispatch source of truth: PostgreSQL outbox_eventsEvent Bus Design (Kafka)
Topic: pipeline-events
Partitions: 256 (partition by repo_id for ordering per repository)
Retention: 30 days
Events: pipeline.created, pipeline.started, job.queued, job.started, job.completed,
job.failed, job.cancelled, artifact.uploaded
Payload: { event_id, tenant_id, repo_id, pipeline_id, job_id, attempt_id?, status, trace_id }
Producer: transactional outbox publisher from PostgreSQL state changes
Idempotency: event_id is unique for publication and consumers tolerate redelivery
Consumer groups: lifecycle, webhook fanout, metrics indexer, and audit consumers use independent groups
Topic: jobs.dispatch (priority lanes)
jobs.dispatch.p0: merge queue, protected branch (32 partitions, key=repo_id)
jobs.dispatch.p1: default push/PR (128 partitions, key=tenant_id)
jobs.dispatch.p2: scheduled/nightly (64 partitions, key=tenant_id)
Dispatch envelope: { event_id, job_id, attempt_id?, tenant_id, repo_id, priority, trace_id }
Consumer: Runner Fleet Manager: partition consumers share work, while scheduler quotas provide tenant isolation
Consumer: Webhook fanout: notify code hosting integrations, Slack, and deployment APIs
Consumer: Metrics indexer: track queue wait time and success rate per repo in metrics storage
DLQ: jobs.dispatch.dlq: alert on any message and enable manual replay via consoleFault Tolerance
Fault Tolerance Scenarios
| Concern | Solution |
|---|---|
| Runner crash during job | Lease based execution follows distributed job scheduler patterns: heartbeats every 60s extend the Redis lease. On expiration, the scheduler creates a new job attempt only after verifying that the prior lease has expired. Each attempt carries a unique lease token, and status transitions are accepted only for the current attempt. Idempotent deployment steps use deduplication keys. Systems cap retries at 3 attempts with exponential backoff, and logs flush incrementally to S3 so partial execution output is preserved. |
| Noisy neighbor when one tenant floods the queue | Fair share scheduling uses weighted admission credits per tenant ID. Concurrency caps limit active jobs per organization, with a default of 500. Priority queues separate P0 merge work, P1 default pushes, and P2 scheduled batch runs. Aging promotes starved P2 jobs after 2 hours, while Kafka partitions key by tenant where ordering affinity is useful. Partitioning alone is not a hard isolation boundary, so concurrency quotas and scheduler policy enforce tenant isolation. |
| DAG deadlock from circular dependencies | The pipeline compiler validates directed acyclic graphs during parsing using topological sorting. At runtime, if a parent fails without an allow_failure flag, all dependent child jobs mark as SKIPPED. Stale RUNNING parents with expired leases transition to FAILED to cascade down the graph, and an admin API provides manual job skip capabilities. |
| Secret leaked in build logs | Secrets are fetched from the secrets manager and injected by the runner agent as masked environment variables. Log scrubbers redact known secret values before upload to S3, but they cannot guarantee that arbitrary job code will not transform or exfiltrate a secret that it can access. Short lived OIDC tokens (15 min) replace long lived cloud keys, and dynamic secret leases are revoked when teardown runs, with TTL based expiry protecting against runner crashes. |
| Artifact store exhaustion | Enforce per repository quotas (100 GB default) with strict time to live policies: 7 days for build artifacts and 30 days for logs. Use zstd compression on upload and transition files to colder object storage tiers after 7 days. Large artifacts (>1 GB) use presigned multipart URLs so runners upload directly to S3 without burdening control plane proxies. Incomplete multipart uploads are aborted by lifecycle cleanup after the configured grace period. |
| Scheduler control plane outage | Stateless API tier is backed by durable PostgreSQL state and a transactional outbox for state change events. In flight jobs continue running until their leases expire, while Kafka preserves undispatched jobs with a replication factor of 3. Standby schedulers elect an active leader via consensus. Recovery targets an RTO under 60 seconds, with at least once dispatch and duplicate side effects prevented by conditional lease claims and idempotency keys. |
| Malicious pipeline YAML (crypto miner) | Runners execute in unprivileged sandboxes with strict CPU and memory limits, seccomp or AppArmor style restrictions, and outbound network allowlists. Runner images are immutable, signed, and verified before launch, with image provenance recorded for incident response. Jobs cannot access a privileged container runtime socket. Untrusted fork pull requests receive zero secret injection and isolated dependency caches. Production deployment stages mandate manual approval gates, and anomaly detectors monitor runner resource utilization across tenants. |
Additional Considerations
Relationship to Code Hosting Platforms
Code Hosting Platform (GitHub) manages Git object storage, push and pull protocols, pull request reviews, and branch protections, explicitly treating CI/CD execution as external. This platform provides that dedicated CI/CD depth by consuming webhook events from the code hosting Kafka bus, fetching commit trees through internal APIs, and reporting commit status results back to update pull request checks.
Relationship to Distributed Job Schedulers
Distributed Job Scheduler establishes the core primitives: priority queuing, DAG dependency counters, lease based execution, and deduplication. This CI/CD architecture applies those primitives to developer workflows, adding YAML compilation, matrix expansion, ephemeral runner lifecycle management, and artifact caching. For long running, multi hour deployment workflows with compensation logic, compare this with Workflow Orchestration (Temporal).
Relationship to Secrets Management and KMS
Runners never embed credentials in container images or repository configuration. At job startup, the control plane retrieves scoped secrets from Secrets Manager & KMS alongside short lived cloud credentials exchanged through OIDC. All dynamic leases terminate automatically during runner teardown, and fork pull requests from external contributors receive an empty secret scope to prevent exfiltration.
Merge Queue (Staff Extension)
High traffic repositories serialize merges targeting main branches: pull requests enter a merge queue where pipelines run against speculative merge commits, automatically merging upon success. This prevents scenarios where individually green pull requests break the main branch due to concurrent merges. Merge queues operate as high priority concurrency groups. Each queued merge is tested against a speculative merge commit and must be revalidated if the target branch advances before the final merge.
Interview Walkthrough
- 25-minute interview structure
- Clarify system boundary with code hosting platforms (5 min)
- Architecture flow: webhooks, compiler, DAG scheduler, queues, and runners (6 min)
- Fleet capacity estimation using Little's Law: 500K concurrent across 30 minutes (5 min)
- Lease management and idempotent deployment steps (5 min)
- Runtime secrets injection via OIDC and fork security protections (4 min)
- Explain the boundary separating Git repository hosting from CI/CD execution engines.
- Illustrate the core pipeline progression from webhook trigger to ephemeral runner execution.
- Size the runner fleet using Little's Law, emphasizing compute fleet scale over database volume.
- Detail lease renewal mechanics, stale attempt protection, and stable idempotency keys for deployment side effects.
- Describe runtime secret injection through OIDC federation, highlighting untrusted fork safeguards.
- Address multi tenant fairness algorithms to prevent noisy neighbors from starving queue capacity.
- Highlight the architectural necessity of isolated ephemeral sandboxes rather than persistent shared hosts.
Engineering Trade-offs
Ephemeral vs Reusable Runners
Ephemeral runners destroy the virtual machine or container immediately after job completion, providing strong isolation, reducing cross job credential leakage, and preventing dirty workspace state at the expense of higher cold start provisioning latency. Reusable persistent runners eliminate provisioning delays but introduce substantial risks of leftover filesystem state and credential residue, making them acceptable only for single tenant self hosted deployments with rigorous cleanup automation. Managed CI/CD platforms commonly use ephemeral sandboxes for hosted tiers.
Warm Pool Sizing
Maintaining a larger warm pool of pre started runner instances minimizes queue wait times and enhances developer feedback, but incurs continuous idle compute costs. Sizing warm pools to approximately 10% of peak concurrency provides a balanced baseline, with dynamic tuning based on regional provisioning latency. Platforms optimize cost by using spot or preemptible instances for low priority batch workloads while reserving reliable on-demand instances for critical merge queues.
Centralized vs Distributed Schedulers
A centralized leader elected scheduler simplifies DAG state consistency and atomic dependency decrements. Sharding scheduler workers by repository ID scales throughput past 25M jobs per day, but complicates cross repository dependency tracking in large monorepos. Best practice begins with centralized leader elected scheduling and transitions to repository partitioned scheduling when database write throughput on job state transitions reaches limits, following Replication, Failover, and Leader Election.
Artifact Retention vs Storage Cost
Retaining build outputs for 90 days assists historical debugging and compliance audits, but drives petabyte-scale storage costs. Enforcing a 7-day retention period for build artifacts and 30 days for execution logs balances operational needs with storage budgets. Issuing presigned URLs for direct uploads to Blob Storage (S3) avoids saturating control plane network bandwidth.
Matrix Fan-Out Limits
Unconstrained matrix declarations can generate thousands of job combinations, overwhelming tenant concurrency quotas and queue capacity. Production systems enforce compile time thresholds on the Cartesian product (such as a maximum of 256 generated jobs per pipeline run) while offering dynamic matrix generation from script outputs protected by approval gates.
Self Hosted vs Hosted Runners
Hosted runners grant the platform complete control over sandboxing, security enforcement, and elasticity, but require the provider to absorb compute expenses. For untrusted multi tenant workloads, hardened VM or microVM isolation is preferred when stronger isolation than a shared kernel can provide is required. Self-hosted runners offload infrastructure costs to customers while reducing provider visibility into environment security. Hybrid architectures represent the standard for enterprise workloads, where public tests execute on hosted runners and sensitive production deployments run on self hosted agents within private virtual networks.
Review
How helpful was this walkthrough?
Click a star to rate. We actively use this feedback to refine and update our system design content.
Discussion
Share your thoughts, ask questions, or help others.