?

Boss35 minIntro+60 XP

The Ultimate System Design Interview.

A Real-World Problem Covering Everything You've Learned

9 phases17 code

Boss · W02 · 9 phases · 55 min

Fight it like the real interview: each phase runs against its time budget, you write your answer in a scratchpad first, then reveal the model answer side by side and grade yourself.

or read the model answer below
watch it first · newThe whole lesson in a 21-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Intro · 1 min

The Interview Begins#

You walk into the interview room. The interviewer smiles and gestures to the whiteboard.

Interviewer

"Thanks for coming in. Today we're going to work through a system design problem together. I'm interested in your thought process, so please think out loud. Feel free to ask questions — this is meant to be collaborative."

They write on the whiteboard:

Design a Global Flash Sale System for "MegaMart"

MegaMart is launching "Lightning Deals" — flash sales where limited inventory items are sold at 90% discount for exactly 10 minutes.

  • 50 million users globally
  • Flash sales happen every hour (random products)
  • Each sale: 10,000 units available
  • Users get 3 minutes to complete checkout after claiming
  • Must handle payment processing and inventory
  • Must notify users via email/push when deals go live
Interviewer

"Take a few minutes to think about this, then walk me through your approach. We have about 45 minutes."

ExplainerThe whole capstone: MegaMart flash sale

MegaMart sells 10,000 units at 90% off for 10 minutes every hour to up to 5M users, with 3 minutes to check out and no overselling, double charges or lost orders. The estimate gives a 150,000 requests a second peak, 333 claims and 56 checkouts a second, and 2.4M notifications in 30 seconds. The design puts claims in Redis (rate limit, idempotent claim key, atomic Lua decrement), checkout behind idempotency, a Stripe circuit breaker and a 10-second timeout, precomputed notification batches, and a leader-elected scheduler with fencing tokens; an atomic counter beats queue-based claims, checkout-time decrements and per-item locks, and 10x scale shards inventory by region.

Phase 1 · Stage 1 of 9 · 5 min budget · 2 min read

Phase 1: Requirements Clarification (5 minutes)#

Before diving in, you take a breath and start asking questions. This is crucial — never assume.

Your Questions#

Transcript
You

"Before I start designing, I'd like to clarify a few requirements. First, when you say 50 million users globally — is that monthly active users, or do we expect all of them potentially accessing a flash sale?"

Interviewer

"Good question. 50M is MAU. For popular flash sales, we've seen up to 5 million users trying to access a single sale. The product team calls these 'hype drops' — think iPhone launches or limited edition sneakers."

You

"That's helpful. For the 10,000 units — do users select quantities, or is it one unit per user?"

Interviewer

"One unit per user per sale. We want to maximize the number of happy customers."

You

"What happens if someone claims an item but doesn't complete checkout within 3 minutes?"

Interviewer

"The item should go back to available inventory for others."

You

"For payment processing — do you have an existing payment provider, or should I design that?"

Interviewer

"Assume we use Stripe. Treat it as an external API that might be slow or fail."

You

"Last question — the notification when deals go live. Are users subscribed to specific products, or do we notify everyone?"

Interviewer

"Users can 'watch' products. When a flash sale includes a watched product, they should be notified. But we also have a general 'deals' notification channel for users who opted in. Could be millions of notifications per sale."

You

"Perfect. Let me summarize the requirements as I understand them."

Functional Requirements#

Functional requirements
1. Flash sale management
  • Create flash sales with specific products and inventory
  • Sales start at scheduled times (hourly)
  • Sales last exactly 10 minutes
  • 10,000 units per sale
2. Inventory claiming
  • Users can claim one item per sale
  • Claimed items reserved for 3 minutes
  • Unclaimed items return to pool after timeout
  • No overselling (exactly 10,000 successful orders max)
3. Checkout flow
  • Complete payment within 3 minutes of claim
  • Process payment via Stripe
  • Handle payment failures gracefully
  • Create order on successful payment
4. Notifications
  • Notify watchers when watched product goes on sale
  • Notify deal subscribers when any sale starts
  • Support email and push notifications
  • Millions of notifications per sale

Non-Functional Requirements#

Non-functional requirements
1. Scale
  • Handle 5M concurrent users hitting a single sale
  • Process 10K checkouts in 3-minute window
  • Send millions of notifications within seconds
2. Reliability
  • No overselling (inventory consistency is critical)
  • No double-charging (payment idempotency)
  • No lost orders (durability)
3. Availability
  • System must work during sale windows
  • Graceful degradation if components fail
4. Latency
  • Claim response: <500ms at p99
  • Checkout: <3s at p99
  • Notification delivery: <30s for email, <5s for push

Phase 2 · Stage 2 of 9 · 5 min budget · 3 min read

Phase 2: Back of the Envelope Estimation (5 minutes)#

You

"Let me work through the numbers to understand the scale."

Traffic Estimation#

WorksheetFlash sale traffic (per sale)
  • Users trying to access: 5,000,000
  • Sale duration: 10 minutes = 600 seconds
  • Average request rate: 5M / 600 = 8,333 requests/second
  • But traffic isn't uniform. The first 30 seconds see 80% of traffic — peak traffic: (5M × 0.8) / 30 = 133,333 requests/second
  • Let's round up for safety: 150,000 requests/second peak
  • Breakdown by operation: Page views 100,000 /sec (viewing the sale) · Claim attempts 40,000 /sec (trying to claim) · Stock checks 10,000 /sec (AJAX refreshes) · Checkouts 50 /sec (successful claimers)

Inventory Operations#

WorksheetInventory math
  • Total inventory: 10,000 units
  • Claim timeout: 3 minutes
  • Successful checkout rate: ~70% (estimate)
  • If all 10K claimed in first 30 seconds: 7,000 complete checkout · 3,000 timeout → return to pool · Pool refills trigger second wave of claims
  • Maximum claim operations: ~15,000 (accounting for timeouts)
  • Checkout operations: ~10,000 (until inventory exhausted)
  • Claims per second (peak): 10,000 / 30 = 333 claims/second
  • Checkouts per second (peak): 10,000 / 180 = 56 checkouts/second

Notification Estimation#

WorksheetNotification volume
  • Product watchers: 500,000 (popular product)
  • Deal subscribers: 2,000,000
  • Overlap (watching + subscribed): ~100,000
  • Total notifications: 2,400,000
  • Delivery target: 30 seconds for all
  • Notification rate: 2.4M / 30 = 80,000 notifications/second
  • Push notification payload: ~500 bytes · Email payload: ~5 KB
  • Split (assume): ~50,000 push/sec + ~30,000 email/sec
  • Bandwidth — Push: 50,000 × 500 = 25 MB/sec
  • Bandwidth — Email: 30,000 × 5KB = 150 MB/sec

Storage Estimation#

Storage requirements
Per sale
Item Size
Sale metadata ~1 KB
Inventory records 10,000 × 100 bytes = 1 MB
Claim records 15,000 × 200 bytes = 3 MB
Order records 10,000 × 500 bytes = 5 MB
Total per sale ~10 MB
  • Sales per day: 24
  • Storage per day: ~240 MB
  • Storage per year: ~90 GB
Notification logs
Item Size
Per notification 200 bytes
Per sale 2.4M × 200 = 480 MB
Per year ~4 TB
Data tiers
  • Hot data (active sales): ~100 MB
  • Warm data (past week): ~2 GB
  • Cold data (archive): Compress and store in S3

Infrastructure Estimation#

Server requirements
API Servers (150K req/sec)
  • Per server capacity: 5,000 req/sec
  • Servers needed: 30 servers
  • With 2x headroom: 60 servers for the sale page and claims (44 for the sale page: 100K page views + 10K stock checks, 16 for claims: 40K), plus 2 for checkout (its 50/sec needs only that, kept separate): 62 in total
Redis (inventory + claims)
  • Operations/sec: 50,000 (reads + writes)
  • Memory for hot data: ~1 GB per sale
  • Cluster: 3 primary + 3 replica
