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.
The Interview Begins#
You walk into the interview room. The interviewer smiles and gestures to the whiteboard.
"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:
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
"Take a few minutes to think about this, then walk me through your approach. We have about 45 minutes."
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: Requirements Clarification (5 minutes)#
Before diving in, you take a breath and start asking questions. This is crucial — never assume.
Your Questions#
"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?"
"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."
"That's helpful. For the 10,000 units — do users select quantities, or is it one unit per user?"
"One unit per user per sale. We want to maximize the number of happy customers."
"What happens if someone claims an item but doesn't complete checkout within 3 minutes?"
"The item should go back to available inventory for others."
"For payment processing — do you have an existing payment provider, or should I design that?"
"Assume we use Stripe. Treat it as an external API that might be slow or fail."
"Last question — the notification when deals go live. Are users subscribed to specific products, or do we notify everyone?"
"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."
"Perfect. Let me summarize the requirements as I understand them."
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#
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: Back of the Envelope Estimation (5 minutes)#
"Let me work through the numbers to understand the scale."
Traffic Estimation#
- 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#
- 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#
- 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#
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#
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
"Good analysis. Those numbers seem reasonable. What concerns you most about the scale?"
"Three things concern me:
-
The 150K requests/second peak — this is a thundering herd problem. We need aggressive caching and potentially a queue-based claim system.
-
The inventory consistency — with 40K claim attempts per second on 10K items, we need atomic operations. Race conditions could cause overselling.
-
The notification delivery — 80K notifications/second is achievable, but we need to pre-compute recipient lists before the sale starts, not at sale time."
Flash sale estimates
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.
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: High-Level Design (10 minutes)#
"Let me sketch out the high-level 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 serviceComponent Overview#
"Let me walk through each component and its role:"
Traffic Layer#
- 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#
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#
- Redis Cluster
- Inventory Counter — Atomic decrement with Lua scripts
- Claims Store —
user_id → claim_infowith 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#
- 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
"This looks comprehensive. I'm curious about a few things. First, how do you prevent overselling? Walk me through the claim flow."
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?
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
DECRthat 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: Deep Dive - Inventory and Claims (10 minutes)#
"Great question. This is the most critical part of the system. Let me detail the claim flow."
The Claim 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 queueStep 1: Rate limiting. Check: Has user exceeded 10 claim attempts in this minute? Implementation: Redis fixed-window counter (one key per minute).
INCRBY rate_limit:{user_id}:{minute} 1
EXPIRE rate_limit:{user_id}:{minute} 60If 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).
-- 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_dataStep 4: Publish event. Kafka: claim_events
{
"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.
{
"status": "claimed",
"claim_id": "clm_abc123",
"expires_at": "2024-01-15T10:03:00Z",
"seconds_remaining": 180,
"checkout_url": "/checkout/clm_abc123"
}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#
"This design prevents overselling through several mechanisms:"
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#
"When a claim expires without checkout, we need to return inventory:"
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)"Nice. What about the checkout flow? How do you handle payment failures and ensure orders aren't double-charged?"
Claims without overselling
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.
Q2/4 The script's
DECRreturns -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.
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.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.
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?
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: Deep Dive - Checkout Flow (10 minutes)#
"The checkout flow is where everything comes together — timeouts, idempotency, circuit breakers. Let me walk through it."
Checkout Architecture#
%% 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 queueStep 1: Validate claim. Check claim exists and hasn't expired:
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}
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).
# 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).
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).
# 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.
{
"status": "confirmed",
"order_id": "ord_xyz789",
"message": "Congratulations! Your order is confirmed.",
"receipt_url": "/orders/ord_xyz789/receipt"
}Complete Checkout Code#
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
}
})"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?"
Checkout reliability
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.
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.
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: Deep Dive - Notification System (5 minutes)#
"The notification system is where the webhook patterns from Day 4 come in. Let me explain."
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#
%% 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 externalT-10 minutes (pre-computation):
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 = trueResult: 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:
{
"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}:0from 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#
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']}"
})"Good. One more thing — how does the flash sale scheduling work? You mentioned these happen every hour. Is that a cron job?"
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?
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: Deep Dive - Sale Scheduling (5 minutes)#
"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#
%% 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 storeSale Scheduler Service
Every minute:
- Check for sales starting in next 10 minutes
- Pre-compute notification recipients
- Warm up Redis with sale data
At scheduled time:
- Verify fencing token
- Initialize inventory counter
- Mark sale as active
- Trigger notifications
At sale end:
- Mark sale as ended
- Return unclaimed inventory
- Generate sale report
Sale Scheduler Implementation#
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
)Notifications and sale scheduling
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.
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.
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: Monitoring and Observability (3 minutes)#
"How would you monitor this system? What alerts would you set up?"
"Monitoring is critical for a flash sale system. Here's my approach:"
Key Metrics 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#
# 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: Wrap-Up and Extensions (2 minutes)#
"We're almost out of time. Let's quickly discuss: what would you do differently at 10x the scale?"
"Great question. At 10x scale (50M concurrent users, 100K inventory), I'd make these changes:"
Scaling to 10x#
| 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#
"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 |
Scaling and alternatives
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.
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.
Interview Conclusion#
"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?"
"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:
- 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.
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.
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.
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.
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.