PostgreSQL (orders, users)
  • Write TPS: 500 (orders only)
  • Read TPS: 5,000
  • Single primary + 2 read replicas
Message Queue (notifications)
  • Messages/sec: 100,000
  • Kafka cluster: 6 brokers, ~24 partitions per topic (consumer parallelism is capped by partition count)
  • Consumer groups: Push, Email, Analytics
Transcript
Interviewer

"Good analysis. Those numbers seem reasonable. What concerns you most about the scale?"

You

"Three things concern me:

  1. The 150K requests/second peak — this is a thundering herd problem. We need aggressive caching and potentially a queue-based claim system.

  2. The inventory consistency — with 40K claim attempts per second on 10K items, we need atomic operations. Race conditions could cause overselling.

  3. The notification delivery — 80K notifications/second is achievable, but we need to pre-compute recipient lists before the sale starts, not at sale time."

Quick check2 questions · +5 XP each

Flash sale estimates

  1. Q1/2 5 million users hit a 10-minute sale. If 60% of them arrive in the first 20 seconds, what peak request rate must you plan for?

    Answer & explanation

    Answer: B

    5M x 0.6 = 3M requests in 20 seconds, or 150,000/s. Averaging over 600 seconds (about 8,333/s) hides the spike that actually breaks things.

  2. Q2/2 Why compute the 2.4 million notification recipients before the sale starts instead of at 10:00:00?

    Answer & explanation

    Answer: C

    At T=0 the system should only fan out a ready-made list. Precomputing moves the expensive work out of the busiest seconds.

Phase 3 · Stage 3 of 9 · 10 min budget · 3 min read

Phase 3: High-Level Design (10 minutes)#

You

"Let me sketch out the high-level architecture."

DiagramMegaMart flash sale architecture
%% title: MegaMart flash sale architecture
flowchart TD
  U@{ icon: "sd:users", label: "Users (5M)" } --> EDGE@{ icon: "tech:cloudfront", label: "CDN + WAF + Load Balancer<br/>(CloudFront + Shield + ALB)" }
  EDGE --> SP["Sale Page<br/>(Read Heavy)<br/>44 servers"]:::core
  EDGE --> CL["Claim API<br/>(Write Heavy)<br/>16 servers"]:::hot
  EDGE --> CO["Checkout API<br/>(Critical)<br/>2 servers"]:::core
  subgraph RED["Redis Cluster"]
    direction TB
    INV@{ icon: "tech:redis", label: "Inventory<br/>Counter" }
    CLS@{ icon: "tech:redis", label: "Claims<br/>Store" }
    RLC@{ icon: "tech:redis", label: "Rate Limit<br/>Cache" }
  end
  SP --> RED
  CL --> RED
  CO --> RED
  RED --> PG@{ icon: "tech:postgresql", label: "PostgreSQL<br/>(Orders DB)<br/>Primary + 2RR" }
  RED --> K@{ icon: "tech:kafka", label: "Kafka<br/>(Events/Notif)<br/>6 brokers" }
  RED --> ST@{ icon: "tech:stripe", label: "Stripe<br/>(Payments)<br/>External API" }
  K --> PW["Push<br/>Workers"]:::service
  K --> EW["Email<br/>Workers"]:::service
  K --> WD["Webhook<br/>Delivery"]:::service
  subgraph SUP["Supporting Services"]
    direction TB
    SS@{ icon: "sd:cron", label: "Sale Sched<br/>(Cron)" }
    IM["Inventory<br/>Manager"]:::service
    CE["Claim<br/>Expiry"]:::service
    MON@{ icon: "tech:grafana", label: "Monitoring<br/>(Grafana)" }
  end
  EW ~~~ SUP
  class U client
  class EDGE edge
  class INV,CLS,RLC cache
  class PG store
  class K queue
  class ST external
  class SS,MON service

Component Overview#

You

"Let me walk through each component and its role:"

Traffic Layer#

CDN + WAF + Load Balancer
  • CloudFront — Cache static assets (sale page HTML, JS, CSS)
  • AWS Shield — DDoS protection (critical during sale spikes)
  • WAF — Rate limiting, bot detection
  • ALB — Route to appropriate service based on path

Benefits:

  • CDN handles 80% of traffic (static content)
  • WAF blocks abusive traffic before hitting servers
  • Geographic distribution reduces latency

API Services#

API services

Sale Page Service (Read-Heavy)

  • Renders sale page with current inventory count
  • Heavy caching (1-second TTL on inventory count)
  • Stateless, horizontally scalable
  • Circuit breaker to Redis

Claim API Service (Write-Heavy)

  • Handles claim requests
  • Atomic inventory operations
  • Idempotent (claim key per user per sale)
  • Rate limited per user

Checkout API Service (Critical)

  • Processes payments through Stripe
  • Creates orders
  • Idempotent (prevents double-charge)
  • Timeout-aware (3-minute claim expiry)
  • Circuit breaker to Stripe

Data Layer#

Data layer
  • Redis Cluster
    • Inventory Counter — Atomic decrement with Lua scripts
    • Claims Store — user_id → claim_info with TTL
    • Rate Limit — Sliding window counters
    • Session Cache — User session data
  • PostgreSQL
    • Primary — Writes (orders, users)
    • Read Replicas — Read queries
    • Partitioned by date for orders
    • Indexed on user_id, sale_id, order_status

Event Processing#

Event processing
  • Kafka
    • Topic: sale_events — sale start, sale end, inventory updates
    • Topic: claim_events — claimed, expired, checkout_started
    • Topic: notification_events — to_send, sent, failed
    • Topic: order_events — created, paid, failed
  • Consumer Groups
    • NotificationWorkers — Send push/email
    • AnalyticsWorkers — Real-time dashboards
    • WebhookWorkers — Notify external systems
    • ClaimExpiryWorkers — Return expired claims to pool
Interviewer

"This looks comprehensive. I'm curious about a few things. First, how do you prevent overselling? Walk me through the claim flow."

Your turn · checkpoint 1not answered

Before the deep dive: 40,000 claims a second for 10,000 units

At peak, about 40,000 claim attempts a second arrive for 10,000 units, one unit per user. Sketch the claim path before reading on. Where does the stock count live, how do you stop two users from taking the last unit, what happens when the count would go below zero, and what does a user's retry get after their success response is lost?

Think it through, or jot it down. Then compare.

One strong answer

  • Where: a counter in Redis, not PostgreSQL. Tens of thousands of claims a second would make the database the bottleneck; it only sees the ~10,000 orders at checkout.
  • No oversell: check-and-decrement in one Lua script, which Redis runs atomically, so no other claim can slip in between.
  • Below zero: a DECR that returns -1 means nothing was left; the script restores the counter and answers "sold out".
  • The retry: a claim:{sale_id}:{user_id} key is checked first, so the retry gets the same claim back with no second decrement. A claim nobody checks out expires after 3 minutes, and a background worker returns its unit to the pool.

Phase 4 · Stage 4 of 9 · 10 min budget · 5 min read

Phase 4: Deep Dive - Inventory and Claims (10 minutes)#

You

"Great question. This is the most critical part of the system. Let me detail the claim flow."

The Claim Flow#

DiagramClaim flow
%% title: Claim flow
flowchart TD
  START(["User clicks “Claim deal”"]):::client --> S1{"Step 1 · Rate limiting<br/>(Redis fixed-window counter)"}:::edge
  S1 -->|"count > 10"| E429(["429 Too Many Requests"]):::bad
  S1 --> S2{"Step 2 · Idempotency check<br/>claim:{sale_id}:{user_id}"}:::core
  S2 -->|"exists"| EX(["Return existing claim<br/>(idempotent response)"]):::ok
  S2 --> S3{"Step 3 · Atomic claim<br/>(Lua script in Redis)"}:::hot
  S3 -->|"already claimed"| EX
  S3 -->|"remaining < 0 · restore counter"| SO(["Sold out"]):::bad
  S3 -->|"success · store claim with TTL"| S4@{ icon: "tech:kafka", label: "Step 4 · Publish event<br/>Kafka: claim_events" }
  S4 --> S5(["Step 5 · Return response"]):::ok
  class S4 queue

Step 1: Rate limiting. Check: Has user exceeded 10 claim attempts in this minute? Implementation: Redis fixed-window counter (one key per minute).

text2 lines
INCRBY rate_limit:{user_id}:{minute} 1
EXPIRE rate_limit:{user_id}:{minute} 60

If count > 10: Return 429 Too Many Requests.

Step 2: Idempotency check. Check: Has user already claimed in this sale? Key: claim:{sale_id}:{user_id}. If exists: Return existing claim (idempotent response). This prevents double-claiming and handles retries safely.

Step 3: Atomic claim (Lua script in Redis).

lua39 lines
-- Lua script for atomic claim
local inventory_key = KEYS[1]  -- inventory:{sale_id}
local claim_key = KEYS[2]      -- claim:{sale_id}:{user_id}
local expiry_key = KEYS[3]     -- claims:expiry:{sale_id} (all keys share the {sale_id} hash tag)
local user_id = ARGV[1]
local claim_id = ARGV[2]
local ttl = ARGV[3]            -- 180 seconds

-- Check if already claimed
if redis.call('EXISTS', claim_key) == 1 then
    return redis.call('GET', claim_key)  -- Return existing claim
end

-- Try to decrement inventory
local remaining = redis.call('DECR', inventory_key)

if remaining < 0 then
    -- No inventory, restore counter
    redis.call('INCR', inventory_key)
    return nil  -- Sold out
end

-- Success! Store claim with TTL
local claim_data = cjson.encode({
    claim_id = claim_id,
    user_id = user_id,
    claimed_at = redis.call('TIME')[1],
    expires_at = redis.call('TIME')[1] + ttl
})

redis.call('SET', claim_key, claim_data, 'EX', ttl)

-- Add to expiry tracking set. Score = expiry + 60s grace, so a checkout
-- that started just before expiry (25s budget) finishes before the unit
-- can be returned to the pool
redis.call('ZADD', expiry_key,
           redis.call('TIME')[1] + ttl + 60, claim_key)

return claim_data

Step 4: Publish event. Kafka: claim_events

json8 lines
{
    "event_type": "claimed",
    "claim_id": "clm_abc123",
    "sale_id": "sale_xyz",
    "user_id": "usr_456",
    "expires_at": "2024-01-15T10:03:00Z",
    "remaining_inventory": 9523
}

Consumers:

  • AnalyticsWorker: Update real-time dashboard
  • WebsocketBroadcaster: Push inventory count to browsers

Step 5: Return response.

json7 lines
{
    "status": "claimed",
    "claim_id": "clm_abc123",
    "expires_at": "2024-01-15T10:03:00Z",
    "seconds_remaining": 180,
    "checkout_url": "/checkout/clm_abc123"
}
ExplainerFlash-sale claim: one Lua script, no overselling

A claim passes the per-minute rate limit (more than 10 attempts gets 429) and the claim:{sale_id}:{user_id} idempotency check, then one atomic Lua script decrements inventory (9,524 → 9,523), stores the claim with a 180 s TTL and records its expiry, and the claimed event goes to claim_events. Two users racing for the last unit run one after the other: A's DECR returns 0 and wins; B's returns -1, is restored to 0 and gets sold out. A retry returns the stored claim without a second decrement, and an unpaid claim's unit returns to the pool when the expiry worker runs, 60 s after the claim's TTL.

Why This Works#

You

"This design prevents overselling through several mechanisms:"

Overselling prevention
1. Atomic operations
  • The Lua script runs atomically in Redis
  • No race condition between check and decrement
  • DECR is atomic, returns new value immediately
2. Idempotency
  • Checking existing claim BEFORE decrementing
  • Same user retrying gets same response
  • No double-claiming even under network issues
3. Bounded inventory
  • Counter starts at exactly 10,000
  • Can only decrement, never go negative (we restore on <0)
  • TTL ensures claims expire automatically
4. No database in hot path
  • Redis handles all claim logic
  • PostgreSQL only involved at checkout (much lower rate)
  • Removes database as bottleneck

Claim Expiry Handling#

You

"When a claim expires without checkout, we need to return inventory:"

pythonClaimExpiryWorker · 74 lines
class ClaimExpiryWorker:
    """
    Background worker that returns expired claims to inventory.
    Runs continuously, processing expired claims.
    """
    
    def __init__(self, redis_client):
        self.redis = redis_client
    
    async def run(self):
        """Main worker loop."""
        while True:
            await self.process_expired_claims()
            await asyncio.sleep(1)  # Check every second
    
    async def process_expired_claims(self):
        """Find and process expired claims."""
        
        now = int(time.time())
        
        # Get all active sales
        active_sales = await self.redis.smembers('active_sales')
        
        for sale_id in active_sales:
            expiry_key = f'claims:expiry:{sale_id}'
            inventory_key = f'inventory:{sale_id}'
            
            # Get claims that have expired
            expired = await self.redis.zrangebyscore(
                expiry_key,
                min=0,
                max=now
            )
            
            for claim_key in expired:
                # Atomically return inventory and remove claim
                await self.return_claim_to_inventory(
                    sale_id, claim_key, inventory_key, expiry_key
                )
    
    async def return_claim_to_inventory(
        self, sale_id, claim_key, inventory_key, expiry_key
    ):
        """Return a single expired claim to inventory."""
        
        # Lua script for atomic return
        script = """
        local claim_key = KEYS[1]
        local inventory_key = KEYS[2]
        local expiry_key = KEYS[3]
        
        -- Checkout removes the claim from this set, so if it is still here
        -- it was never checked out. (Don't test EXISTS: the claim key has its
        -- own TTL and has usually expired already, which would look like a
        -- checkout and leak the unit forever.)
        if redis.call('ZREM', expiry_key, claim_key) == 0 then
            return 0  -- already checked out or already returned
        end
        
        redis.call('DEL', claim_key)  -- may already be gone via its TTL
        redis.call('INCR', inventory_key)
        
        return 1
        """
        
        # redis-py signature: eval(script, numkeys, *keys_and_args)
        returned = await self.redis.eval(
            script, 3,
            claim_key, inventory_key, expiry_key
        )
        
        if returned:
            # Publish event
            await self.publish_claim_expired(sale_id, claim_key)
Interviewer

"Nice. What about the checkout flow? How do you handle payment failures and ensure orders aren't double-charged?"

Quick check4 questions · +5 XP each

Claims without overselling

  1. Q1/4 Two users try to claim the last unit at the same moment. Why does the design run the check and the decrement inside one Redis Lua script?

    Answer & explanation

    Answer: A

    With separate GET-then-DECR calls, both users could see "1 left" and both succeed: an oversell. Inside one atomic script there is no gap to race through.

  2. Q2/4 The script's DECR returns -1. What happens?

    Answer & explanation

    Answer: B

    A negative result means there was no unit left, so the script restores the counter and rejects the claim. The count can never stay below zero.

  3. Q3/4 A user's claim succeeds, but the response is lost and the app retries. What does the retry get?

    Answer & explanation

    Answer: B

    The claim:{sale_id}:{user_id} key is checked first, so a retry returns the stored claim. Idempotency at the claim step prevents double-claiming.

  4. Q4/4 Why is PostgreSQL kept out of the claim path entirely?

    Answer & explanation

    Answer: A

    Redis handles the hot, atomic counter. The database is only touched for about 10,000 orders over minutes, which it handles easily.

Your turn · checkpoint 2not answered

Before checkout: the charge that times out

A user with a valid claim clicks "Complete purchase". The Stripe call has a 10-second timeout, and it fires with no answer. Should checkout mark the payment failed? How do you find out what happened without risking a second charge? And what should a user see if the Stripe circuit breaker is already open when they click?

Think it through, or jot it down. Then compare.

One strong answer

  • Don't mark it failed: a timeout is ambiguous; the charge may have gone through.
  • Find out safely: every charge carries an idempotency key derived from the claim ID. Replaying the same request with the same key returns the original charge if it went through, and charges once if it didn't.
  • Circuit open: answer at once with a 503 "payment temporarily unavailable" instead of waiting 10 seconds. The claim is still valid for its 3 minutes, so the user can retry, and you never confirm an order without a payment.

Phase 5 · Stage 5 of 9 · 10 min budget · 7 min read

Phase 5: Deep Dive - Checkout Flow (10 minutes)#

You

"The checkout flow is where everything comes together — timeouts, idempotency, circuit breakers. Let me walk through it."

Checkout Architecture#

DiagramCheckout flow
%% title: Checkout flow
flowchart TD
  START(["User clicks “Complete purchase”"]):::client --> V{"Step 1 · Validate claim"}:::core
  V -->|"no claim data"| G410(["410 Gone<br/>Your claim has expired"]):::bad
  V -->|"checkout_started"| C409(["409 Conflict<br/>Checkout already in progress"]):::bad
  V -->|"mark checkout started"| I{"Step 2 · Idempotency check<br/>idempotency:{claim_id}"}:::core
  I -->|"completed"| SAME(["Return same successful response"]):::ok
  I -->|"processing"| P409(["409 Payment in progress"]):::bad
  I -->|"not found, or failed (allow retry)"| PAY{"Step 3 · Process payment<br/>(with timeout + circuit breaker)"}:::hot
  PAY -->|"circuit open"| E503(["503 Payment service<br/>temporarily unavailable"]):::bad
  PAY -->|"timeout, payment not verified"| E504(["504 Payment timeout -<br/>please try again"]):::bad
  PAY -->|"StripeError"| E400(["400 Payment failed"]):::bad
  PAY -->|"success"| ORD@{ icon: "sd:database", label: "Step 4 · Create order<br/>(transactionally)" }
  ORD --> EV@{ icon: "sd:queue", label: "Step 5 · Publish events<br/>(async, non-blocking)" }
  EV --> OK(["Step 6 · Return success"]):::ok
  class ORD store
  class EV queue

Step 1: Validate claim. Check claim exists and hasn't expired:

python10 lines
claim_data = redis.get(f"claim:{sale_id}:{user_id}")

if not claim_data:
    return 410 Gone "Your claim has expired"

# Mark checkout started (prevent concurrent checkouts). Atomic SET NX on a
# separate key: the claim itself is a JSON string, so HSET on it would fail
# (WRONGTYPE), and a separate check-then-set would race.
if not redis.set(f"checkout:{claim_id}", timestamp, nx=True, ex=180):
    return 409 Conflict "Checkout already in progress"

Step 2: Idempotency check. Check if this checkout was already processed. Key: idempotency:{claim_id}

python15 lines
existing = await idempotency_store.get(claim_id)
if existing:
    if existing.status == "completed":
        return existing.response  # Return same successful response
    if existing.status == "processing":
        return 409 "Payment in progress"
    if existing.status == "failed":
        # Allow retry
        pass

# Record processing started
await idempotency_store.set(claim_id, {
    status: "processing",
    started_at: now
}, ttl=3600)

Step 3: Process payment (with timeout + circuit breaker).

python33 lines
# Check circuit breaker first (Day 3 pattern)
if stripe_circuit.is_open:
    await idempotency_store.set(claim_id, {status: "failed"})
    return 503 "Payment service temporarily unavailable"

# Call Stripe with timeout (Day 1 pattern)
# Note: Stripe has deprecated the Charges API for creating payments; new code
# would use PaymentIntents, with the same idempotency_key.
try:
    payment = await asyncio.wait_for(
        stripe.charges.create(
            amount=sale.discounted_price,
            currency="usd",
            customer=user.stripe_customer_id,
            idempotency_key=f"checkout_{claim_id}",  # Day 2 pattern
            metadata={"sale_id": sale_id, "claim_id": claim_id}
        ),
        timeout=10.0  # 10 second timeout
    )
    stripe_circuit.record_success()

except asyncio.TimeoutError:
    stripe_circuit.record_failure()
    # Don't fail yet - payment might have succeeded!
    payment = await verify_payment_status(claim_id)
    if not payment:
        await idempotency_store.set(claim_id, {status: "failed"})
        return 504 "Payment timeout - please try again"

except StripeError as e:
    stripe_circuit.record_failure()
    await idempotency_store.set(claim_id, {status: "failed", error: e})
    return 400 f"Payment failed: {e.message}"

Step 4: Create order (transactionally).

python23 lines
async with database.transaction():
    # Create order record
    order = await Order.create(
        id=generate_order_id(),
        user_id=user_id,
        sale_id=sale_id,
        claim_id=claim_id,
        amount=payment.amount,
        payment_id=payment.id,
        status="confirmed"
    )

    # Record in idempotency store
    await idempotency_store.set(claim_id, {
        status: "completed",
        order_id: order.id,
        response: {order_id: order.id, status: "confirmed"}
    }, ttl=86400 * 7)  # Keep for 7 days

    # Delete claim (inventory already decremented)
    await redis.delete(f"claim:{sale_id}:{user_id}")
    await redis.zrem(f"claims:expiry:{sale_id}",
                     f"claim:{sale_id}:{user_id}")

Step 5: Publish events (async, non-blocking).

python16 lines
# Publish to Kafka - don't wait for confirmation
await kafka.send_async("order_events", {
    "event_type": "order.created",
    "order_id": order.id,
    "user_id": user_id,
    "sale_id": sale_id,
    "amount": payment.amount
})

# Queue confirmation email (Day 4 - webhook pattern)
await notification_queue.send({
    "type": "email",
    "template": "order_confirmation",
    "user_id": user_id,
    "order_id": order.id
})

Step 6: Return success.

json6 lines
{
    "status": "confirmed",
    "order_id": "ord_xyz789",
    "message": "Congratulations! Your order is confirmed.",
    "receipt_url": "/orders/ord_xyz789/receipt"
}

Complete Checkout Code#

pythonCheckoutService · 246 lines
from datetime import datetime, timezone

class CheckoutService:
    """
    Checkout service implementing all Week 1-2 patterns:
    - Timeouts (Day 1)
    - Idempotency (Day 2)
    - Circuit Breakers (Day 3)
    - Async Events (Day 4)
    """
    
    def __init__(
        self,
        redis: Redis,
        db: Database,
        stripe_client: Stripe,
        kafka: KafkaProducer,
        idempotency_store: IdempotencyStore,
        circuit_breaker: CircuitBreaker
    ):
        self.redis = redis
        self.db = db
        self.stripe = stripe_client
        self.kafka = kafka
        self.idempotency = idempotency_store
        self.circuit = circuit_breaker
        
        # Timeout configuration (Day 1)
        self.payment_timeout = 10.0
        self.db_timeout = 5.0
        self.total_timeout = 25.0  # Budget for entire checkout
    
    async def checkout(
        self, 
        user_id: str, 
        sale_id: str, 
        claim_id: str
    ) -> CheckoutResult:
        """
        Process checkout with full reliability guarantees.
        """
        
        # Start timeout budget (Day 1)
        deadline = time.time() + self.total_timeout
        
        # Step 1: Validate claim
        claim = await self._validate_claim(user_id, sale_id, claim_id)
        if not claim:
            return CheckoutResult(
                success=False,
                error="Claim expired or invalid"
            )
        
        # Step 2: Idempotency check (Day 2)
        existing = await self.idempotency.get(claim_id)
        if existing:
            if existing['status'] == 'completed':
                return CheckoutResult(
                    success=True,
                    order_id=existing['order_id'],
                    idempotent=True
                )
            elif existing['status'] == 'processing':
                return CheckoutResult(
                    success=False,
                    error="Checkout already in progress"
                )
        
        # Mark as processing
        await self.idempotency.set(claim_id, {
            'status': 'processing',
            'started_at': time.time()
        })
        
        try:
            # Step 3: Process payment (Day 1 timeout + Day 3 circuit breaker)
            payment = await self._process_payment(
                user_id, sale_id, claim_id, deadline
            )
            
            # Step 4: Create order (with remaining timeout budget)
            remaining_time = deadline - time.time()
            if remaining_time <= 0:
                raise TimeoutError("Checkout timeout exceeded")
            
            order = await asyncio.wait_for(
                self._create_order(user_id, sale_id, claim_id, payment),
                timeout=remaining_time
            )
            
            # Step 5: Finalize idempotency record
            await self.idempotency.set(claim_id, {
                'status': 'completed',
                'order_id': order.id,
                'completed_at': time.time()
            }, ttl=86400 * 7)
            
            # Step 6: Publish events (async, non-blocking) (Day 4)
            asyncio.create_task(self._publish_order_events(order))
            
            return CheckoutResult(
                success=True,
                order_id=order.id
            )
        
        except PaymentError as e:
            await self.idempotency.set(claim_id, {
                'status': 'failed',
                'error': str(e)
            })
            return CheckoutResult(success=False, error=str(e))
        
        except TimeoutError:
            # Don't mark as failed - payment might have succeeded
            # Let user retry, idempotency will handle it
            return CheckoutResult(
                success=False,
                error="Request timeout - please try again"
            )
    
    async def _process_payment(
        self, 
        user_id: str, 
        sale_id: str, 
        claim_id: str,
        deadline: float
    ) -> PaymentResult:
        """Process payment with circuit breaker and timeout."""
        
        # Check circuit breaker (Day 3)
        if self.circuit.is_open():
            raise PaymentError("Payment service temporarily unavailable")
        
        # Calculate remaining timeout
        remaining = deadline - time.time()
        timeout = min(self.payment_timeout, remaining)
        
        if timeout <= 0:
            raise TimeoutError("No time remaining for payment")
        
        # Charges API shown for brevity; Stripe has deprecated it for new payments (use PaymentIntents)
        charge_params = dict(
            amount=await self._get_sale_price(sale_id),
            currency='usd',
            customer=await self._get_stripe_customer(user_id),
            idempotency_key=f"checkout_{claim_id}",  # Day 2
            metadata={
                'sale_id': sale_id,
                'claim_id': claim_id,
                'user_id': user_id
            }
        )
        
        try:
            # Call Stripe with idempotency key (Day 2)
            payment = await asyncio.wait_for(
                self.stripe.charges.create(**charge_params),
                timeout=timeout
            )
            
            self.circuit.record_success()
            return payment
        
        except asyncio.TimeoutError:
            self.circuit.record_failure()
            
            # Payment might have succeeded - verify by replaying the SAME
            # request with the SAME idempotency key: Stripe returns the
            # original charge if it went through, and charges once if not
            payment = await self._verify_payment(charge_params)
            if payment:
                return payment
            
            raise TimeoutError("Payment timeout")
        
        except StripeError as e:
            self.circuit.record_failure()
            raise PaymentError(str(e))
    
    async def _verify_payment(self, charge_params: dict) -> Optional[PaymentResult]:
        """
        Timeout recovery: replay the charge with the same idempotency key.
        (charges.list can't filter by metadata, and the Search API is
        eventually consistent, lagging up to about a minute.)
        """
        
        try:
            return await asyncio.wait_for(
                self.stripe.charges.create(**charge_params),
                timeout=5.0
            )
        except Exception:
            return None
    
    async def _create_order(
        self, 
        user_id: str, 
        sale_id: str, 
        claim_id: str,
        payment: PaymentResult
    ) -> Order:
        """Create order in database."""
        
        async with self.db.transaction():
            order = await self.db.orders.create(
                id=generate_order_id(),
                user_id=user_id,
                sale_id=sale_id,
                claim_id=claim_id,
                payment_id=payment.id,
                amount=payment.amount,
                status='confirmed',
                created_at=datetime.now(timezone.utc)
            )
            
            # Clean up claim, and take it out of the expiry set so the
            # expiry worker never returns this (sold) unit to the pool
            await self.redis.delete(f"claim:{sale_id}:{user_id}")
            await self.redis.zrem(f"claims:expiry:{sale_id}",
                                  f"claim:{sale_id}:{user_id}")
            
            return order
    
    async def _publish_order_events(self, order: Order):
        """Publish events for order (non-blocking)."""
        
        # Order created event
        await self.kafka.send('order_events', {
            'event_type': 'order.created',
            'order_id': order.id,
            'user_id': order.user_id,
            'sale_id': order.sale_id,
            'amount': order.amount,
            'timestamp': datetime.now(timezone.utc).isoformat()
        })
        
        # Queue confirmation email
        await self.kafka.send('notification_events', {
            'type': 'email',
            'template': 'order_confirmation',
            'recipient_id': order.user_id,
            'data': {
                'order_id': order.id,
                'amount': order.amount
            }
        })
Interviewer

"I like how you've integrated all the patterns. Now, let's talk about the notification system. You mentioned millions of notifications when a sale starts. How do you handle that?"

Quick check3 questions · +5 XP each

Checkout reliability

  1. Q1/3 The Stripe charge call times out after 10 seconds. Why doesn't checkout immediately mark the payment failed?

    Answer & explanation

    Answer: C

    A timeout is ambiguous. Replaying with the same key returns the original charge if it went through, so you neither tell a charged user it failed nor charge them again.

  2. Q2/3 The Stripe call carries an idempotency key derived from the claim ID. What does that buy you?

    Answer & explanation

    Answer: C

    Stripe deduplicates requests with the same key, so retries after timeouts or crashes are safe end to end.

  3. Q3/3 The Stripe circuit breaker is open when a user clicks "Complete purchase". What does the user get?

    Answer & explanation

    Answer: B

    Failing fast frees the checkout thread and gives the user a clear message while their claim is still valid. You cannot confirm an order without a payment.

Phase 6 · Stage 6 of 9 · 5 min budget · 4 min read

Phase 6: Deep Dive - Notification System (5 minutes)#

You

"The notification system is where the webhook patterns from Day 4 come in. Let me explain."

Pre-computation Strategy#

Pre-computation strategy

The problem

  • Sale starts at 10:00:00
  • Need to notify 2.4M users
  • Users expect to know within seconds
  • Computing recipients at 10:00:00 = disaster

The solution

  • Pre-compute recipient list before sale starts
  • Store in ready-to-send format
  • At 10:00:00, just fan out to workers

Notification Architecture#

DiagramNotification pipeline
%% title: Notification pipeline
flowchart TD
  subgraph PRE["T-10 minutes (Pre-computation)"]
    direction TB
    Q@{ icon: "sd:database", label: "users query<br/>Result: 2.4M rows" } --> NB@{ icon: "tech:redis", label: "Redis lists<br/>(partitioned by user_id hash)<br/>notification_batch:{sale_id}:0 … :23" }
  end
  subgraph T0["T=0 (Sale Starts)"]
    direction TB
    SCH["Scheduler"]:::core --> NT@{ icon: "tech:kafka", label: "Kafka topic:<br/>notification_triggers" }
  end
  subgraph WK["Workers Process Batches (Parallel)"]
    direction TB
    W@{ icon: "sd:worker", label: "24 workers<br/>each handles 100k users" }
  end
  subgraph SND["Sender Pools (Final Delivery)"]
    direction TB
    PP@{ icon: "sd:bell", label: "Push Notification Pool<br/>(20 workers)<br/>~50,000/second total" }
    EP@{ icon: "sd:mail", label: "Email Pool<br/>(10 workers)<br/>~30,000/second total" }
  end
  NT --> W
  NB --> W
  W --> PP
  W --> EP
  PP --> FCM@{ icon: "tech:firebase", label: "Firebase Cloud Messaging<br/>for Android" }
  PP --> APNS@{ icon: "tech:apple", label: "APNs for iOS" }
  EP --> SG@{ icon: "tech:sendgrid", label: "SendGrid / SES" }
  class Q store
  class NB cache
  class NT queue
  class W,PP,EP service
  class FCM,APNS,SG external

T-10 minutes (pre-computation):

sql6 lines
SELECT user_id, email, push_token, notification_preferences
FROM users
WHERE (user_id IN (SELECT user_id FROM product_watchers
                   WHERE product_id = 'xyz')
       OR subscribed_to_deals = true)
AND notification_enabled = true

Result: 2.4M rows. Store in: Redis lists (partitioned by user_id hash; workers read them with LRANGE)

Key Users
notification_batch:{sale_id}:0 [users 0-100k]
notification_batch:{sale_id}:1 [users 100k-200k]
...
notification_batch:{sale_id}:23 [users 2.3M-2.4M]

T=0 (sale starts): Scheduler publishes to Kafka, topic notification_triggers:

json5 lines
{
    "sale_id": "xyz",
    "batch_count": 24,
    "trigger_time": "2024-01-15T10:00:00Z"
}

Workers process batches (parallel): 24 workers, each handles 100k users. Worker 0:

  • Read notification_batch:{sale_id}:0 from Redis
  • For each user:
    • Check preferences (email? push? both?)
    • Queue to appropriate sender

All workers run simultaneously = 2.4M queued in ~10 seconds.

Sender pools (final delivery):

  • Push Notification Pool (20 workers): Firebase Cloud Messaging for Android, APNs for iOS. Rate: ~50,000/second total (FCM's default quota is 600,000 messages per minute per project, about 10,000/second, so this rate needs a quota increase agreed with Firebase Support ahead of the sale).
  • Email Pool (10 workers, batched API calls): SendGrid / SES. Rate: ~30,000/second total. Lower priority (30 second SLA, not 5 second).

Notification Worker Implementation#

pythonNotificationWorker · 107 lines
class NotificationWorker:
    """
    Notification worker implementing Day 4 patterns:
    - At-least-once delivery
    - Retry with backoff
    - Dead letter queue
    - Circuit breaker for external services
    """
    
    def __init__(
        self,
        redis: Redis,
        kafka_consumer: KafkaConsumer,
        push_service: PushNotificationService,
        email_service: EmailService,
        circuit_breakers: Dict[str, CircuitBreaker]
    ):
        self.redis = redis
        self.consumer = kafka_consumer
        self.push = push_service
        self.email = email_service
        self.circuits = circuit_breakers
    
    async def process_batch(self, trigger: NotificationTrigger):
        """Process a notification batch for a sale."""
        
        batch_key = f"notification_batch:{trigger.sale_id}:{trigger.batch_id}"
        
        # Get users from pre-computed batch
        users = await self.redis.lrange(batch_key, 0, -1)
        
        for user_data in users:
            user = json.loads(user_data)
            
            try:
                await self._send_notification(user, trigger)
            except Exception as e:
                # Log and continue - don't let one failure stop batch
                logger.error(
                    "Notification failed",
                    user_id=user['id'],
                    error=str(e)
                )
    
    async def _send_notification(self, user: dict, trigger: NotificationTrigger):
        """Send notification to a single user."""
        
        # Idempotency check (Day 2)
        idem_key = f"notif_sent:{trigger.sale_id}:{user['id']}"
        if await self.redis.exists(idem_key):
            return  # Already sent
        
        # Send based on preferences
        if user.get('push_enabled') and user.get('push_token'):
            await self._send_push(user, trigger)
        
        if user.get('email_enabled') and user.get('email'):
            await self._queue_email(user, trigger)
        
        # Mark as sent (idempotency)
        await self.redis.set(idem_key, '1', ex=3600)  # 1 hour TTL
    
    async def _send_push(self, user: dict, trigger: NotificationTrigger):
        """Send push notification with circuit breaker."""
        
        # Check circuit breaker (Day 3)
        provider = 'fcm' if user['platform'] == 'android' else 'apns'
        
        if self.circuits[provider].is_open():
            # Queue for retry later
            await self._queue_for_retry(user, trigger, 'push')
            return
        
        try:
            await asyncio.wait_for(
                self.push.send(
                    token=user['push_token'],
                    title="⚡ Lightning Deal Live!",
                    body=f"The {trigger.product_name} deal is live! Tap to claim.",
                    data={'sale_id': trigger.sale_id}
                ),
                timeout=2.0  # Day 1: Timeout
            )
            
            self.circuits[provider].record_success()
            
        except asyncio.TimeoutError:
            self.circuits[provider].record_failure()
            await self._queue_for_retry(user, trigger, 'push')
        
        except Exception as e:
            self.circuits[provider].record_failure()
            logger.error("Push failed", error=str(e))
    
    async def _queue_email(self, user: dict, trigger: NotificationTrigger):
        """Queue email for async sending (Day 4 pattern)."""
        
        await self.kafka.send('email_queue', {
            'recipient': user['email'],
            'template': 'flash_sale_live',
            'data': {
                'user_name': user['name'],
                'product_name': trigger.product_name,
                'sale_url': f"https://megamart.com/sale/{trigger.sale_id}"
            },
            'idempotency_key': f"sale_email:{trigger.sale_id}:{user['id']}"
        })
Interviewer

"Good. One more thing — how does the flash sale scheduling work? You mentioned these happen every hour. Is that a cron job?"

Your turn · checkpoint 3not answered

Before scheduling: two schedulers, one sale

The 10:00 sale must start exactly once, and the scheduler runs on several servers. During a failover, the old leader and the new one both see that the sale is due. What keeps it from starting twice, and what stops a stale leader that hasn't noticed it was replaced?

Think it through, or jot it down. Then compare.

One strong answer

  • One scheduler at a time: leader election through etcd, with a lease the leader must keep renewing.
  • The database decides: starting a sale is a conditional UPDATE ... SET status = 'active' WHERE status = 'scheduled'. Whichever runs first changes the row; the other matches nothing and backs off.
  • Stale leaders: each leader carries a fencing token that rises with every election, and the write records it, so an old leader's token can be rejected.

Phase 7 · Stage 7 of 9 · 5 min budget · 5 min read

Phase 7: Deep Dive - Sale Scheduling (5 minutes)#

You

"Yes, this is where the distributed cron patterns from Day 5 come in. We need to ensure each sale starts exactly once, even across multiple servers."

Sale Scheduling Architecture#

DiagramDistributed sale scheduler
%% title: Distributed sale scheduler
flowchart TD
  subgraph ETCD["etcd Coordination Layer"]
    direction TB
    E1@{ icon: "tech:etcd", label: "/elections/sale-scheduler/leader" }
    E2@{ icon: "tech:etcd", label: "/config/sales/upcoming" }
    E3@{ icon: "tech:etcd", label: "/fencing/current_token" }
  end
  ETCD -->|"Only leader runs"| SVC["Sale Scheduler Service"]:::core
  class E1,E2,E3 store

Sale Scheduler Service

Every minute:

  1. Check for sales starting in next 10 minutes
  2. Pre-compute notification recipients
  3. Warm up Redis with sale data

At scheduled time:

  1. Verify fencing token
  2. Initialize inventory counter
  3. Mark sale as active
  4. Trigger notifications

At sale end:

  1. Mark sale as ended
  2. Return unclaimed inventory
  3. Generate sale report

Sale Scheduler Implementation#

pythonFlashSaleScheduler · 220 lines
from datetime import datetime, timezone

class FlashSaleScheduler:
    """
    Distributed flash sale scheduler using Day 5 patterns:
    - Leader election
    - Fencing tokens
    - Exactly-once scheduling
    - Heartbeat monitoring
    """
    
    def __init__(
        self,
        etcd_client,
        redis: Redis,
        db: Database,
        kafka: KafkaProducer,
        notification_service: NotificationService
    ):
        self.etcd = etcd_client
        self.redis = redis
        self.db = db
        self.kafka = kafka
        self.notifications = notification_service
        
        self.is_leader = False
        self.fencing_token = 0
        self.leader_election = LeaderElection(
            etcd_client,
            election_name="sale-scheduler"
        )
    
    async def start(self):
        """Start the scheduler."""
        
        # Start leader election
        asyncio.create_task(
            self.leader_election.campaign(
                on_elected=self._on_elected,
                on_demoted=self._on_demoted
            )
        )
        
        # Main scheduler loop
        while True:
            if self.is_leader:
                await self._scheduler_tick()
            await asyncio.sleep(1)
    
    def _on_elected(self):
        """Called when this instance becomes leader."""
        self.is_leader = True
        # Strictly increasing across elections (e.g. the etcd revision of our
        # leader key). Not wall-clock time: clocks can go backwards.
        self.fencing_token = self.leader_election.leader_revision
        logger.info("Became sale scheduler leader", token=self.fencing_token)
    
    def _on_demoted(self):
        """Called when this instance loses leadership."""
        self.is_leader = False
        logger.info("Lost sale scheduler leadership")
    
    async def _scheduler_tick(self):
        """Main scheduling loop tick."""
        
        now = datetime.now(timezone.utc)
        
        # Find sales that need action
        await self._prepare_upcoming_sales(now)
        await self._start_due_sales(now)
        await self._end_expired_sales(now)
    
    async def _prepare_upcoming_sales(self, now: datetime):
        """Prepare sales starting in next 10 minutes."""
        
        upcoming = await self.db.query("""
            SELECT * FROM flash_sales
            WHERE status = 'scheduled'
            AND start_time BETWEEN $1 AND $2
            AND preparation_started = false
        """, now, now + timedelta(minutes=10))
        
        for sale in upcoming:
            # Mark as preparing (idempotent)
            await self.db.execute("""
                UPDATE flash_sales
                SET preparation_started = true,
                    preparation_token = $1
                WHERE id = $2 AND preparation_started = false
            """, self.fencing_token, sale.id)
            
            # Pre-compute notifications (async)
            asyncio.create_task(
                self.notifications.prepare_recipients(sale)
            )
            
            # Warm up Redis
            await self._warm_up_sale_data(sale)
    
    async def _start_due_sales(self, now: datetime):
        """Start sales that are due."""
        
        due_sales = await self.db.query("""
            SELECT * FROM flash_sales
            WHERE status = 'scheduled'
            AND start_time <= $1
            AND preparation_started = true
        """, now)
        
        for sale in due_sales:
            await self._start_sale(sale)
    
    async def _start_sale(self, sale):
        """
        Start a single sale.
        Uses fencing token to prevent double-start.
        """
        
        # Atomically claim sale start with fencing token
        claimed = await self.db.execute("""
            UPDATE flash_sales
            SET status = 'active',
                started_at = NOW(),
                started_by_token = $1
            WHERE id = $2
            AND status = 'scheduled'
            AND (started_by_token IS NULL OR started_by_token < $1)
            RETURNING id
        """, self.fencing_token, sale.id)
        
        if not claimed:
            logger.warning("Could not claim sale start", sale_id=sale.id)
            return
        
        logger.info("Starting sale", sale_id=sale.id, token=self.fencing_token)
        
        # Initialize inventory in Redis
        await self.redis.set(
            f"inventory:{sale.id}",
            sale.total_inventory
        )
        
        # Add to active sales set
        await self.redis.sadd('active_sales', sale.id)
        
        # Trigger notifications
        await self.kafka.send('notification_triggers', {
            'type': 'sale_started',
            'sale_id': sale.id,
            'product_name': sale.product_name,
            'fencing_token': self.fencing_token
        })
        
        # Schedule sale end
        asyncio.create_task(
            self._schedule_sale_end(sale.id, sale.duration_minutes)
        )
    
    async def _schedule_sale_end(self, sale_id: str, duration_minutes: int):
        """Schedule the end of a sale."""
        
        await asyncio.sleep(duration_minutes * 60)
        
        if self.is_leader:  # Only end if still leader
            await self._end_sale(sale_id)
    
    async def _end_sale(self, sale_id: str):
        """End a sale and clean up."""
        
        # Update database with fencing token
        ended = await self.db.execute("""
            UPDATE flash_sales
            SET status = 'ended',
                ended_at = NOW(),
                ended_by_token = $1
            WHERE id = $2
            AND status = 'active'
            RETURNING id
        """, self.fencing_token, sale_id)
        
        if not ended:
            return
        
        logger.info("Ending sale", sale_id=sale_id)
        
        # Remove from active sales
        await self.redis.srem('active_sales', sale_id)
        
        # Return unclaimed inventory to report
        remaining = await self.redis.get(f"inventory:{sale_id}")
        
        # Publish sale ended event
        await self.kafka.send('sale_events', {
            'type': 'sale_ended',
            'sale_id': sale_id,
            'remaining_inventory': int(remaining) if remaining else 0,
            'fencing_token': self.fencing_token
        })
        
        # Clean up Redis (keep for a while for debugging)
        await self.redis.expire(f"inventory:{sale_id}", 3600)
    
    async def _warm_up_sale_data(self, sale):
        """Pre-load sale data into Redis."""
        
        await self.redis.hset(f"sale:{sale.id}", mapping={
            'product_id': sale.product_id,
            'product_name': sale.product_name,
            'original_price': sale.original_price,
            'sale_price': sale.sale_price,
            'total_inventory': sale.total_inventory,
            'start_time': sale.start_time.isoformat(),
            'end_time': sale.end_time.isoformat()
        })
        
        # Set TTL beyond sale end
        await self.redis.expire(
            f"sale:{sale.id}",
            sale.duration_minutes * 60 + 3600
        )
Quick check3 questions · +5 XP each

Notifications and sale scheduling

  1. Q1/3 A notification worker crashes halfway through its 100,000-user batch and the batch is reprocessed. How are double notifications avoided?

    Answer & explanation

    Answer: A

    At-least-once processing plus an idempotency key per user and sale makes reprocessing safe, the same pattern as Day 4 webhooks.

  2. Q2/3 During a failover, two scheduler instances both try to start the same sale. What ensures it starts only once?

    Answer & explanation

    Answer: B

    The database row is the source of truth: whichever UPDATE runs first changes the status, and the other matches no row. Fencing tokens additionally reject stale leaders.

  3. Q3/3 The FCM circuit breaker is open while the sale launch notifications are going out. What does the push sender do with Android users?

    Answer & explanation

    Answer: A

    An open circuit means "stop calling FCM for now", not "give up". Queued retries deliver once the provider recovers.

Phase 8 · Stage 8 of 9 · 3 min budget · 2 min read

Phase 8: Monitoring and Observability (3 minutes)#

Transcript
Interviewer

"How would you monitor this system? What alerts would you set up?"

You

"Monitoring is critical for a flash sale system. Here's my approach:"

Key Metrics Dashboard#

Flash sale real-time dashboard

Current sale: iPhone 15 Pro Lightning Deal

  • 2,847 — inventory remaining (▼ 450/min)
  • 732 — claims awaiting checkout
  • 6,421 — checkouts completed (▲ 280/min)
  • 45,231 — requests/sec (peak: 150k)
  • 0.12% — error rate (budget: 1%)
  • 247ms — P99 latency (budget: 500ms)
Circuit breakers
Dependency State Success
Stripe API CLOSED 99.8% success
Redis Cluster CLOSED 100% success
Push (FCM) CLOSED 97.2% success
Push (APNs) CLOSED 99.9% success
Email (SES) CLOSED 99.7% success
Notifications
Metric Value
Total to send 2,400,000
Sent 2,387,421 (99.5%)
Failed 12,579 (0.5%)
Pending 0
Time elapsed 18 seconds

Critical Alerts#

yaml73 lines
# Prometheus Alert Rules

groups:
  - name: flash_sale_critical
    rules:
      # INVENTORY ALERTS
      - alert: InventoryOversold
        expr: flash_sale_inventory < 0
        for: 0s
        labels:
          severity: critical
        annotations:
          summary: "CRITICAL: Inventory oversold for sale {{ $labels.sale_id }}"
          
      - alert: InventoryNotDecreasing
        expr: deriv(flash_sale_inventory[1m]) >= 0 and flash_sale_active == 1  # deriv() for gauges; PromQL operators are lowercase
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Inventory not moving during active sale"

      # LATENCY ALERTS
      - alert: ClaimLatencyHigh
        expr: histogram_quantile(0.99, rate(claim_request_duration_seconds_bucket[1m])) > 0.5
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "P99 claim latency {{ $value }}s exceeds 500ms"
          
      - alert: CheckoutLatencyHigh
        expr: histogram_quantile(0.99, rate(checkout_request_duration_seconds_bucket[1m])) > 3
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "P99 checkout latency {{ $value }}s exceeds 3s"

      # ERROR RATE ALERTS
      - alert: HighErrorRate
        expr: sum(rate(http_requests_total{status=~"5.."}[1m])) / sum(rate(http_requests_total[1m])) > 0.01
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Error rate {{ $value | humanizePercentage }} exceeds 1%"

      # CIRCUIT BREAKER ALERTS
      - alert: CircuitBreakerOpen
        expr: circuit_breaker_state == 2  # 2 = OPEN
        for: 0s
        labels:
          severity: critical
        annotations:
          summary: "Circuit breaker {{ $labels.name }} is OPEN"

      # INFRASTRUCTURE ALERTS
      - alert: RedisHighLatency
        expr: redis_command_duration_seconds_p99 > 0.01
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "Redis P99 latency {{ $value }}s exceeds 10ms"
          
      - alert: KafkaLag
        expr: kafka_consumer_lag > 10000
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Kafka consumer lag {{ $value }} exceeds 10k"

Phase 9 · Stage 9 of 9 · 2 min budget · 1 min read

Phase 9: Wrap-Up and Extensions (2 minutes)#

Transcript
Interviewer

"We're almost out of time. Let's quickly discuss: what would you do differently at 10x the scale?"

You

"Great question. At 10x scale (50M concurrent users, 100K inventory), I'd make these changes:"

Scaling to 10x#

Current → 10x scale
Area Current 10x Solution
Traffic handling 150K req/sec peak 1.5M req/sec peak Geographic distribution (multiple regions) · Regional inventory pools · Edge caching for sale pages · WebSocket for inventory updates (reduce polling)
Inventory management Single Redis cluster Redis cluster can't handle atomic ops at this rate Shard inventory by region · Eventual consistency for display count · Strong consistency only for actual claims · Consider CockroachDB for distributed transactions
Notification delivery 2.4M in 30 seconds 24M in 30 seconds Pre-send notifications (hint: "sale starting in 5 seconds") · Progressive rollout by user segment · More aggressive batching to FCM/APNs
Database PostgreSQL primary + 2 replicas Single primary becomes bottleneck Vitess or CockroachDB for horizontal scaling · Event sourcing for order creation · Read from cache, async write to DB

Alternative Approaches Considered#

You

"I should also mention alternatives I considered but didn't choose:"

Option Instead of Alternative Pros Cons Decision
Queue-Based Claims Direct Redis DECR for claims Put all claim requests in a queue, process in order Perfect ordering, simpler consistency Higher latency, doesn't match "instant" UX requirement Direct Redis better for our latency requirements
Reservation-Style Inventory Decrement on claim Decrement only on successful checkout No need for claim expiry handling Inventory shows as available but isn't, worse UX Claim-then-checkout better for transparency
Distributed Lock per Item Atomic counter Lock each inventory unit individually Fine-grained control 10,000 locks = complexity nightmare Atomic counter much simpler
Quick check2 questions · +5 XP each

Scaling and alternatives

  1. Q1/2 Why was a queue-based claim system (process all claims in order) rejected?

    Answer & explanation

    Answer: C

    A queue gives perfect ordering and simple consistency, but users would wait in line. Atomic Redis decrements give instant answers while still preventing oversells.

  2. Q2/2 At 10x scale, the plan uses eventual consistency for the displayed inventory count but strong consistency for claims. Why split it?

    Answer & explanation

    Answer: B

    Match the guarantee to the harm: the badge on the page can lag, while the decrement that grants a unit must be exact.

Wrap-up · 2 min

Interview Conclusion#

Transcript
Interviewer

"Excellent work. You've demonstrated strong understanding of distributed systems, handled the scale estimation well, and made good trade-off decisions. Any questions for me?"

You

"Thank you! I'd love to hear how MegaMart currently handles this, and what challenges you've faced in production."

Summary: Week 1-2 Concepts Applied#

Week 1 Concepts (Data at Scale)#

Concept Application in This Design
Partitioning Orders partitioned by date, notifications batched by user_id hash, Redis keys hash-tagged by sale
Replication Redis primaries + replicas, PostgreSQL primary + 2 read replicas
Rate Limiting Per-user claim limit (10/minute), WAF at the edge
Hot Keys One inventory counter per hype drop; CDN + 1-second cached count absorb page views
Session Store Redis session cache for logged-in shoppers

Week 2 Concepts (Failure-First Design)#

Day Concept Application
Day 1 Timeouts Payment timeout (10s), checkout budget (25s), claim validation
Day 2 Idempotency Claim idempotency key, checkout idempotency, Stripe idempotency_key
Day 3 Circuit Breakers Stripe circuit, FCM/APNs circuits, Redis circuit
Day 4 Webhooks Notification delivery, at-least-once semantics, retry with backoff
Day 5 Distributed Cron Sale scheduling, leader election, fencing tokens

Code Patterns Demonstrated#

1. Atomic operations
  • Redis Lua scripts for claim
  • Database transactions for orders
2. Idempotency implementation
  • Check-before-execute pattern
  • Idempotency store with TTL
3. Circuit breaker integration
  • Check before external calls
  • Record success/failure
  • Fallback behavior
4. Timeout budgets
  • Total budget for operation
  • Remaining budget propagation
  • Timeout on all external calls
5. Leader election
  • etcd-based election
  • Fencing token validation
  • Graceful failover
6. Event-driven architecture
  • Kafka for event publishing
  • Consumer groups for parallel processing
  • At-least-once delivery

Self-Assessment Checklist#

After studying this capstone, you should be able to:

After studying this capstone, you should be able to0/10 ready
  • Estimate traffic and storage requirements from business requirements
  • Design a system that handles massive traffic spikes
  • Implement atomic inventory operations without race conditions
  • Integrate idempotency at multiple levels (claim, checkout, notifications)
  • Apply circuit breakers to protect against external service failures
  • Design a notification system that delivers millions of messages quickly
  • Implement distributed scheduling that starts each sale once (conditional updates + fencing tokens)
  • Set up meaningful monitoring and alerting
  • Discuss trade-offs and alternatives clearly
  • Handle follow-up questions about scaling and edge cases

This capstone problem integrates all concepts from Weeks 1-2 of the System Design Mastery Series. Use this as a template for approaching similar interview problems.

this lesson in shorts · 1

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Boss · The Ultimate System Design Interview
Finish BossStages clear as you read them (0/9 so far). Skipping ahead doesn't count: marking complete unlocks after ~6 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

ShopifyBlog post · ~7 min

Surviving Flashes of High-Write Traffic Using Scriptable Load Balancers (Part I)

Emil Stolarsky · 2017
What
How Shopify survived recurring flash sales, such as Kylie Cosmetics drops, that once took down a whole database shard.
Key idea
A checkout throttle written in Lua on its Nginx/OpenResty edge applies back pressure, admitting buyers at a rate checkout can handle.
Why here
The capstone's core move: protect inventory and checkout by queueing demand at the edge instead of letting it reach the database.
taught here: Flash sale admission control
ShopifyBlog post · ~4 min

A Pods Architecture To Allow Shopify To Scale

2018
What
Why Shopify split its platform into pods after sharding its database in 2015.
Key idea
A pod is a set of shops on a fully isolated set of datastores, so a flash sale or failure in one pod cannot take down shops in another.
Why here
Applies failure-first thinking to the capstone: isolate the hot merchant's traffic so one sale cannot become a platform outage.
taught here: Blast radius isolation

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…