Week 2 Finale: Bringing It All Together#
Preface: The Week 2 Journey#
We've come a long way this week. Let's see where we started and where we are now:
- Day 1: Timeouts — "Don't wait forever for slow services"
- Timeout budgets, cascading timeouts
- Day 2: Idempotency — "Safe to retry without duplicate effects"
- Idempotency keys, request fingerprinting
- Day 3: Circuit Breakers — "Stop calling broken services"
- Fail fast, protect the system
- Day 4: Webhooks — "Reliable async delivery to external systems"
- At-least-once delivery, retries, DLQ
- Day 5: Distributed Cron (TODAY) — "Run scheduled jobs exactly once across multiple servers"
- Leader election, fencing tokens, distributed coordination
Today's challenge is the culmination of everything we've learned. Distributed cron combines:
- Timeouts (Day 1): Jobs that run too long
- Idempotency (Day 2): Jobs that might run twice
- Circuit Breakers (Day 3): External dependencies that fail
- Async Processing (Day 4): Fire-and-forget execution
And it adds a new critical concept: distributed coordination.
The Fundamental Problem#
On a single server, cron is simple:
# /etc/crontab
0 9 * * * /scripts/send_daily_report.sh
*/5 * * * * /scripts/cleanup_old_sessions.sh
0 0 * * * /scripts/generate_invoices.shThe single machine gives you:
- Jobs run at the scheduled time (while the machine is up)
- One scheduler, so each tick fires once (cron won't stop a slow run overlapping the next one, though—wrap jobs in
flockfor that) - The schedule survives restarts (but runs missed while the machine was down are skipped unless you use anacron)
But in a distributed system:
%% title: Cron on every server
flowchart TD
S1@{ icon: "sd:server", label: "Server 1 · [cron]<br/>10:00 AM: Run job!" }
S2@{ icon: "sd:server", label: "Server 2 · [cron]<br/>10:00 AM: Run job!" }
S3@{ icon: "sd:server", label: "Server 3 · [cron]<br/>10:00 AM: Run job!" }
S1 --> J(["JOB RUNS 3 TIMES!"]):::bad
S2 --> J
S3 --> J
class S1,S2,S3 serviceThe distributed cron problem: How do you ensure a scheduled job runs exactly once across multiple servers, even when:
- Servers crash
- Networks partition
- Clocks drift
- Deployments happen
This is one of the hardest problems in distributed systems, and it's what we'll solve today.
Cron on three servers runs a job three times, and naive fixes fail: a crashed lock holder blocks the job forever, a slow job outlives its lock timeout and runs twice. Perfect distributed cron is impossible, so elect one leader with a lease in etcd or ZooKeeper, guard against split brain with fencing tokens, and run idempotent jobs at-least-once. When the leader dies mid-job, persistent execution records, heartbeats, checkpoints and stale-token rejection keep the job from running twice or never; Celery Beat, Kubernetes CronJobs and Temporal each trade these off, and a reliable job combines every week 2 pattern.
Part I: Foundations#
Chapter 1: Why Distributed Cron Is Hard#
What breaks with a simple lock?
Three servers each run the same cron schedule, so you guard the job with a database flag: set locked = true before running, clear it after. Server 1 takes the lock and loses power mid-job. What happens to the job from then on? Now give the lock a 5-minute expiry instead, and the report takes 8 minutes. What goes wrong this time?
One strong answer
- No expiry: nothing ever clears the flag, so every other server is blocked and the job never runs again until someone steps in.
- 5-minute expiry, 8-minute job: at minute 5 the lock expires and Server 2 starts the same job, so it runs twice. When Server 1 finishes, it releases what is now Server 2's lock, corrupting the lock state.
- The pattern: an expiry fixes crashed holders but breaks slow ones. You need a way to tell an old holder it is no longer in charge.
1.1 The Illusion of Simplicity#
At first glance, the solution seems obvious:
Attempt 1: "Just use a database lock"
def run_scheduled_job(job_id):
# Try to acquire lock
if db.execute("UPDATE jobs SET locked = true WHERE id = ? AND locked = false", job_id):
try:
execute_job(job_id)
finally:
db.execute("UPDATE jobs SET locked = false WHERE id = ?", job_id)Problem: What if the server crashes while holding the lock?
- 10:00:00 — Server 1 acquires lock
- 10:00:01 — Server 1 starts job
- 10:00:05 — Server 1 crashes (power failure). Lock is still held!
- 10:00:06 — Server 2 tries to run job → blocked
- 10:01:00 — Server 3 tries to run job → blocked
- ... — Job never runs again until manual intervention
Attempt 2: "Add a lock timeout"
def run_scheduled_job(job_id):
lock_until = datetime.now() + timedelta(minutes=5)
if db.execute("""
UPDATE jobs
SET locked_until = ?
WHERE id = ? AND (locked_until IS NULL OR locked_until < NOW())
""", lock_until, job_id):
try:
execute_job(job_id)
finally:
db.execute("UPDATE jobs SET locked_until = NULL WHERE id = ?", job_id)Problem: What if the job takes longer than the timeout?
- 10:00:00 — Server 1 acquires lock (expires at 10:05:00)
- 10:00:01 — Server 1 starts job (a slow report generation)
- 10:05:00 — Lock expires!
- 10:05:01 — Server 2 acquires lock, starts SAME job
- 10:08:00 — Server 1 finishes job
- 10:08:01 — Server 1 releases lock (but it's Server 2's lock now!)
- 10:13:00 — Server 2 finishes job (an 8-minute run from 10:05:01)
- Result — Job ran twice, and lock state is corrupted
Attempt 3: "Use distributed locking with Redis"
def run_scheduled_job(job_id):
lock = redis.set(f"lock:{job_id}", server_id, nx=True, ex=300)
if lock:
try:
execute_job(job_id)
finally:
# Only release if we still own the lock
if redis.get(f"lock:{job_id}") == server_id:
redis.delete(f"lock:{job_id}")Problem: This is still vulnerable to the "slow job" problem and also has a race condition in the finally block.
- 10:04:59.990 — Server 1's GET returns its own server_id (lock still valid)
- 10:05:00.000 — Lock expires
- 10:05:00.005 — Server 2 acquires lock
- 10:05:00.010 — Server 1 DELETEs the lock (but it's Server 2's lock!)
1.2 The Core Challenges#
These attempts reveal the fundamental challenges:
| Challenge | Description |
|---|---|
| Mutual Exclusion | Only one server should run a job at a time |
| Deadlock Prevention | Crashed servers shouldn't hold locks forever |
| Split Brain | Network partitions can cause multiple leaders |
| Clock Skew | Servers' clocks may disagree on "now" |
| Exactly-Once Execution | Jobs should run once, not zero, not twice |
| Failure Recovery | Crashed jobs should be detected and handled |
1.3 The Fundamental Impossibility#
The FLP Impossibility Result (Fischer, Lynch, Paterson, 1985) proves that in an asynchronous distributed system with even one faulty process, it's impossible to guarantee consensus will be reached.
In practical terms: You cannot have perfect distributed cron.
But you can get very close with the right trade-offs:
Perfect
Job runs exactly once, on time, every time
(Impossible in distributed systems)
Practical
- Job runs at-least-once, we use idempotency to handle duplicates
- Job may be slightly delayed during failures
- We detect and alert on missed executions
This is what production systems actually do.
Chapter 2: Leader Election#
The foundation of distributed cron is leader election: choosing one server to be responsible for scheduling and triggering jobs.
2.1 What Is Leader Election?#
Before Election
- Server 1 — wants to lead
- Server 2 — wants to lead
- Server 3 — wants to lead
After Election
- Server 1 (runs cron) ← LEADER — schedules jobs
- Server 2 (standby) ← FOLLOWER — ready to take over
- Server 3 (standby) ← FOLLOWER — ready to take over
Only the leader runs the cron scheduler. Followers monitor the leader and are ready to take over if it fails.
2.2 The Split-Brain Problem#
The most dangerous scenario in leader election:
%% title: Network partition flowchart TD P["PARTITION"]:::bad P --> A["Servers 1, 2, 3<br/>“Server 1 is<br/>still leader”"]:::service P --> B["Servers 4, 5<br/>“We can't see Server 1...<br/>Elect new leader: 4!”"]:::service A --> A1["Server 1<br/>runs jobs"]:::core B --> B1["Server 4<br/>runs jobs"]:::core A1 -.- TL(["TWO LEADERS!"]):::bad B1 -.- TL
Now two servers both think they're the leader, both running jobs. This happens with naive election ("whoever can't see the leader elects a new one"). A majority-quorum system (etcd, ZooKeeper) would refuse to let the 2-node minority elect anyone—but even then a deposed leader that hasn't noticed yet (GC pause, expired lease) can keep acting on stale beliefs, which is what fencing tokens guard against.
Design it: refuse the old leader
During a partition Server B was elected leader and ran the job. The partition heals, and old leader A, which never noticed it was replaced, now tries to run the same job against the database. A still believes it is the leader. How can the database refuse A's write without A having to know anything?
One strong answer
- Number every leadership: each new election gets a higher number (a fencing token): A had 1, B has 2.
- Send it with every write: the leader includes its token in each operation.
- Let the resource decide: the database remembers the highest token it has accepted (2) and rejects anything lower. A's write carries 1, so it's refused, and A learns it should step down.
- Why the resource: A's own view can be stale (a GC pause, an expired lease it hasn't noticed), so the check has to live where the write lands.
2.3 Fencing Tokens#
The solution to split-brain is fencing tokens (also called epoch numbers or generation IDs):
%% title: Fencing tokens sequenceDiagram autonumber participant A as Server A participant B as Server B participant DB as Database Note over A: Token 1: Server A becomes leader A->>DB: Run job with token=1 DB-->>A: OK, token 1 accepted Note over A,B: Token 2: Network partition, Server B becomes leader B->>DB: Run job with token=2 DB-->>B: OK, token 2 accepted Note over A,DB: Partition heals A->>DB: Run job with token=1 (stale) DB--xA: REJECTED - token 1 < current token 2 Note over A: Server A realizes it's no longer leader and steps down.
- Every time a new leader is elected, increment a global counter.
- The leader includes this token in all operations.
- Resources reject operations with old tokens.
A 5-minute lock expires while Server 1's slow job is still running, so Server 2 runs the same job and Server 1 later releases Server 2's lock: the job runs twice. With fencing tokens every new leader gets a higher token; once Server B's token 2 is accepted, the old leader's token 1 is rejected and it steps down.
Implementation:
class FencedJobExecutor:
def __init__(self, db):
self.db = db
def execute_job(self, job_id: str, fencing_token: int, job_func):
"""Execute job only if fencing token is current."""
# Atomically check and update token
result = self.db.execute("""
UPDATE jobs
SET last_execution_token = ?,
last_execution_started = NOW()
WHERE id = ?
AND (last_execution_token IS NULL OR last_execution_token <= ?) -- same leader may rerun; older tokens rejected
RETURNING id
""", fencing_token, job_id, fencing_token)
if not result:
raise StaleLeaderError(f"Token {fencing_token} is stale")
try:
job_func()
# Mark completion with token
self.db.execute("""
UPDATE jobs
SET last_execution_completed = NOW()
WHERE id = ? AND last_execution_token = ?
""", job_id, fencing_token)
except Exception as e:
# Mark failure with token
self.db.execute("""
UPDATE jobs
SET last_execution_error = ?
WHERE id = ? AND last_execution_token = ?
""", str(e), job_id, fencing_token)
raise2.4 Why ZooKeeper and etcd Exist#
Implementing leader election correctly is so hard that dedicated systems exist just for this:
ZooKeeper (Apache):
- Created by Yahoo for Hadoop
- Uses ZAB (ZooKeeper Atomic Broadcast) consensus
- Provides: leader election, distributed locks, configuration management
- Used by: HBase, Solr, and Kafka before KRaft (Kafka 4.0 removed ZooKeeper)
etcd (CoreOS/CNCF):
- Created by CoreOS (2013) for cluster coordination; best known today as Kubernetes' datastore
- Uses Raft consensus
- Provides: key-value store, leader election, watch notifications
- Used by: Kubernetes, CoreDNS, many cloud-native tools
Consul (HashiCorp):
- Uses Raft consensus
- Provides: service discovery, health checking, leader election
- Used by: Many HashiCorp tools, service meshes
Why use these instead of building your own?
| DIY Implementation | Dedicated System |
|---|---|
| Months of development | Days of integration |
| Subtle bugs (split-brain, etc.) | Battle-tested |
| No monitoring/tooling | Rich observability |
| Your team maintains it | Community maintains it |
The rule: Unless you're building a distributed database or similar infrastructure, use an existing coordination service.
2.5 Leader Election with etcd#
import etcd3
from threading import Thread, Event
import time
class EtcdLeaderElection:
"""Leader election using etcd leases."""
def __init__(self, etcd_client, election_name: str, ttl: int = 10):
self.client = etcd_client
self.election_name = election_name
self.ttl = ttl
self.lease = None
self.is_leader = False
self.leader_key = f"/elections/{election_name}/leader"
self.stop_event = Event()
def campaign(self, candidate_id: str, on_elected, on_demoted):
"""Campaign to become leader."""
while not self.stop_event.is_set():
try:
# Create a lease that must be renewed
self.lease = self.client.lease(self.ttl)
# Try to become leader
success, _ = self.client.transaction(
compare=[
self.client.transactions.create(self.leader_key) == 0
],
success=[
self.client.transactions.put(
self.leader_key,
candidate_id,
lease=self.lease
)
],
failure=[]
)
if success:
self.is_leader = True
on_elected()
self._maintain_leadership(on_demoted)
else:
# Someone else is leader, watch for changes
self._wait_for_leader_change()
except Exception as e:
if self.is_leader:
self.is_leader = False
on_demoted()
time.sleep(1)
def _maintain_leadership(self, on_demoted):
"""Keep refreshing lease while leader."""
while self.is_leader and not self.stop_event.is_set():
try:
self.lease.refresh()
time.sleep(self.ttl / 3)
except:
self.is_leader = False
on_demoted()
break
def _wait_for_leader_change(self):
"""Watch for leader key deletion."""
events, cancel = self.client.watch(self.leader_key)
for event in events:
if isinstance(event, etcd3.events.DeleteEvent):
cancel()
return # Leader gone, try to become leader
if self.stop_event.is_set():
cancel()
return
def resign(self):
"""Voluntarily give up leadership."""
if self.is_leader and self.lease:
self.lease.revoke()
self.is_leader = False
def stop(self):
"""Stop the election process."""
self.stop_event.set()
self.resign()Chapter 3: Job Execution Guarantees#
3.1 The Spectrum of Guarantees#
AT-MOST-ONCE
"Fire and forget"
- Simple
- Fast
- May lose jobs
Use for: Metrics/logs · Cache warming · Best-effort
AT-LEAST-ONCE
"Retry until done"
- Reliable
- May duplicate
- Need idempotent jobs
Use for: Most jobs · Email sending · Report generation
EXACTLY-ONCE
"Magic" (myth)
- Requires idempotency
- Complex
- Expensive
Use for: Financial · Critical audit · Exactly-once semantics needed
3.2 Implementing At-Least-Once Job Execution#
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
from typing import Callable, Optional
import uuid
class JobStatus(Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
DEAD = "dead" # Exceeded all retries
@dataclass
class ScheduledJob:
id: str
name: str
cron_expression: str
handler: str # e.g., "app.jobs.send_daily_report"
# Execution tracking
status: JobStatus = JobStatus.PENDING
last_run_at: Optional[datetime] = None
next_run_at: Optional[datetime] = None
# Current execution
execution_id: Optional[str] = None
execution_started_at: Optional[datetime] = None
execution_token: Optional[int] = None
# Retry configuration
max_retries: int = 3
retry_count: int = 0
timeout_seconds: int = 3600 # 1 hour default
# Failure tracking
last_error: Optional[str] = None
consecutive_failures: int = 0
class AtLeastOnceJobExecutor:
"""
Job executor with at-least-once guarantee.
Guarantees:
- Jobs will run at least once (may run multiple times on failures)
- Failed jobs will be retried
- Stuck jobs will be detected and re-run
- Jobs are idempotent (caller's responsibility)
"""
def __init__(self, db, job_registry, fencing_token: int):
self.db = db
self.job_registry = job_registry
self.fencing_token = fencing_token
def execute_job(self, job: ScheduledJob) -> bool:
"""
Execute a scheduled job with at-least-once semantics.
Returns True if job completed successfully.
"""
execution_id = str(uuid.uuid4())
# Try to claim the job
claimed = self._claim_job(job.id, execution_id)
if not claimed:
return False # Another instance is running it
try:
# Get the handler function
handler = self.job_registry.get_handler(job.handler)
if not handler:
raise ValueError(f"Unknown job handler: {job.handler}")
# Execute with timeout
self._execute_with_timeout(handler, job.timeout_seconds)
# Mark success
self._mark_completed(job.id, execution_id)
return True
except TimeoutError:
self._mark_failed(job.id, execution_id, "Job timed out")
return False
except Exception as e:
self._mark_failed(job.id, execution_id, str(e))
return False
def _claim_job(self, job_id: str, execution_id: str) -> bool:
"""
Atomically claim a job for execution.
Uses fencing token to prevent stale leaders from claiming.
"""
result = self.db.execute("""
UPDATE scheduled_jobs
SET status = 'running',
execution_id = ?,
execution_started_at = NOW(),
execution_token = ?
WHERE id = ?
AND status IN ('pending', 'failed')
AND (execution_token IS NULL OR execution_token <= ?) -- reject older leaders only
RETURNING id
""", execution_id, self.fencing_token, job_id, self.fencing_token)
return result is not None
def _execute_with_timeout(self, handler: Callable, timeout: int):
"""Execute handler with timeout."""
import signal
def timeout_handler(signum, frame):
raise TimeoutError("Job execution timed out")
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(timeout)
try:
handler()
finally:
signal.alarm(0)
def _mark_completed(self, job_id: str, execution_id: str):
"""Mark job as completed."""
self.db.execute("""
UPDATE scheduled_jobs
SET status = 'completed',
last_run_at = NOW(),
next_run_at = ?,
retry_count = 0,
consecutive_failures = 0,
last_error = NULL
WHERE id = ? AND execution_id = ?
""", self._calculate_next_run(job_id), job_id, execution_id)
def _mark_failed(self, job_id: str, execution_id: str, error: str):
"""Mark job as failed, potentially scheduling retry."""
job = self.db.get_job(job_id)
if job.retry_count < job.max_retries:
# Schedule retry
retry_delay = self._calculate_retry_delay(job.retry_count)
self.db.execute("""
UPDATE scheduled_jobs
SET status = 'pending',
next_run_at = NOW() + INTERVAL ? SECOND,
retry_count = retry_count + 1,
consecutive_failures = consecutive_failures + 1,
last_error = ?
WHERE id = ? AND execution_id = ?
""", retry_delay, error, job_id, execution_id)
else:
# Move to dead status
self.db.execute("""
UPDATE scheduled_jobs
SET status = 'dead',
last_error = ?,
consecutive_failures = consecutive_failures + 1
WHERE id = ? AND execution_id = ?
""", error, job_id, execution_id)
# Alert operations
self._alert_dead_job(job, error)
def _calculate_retry_delay(self, retry_count: int) -> int:
"""Exponential backoff for retries."""
base_delay = 60 # 1 minute
max_delay = 3600 # 1 hour
delay = base_delay * (2 ** retry_count)
return min(delay, max_delay)Your call: slow job, or dead worker?
A job's record says running, and it started 2 hours ago, longer than usual. Either the job is just slow, or the worker running it crashed and nobody will ever finish it. What would you have the worker record while it runs, so a background checker can tell the two apart, and what should the checker do when it decides the job is stuck?
One strong answer
- Record a heartbeat: the worker updates a
last_heartbeat_attimestamp every 30 seconds or so while the job runs. - Stuck means both: running past its expected time and no recent heartbeat. A slow job keeps heartbeating, so it's left alone.
- On stuck: mark the execution failed, alert if it keeps happening, and let it be retried. Because it may already be half done, the job itself must be idempotent.
3.3 Detecting and Recovering Stuck Jobs#
What happens when a job starts but the server crashes mid-execution?
class StuckJobDetector:
"""
Detect and recover jobs that are stuck in 'running' state.
A job is considered stuck if:
- Status is 'running'
- Started more than timeout_seconds ago
- No heartbeat received recently
"""
def __init__(self, db, alert_service):
self.db = db
self.alert_service = alert_service
def find_stuck_jobs(self) -> list:
"""Find jobs that appear to be stuck."""
return self.db.query("""
SELECT * FROM scheduled_jobs
WHERE status = 'running'
AND execution_started_at < NOW() - INTERVAL timeout_seconds SECOND
AND (last_heartbeat_at IS NULL
OR last_heartbeat_at < NOW() - INTERVAL 60 SECOND)
""")
def recover_stuck_jobs(self):
"""Reset stuck jobs to pending for re-execution."""
stuck_jobs = self.find_stuck_jobs()
for job in stuck_jobs:
# Log the stuck job
logger.warning(
"Recovering stuck job",
job_id=job.id,
execution_id=job.execution_id,
started_at=job.execution_started_at
)
# Check if it's a chronic problem
if job.consecutive_failures >= 3:
self.alert_service.alert(
severity="high",
message=f"Job {job.name} has failed {job.consecutive_failures} times",
job_id=job.id
)
# Reset to pending (will be picked up on next schedule)
self.db.execute("""
UPDATE scheduled_jobs
SET status = 'failed',
last_error = 'Job stuck - server may have crashed',
consecutive_failures = consecutive_failures + 1
WHERE id = ? AND execution_id = ?
""", job.id, job.execution_id)
def run_detector(self, interval: int = 60):
"""Run stuck job detection periodically."""
while True:
try:
self.recover_stuck_jobs()
except Exception as e:
logger.error("Stuck job detector error", error=str(e))
time.sleep(interval)3.4 Job Heartbeats#
For long-running jobs, use heartbeats to signal progress:
class HeartbeatJobExecutor:
"""
Job executor with heartbeat support for long-running jobs.
"""
def __init__(self, db, heartbeat_interval: int = 30):
self.db = db
self.heartbeat_interval = heartbeat_interval
self.heartbeat_thread = None
self.stop_heartbeat = False
def execute_with_heartbeat(self, job: ScheduledJob, handler: Callable):
"""Execute job while sending heartbeats."""
self.stop_heartbeat = False
# Start heartbeat thread
self.heartbeat_thread = Thread(
target=self._heartbeat_loop,
args=(job.id, job.execution_id)
)
self.heartbeat_thread.start()
try:
handler()
finally:
self.stop_heartbeat = True
self.heartbeat_thread.join()
def _heartbeat_loop(self, job_id: str, execution_id: str):
"""Send periodic heartbeats."""
while not self.stop_heartbeat:
try:
self.db.execute("""
UPDATE scheduled_jobs
SET last_heartbeat_at = NOW()
WHERE id = ? AND execution_id = ?
""", job_id, execution_id)
except:
pass # Best effort
time.sleep(self.heartbeat_interval)Chapter 4: The Complete Distributed Cron System#
4.1 Architecture#
%% title: Distributed cron architecture
flowchart TD
subgraph COORD["Coordination Layer (etcd / ZooKeeper)"]
direction TB
LE@{ icon: "sd:crown", label: "Leader<br/>Election" }
CFG@{ icon: "sd:database", label: "Config<br/>Store" }
FT@{ icon: "sd:key", label: "Fencing<br/>Tokens" }
end
subgraph SCHED["Scheduler (Leader Only)"]
direction TB
CP["Cron<br/>Parser"]:::core
JR@{ icon: "sd:database", label: "Job<br/>Registry" }
TG["Trigger<br/>Generator"]:::core
end
JQ@{ icon: "sd:queue", label: "Job Queue<br/>(Redis / RabbitMQ / SQS)" }
subgraph WK1["Worker 1"]
direction TB
E1@{ icon: "sd:worker", label: "Executor" }
H1["Heartbeat"]:::service
end
subgraph WK2["Worker 2"]
direction TB
E2@{ icon: "sd:worker", label: "Executor" }
H2["Heartbeat"]:::service
end
subgraph WK3["Worker 3"]
direction TB
E3@{ icon: "sd:worker", label: "Executor" }
H3["Heartbeat"]:::service
end
subgraph PERS["Persistence Layer"]
direction TB
JS@{ icon: "sd:database", label: "Job<br/>Store" }
EL@{ icon: "sd:log", label: "Execution<br/>Logs" }
AU@{ icon: "sd:log", label: "Audit<br/>Trail" }
end
COORD --> SCHED
SCHED --> JQ
JQ --> WK1
JQ --> WK2
JQ --> WK3
class LE,FT,E1,E2,E3 service
class CFG,JR,JS,EL,AU store
class JQ queue4.2 Complete Implementation#
"""
Distributed Cron System
Combines: Leader Election + Job Scheduling + At-Least-Once Execution
"""
import asyncio
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Callable, Optional
from dataclasses import dataclass, field
from croniter import croniter
import structlog
logger = structlog.get_logger()
# =============================================================================
# Configuration
# =============================================================================
@dataclass
class CronConfig:
# Leader election
etcd_endpoints: List[str] = field(default_factory=lambda: ["localhost:2379"])
election_ttl: int = 10 # seconds
# Scheduling
tick_interval: int = 1 # seconds
lookahead_seconds: int = 60 # How far ahead to schedule
# Execution
default_timeout: int = 3600 # 1 hour
max_retries: int = 3
stuck_job_threshold: int = 300 # 5 minutes without heartbeat
# Workers
num_workers: int = 4
heartbeat_interval: int = 30
# =============================================================================
# Job Definitions
# =============================================================================
@dataclass
class JobDefinition:
name: str
cron: str
handler: str
timeout: int = 3600
max_retries: int = 3
enabled: bool = True
metadata: Dict = field(default_factory=dict)
@dataclass
class JobExecution:
id: str
job_name: str
scheduled_at: datetime
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
status: str = "pending" # pending, running, completed, failed, dead
fencing_token: Optional[int] = None
worker_id: Optional[str] = None
error: Optional[str] = None
retry_count: int = 0
# =============================================================================
# Distributed Cron Scheduler
# =============================================================================
class DistributedCronScheduler:
"""
Distributed cron scheduler with leader election.
Only the leader schedules jobs. Workers execute them.
"""
def __init__(
self,
config: CronConfig,
etcd_client,
job_store,
job_queue,
job_registry: Dict[str, Callable]
):
self.config = config
self.etcd = etcd_client
self.job_store = job_store
self.job_queue = job_queue
self.job_registry = job_registry
self.is_leader = False
self.fencing_token = 0
self.node_id = str(uuid.uuid4())[:8]
self.leader_election = EtcdLeaderElection(
etcd_client,
"cron-scheduler",
ttl=config.election_ttl
)
async def start(self):
"""Start the distributed cron system."""
logger.info("Starting distributed cron", node_id=self.node_id)
self.loop = asyncio.get_running_loop()
# Start leader election in background
asyncio.create_task(self._run_election())
# Start workers
for i in range(self.config.num_workers):
asyncio.create_task(self._run_worker(f"{self.node_id}-worker-{i}"))
# Start stuck job detector
asyncio.create_task(self._run_stuck_job_detector())
# Keep running
while True:
await asyncio.sleep(1)
async def _run_election(self):
"""Run leader election."""
# campaign() is a blocking loop (python-etcd3 is synchronous): run it in a thread
await asyncio.to_thread(
self.leader_election.campaign,
self.node_id,
self._on_elected,
self._on_demoted
)
def _on_elected(self):
"""Called when this node becomes leader."""
self.is_leader = True
# Fencing token must strictly increase across elections: use the etcd
# revision at which our leader key was created. Never wall-clock time—
# a new leader with a lagging clock would get a *smaller* token.
_, meta = self.etcd.get(self.leader_election.leader_key)
self.fencing_token = meta.create_revision
logger.info(
"Became leader",
node_id=self.node_id,
fencing_token=self.fencing_token
)
# Start scheduler loop (we're on the election thread, so hand it to the event loop)
self.loop.call_soon_threadsafe(
lambda: asyncio.create_task(self._scheduler_loop())
)
def _on_demoted(self):
"""Called when this node loses leadership."""
self.is_leader = False
logger.info("Lost leadership", node_id=self.node_id)
async def _scheduler_loop(self):
"""Main scheduler loop (runs only on leader)."""
logger.info("Starting scheduler loop", node_id=self.node_id)
while self.is_leader:
try:
await self._schedule_due_jobs()
except Exception as e:
logger.error("Scheduler error", error=str(e))
await asyncio.sleep(self.config.tick_interval)
logger.info("Scheduler loop stopped", node_id=self.node_id)
async def _schedule_due_jobs(self):
"""Find and queue jobs that are due to run."""
now = datetime.now(timezone.utc)
# Get all job definitions
jobs = await self.job_store.get_all_jobs()
for job in jobs:
if not job.enabled:
continue
# Check if job is due
cron = croniter(job.cron, now - timedelta(seconds=self.config.lookahead_seconds))
next_run = cron.get_next(datetime)
if next_run <= now:
# Check if already scheduled
existing = await self.job_store.get_pending_execution(
job.name,
next_run
)
if not existing:
# Create execution record
execution = JobExecution(
id=str(uuid.uuid4()),
job_name=job.name,
scheduled_at=next_run,
fencing_token=self.fencing_token
)
await self.job_store.create_execution(execution)
# Queue for workers
await self.job_queue.enqueue({
'execution_id': execution.id,
'job_name': job.name,
'handler': job.handler,
'timeout': job.timeout,
'fencing_token': self.fencing_token
})
logger.info(
"Scheduled job",
job_name=job.name,
execution_id=execution.id,
scheduled_at=next_run
)
async def _run_worker(self, worker_id: str):
"""Worker loop that executes jobs."""
logger.info("Starting worker", worker_id=worker_id)
while True:
try:
# Get job from queue
task = await self.job_queue.dequeue(timeout=5)
if task:
await self._execute_job(worker_id, task)
except Exception as e:
logger.error("Worker error", worker_id=worker_id, error=str(e))
await asyncio.sleep(1)
async def _execute_job(self, worker_id: str, task: dict):
"""Execute a job task."""
execution_id = task['execution_id']
job_name = task['job_name']
handler_name = task['handler']
timeout = task['timeout']
fencing_token = task['fencing_token']
logger.info(
"Executing job",
worker_id=worker_id,
execution_id=execution_id,
job_name=job_name
)
# Claim the execution
claimed = await self.job_store.claim_execution(
execution_id,
worker_id,
fencing_token
)
if not claimed:
logger.warning(
"Could not claim execution",
execution_id=execution_id,
reason="Already claimed or stale token"
)
return
# Get handler
handler = self.job_registry.get(handler_name)
if not handler:
await self.job_store.fail_execution(
execution_id,
f"Unknown handler: {handler_name}"
)
return
# Start heartbeat
heartbeat_task = asyncio.create_task(
self._heartbeat_loop(execution_id, worker_id)
)
try:
# Execute with timeout
await asyncio.wait_for(
handler(),
timeout=timeout
)
# Mark success
await self.job_store.complete_execution(execution_id)
logger.info(
"Job completed",
execution_id=execution_id,
job_name=job_name
)
except asyncio.TimeoutError:
await self.job_store.fail_execution(
execution_id,
"Job timed out"
)
logger.error(
"Job timed out",
execution_id=execution_id,
job_name=job_name,
timeout=timeout
)
except Exception as e:
await self.job_store.fail_execution(
execution_id,
str(e)
)
logger.error(
"Job failed",
execution_id=execution_id,
job_name=job_name,
error=str(e)
)
finally:
heartbeat_task.cancel()
async def _heartbeat_loop(self, execution_id: str, worker_id: str):
"""Send heartbeats while job is running."""
while True:
try:
await self.job_store.update_heartbeat(execution_id, worker_id)
await asyncio.sleep(self.config.heartbeat_interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Heartbeat error", error=str(e))
async def _run_stuck_job_detector(self):
"""Detect and recover stuck jobs."""
while True:
try:
stuck_jobs = await self.job_store.find_stuck_executions(
threshold_seconds=self.config.stuck_job_threshold
)
for execution in stuck_jobs:
logger.warning(
"Found stuck job",
execution_id=execution.id,
job_name=execution.job_name,
started_at=execution.started_at
)
# Reset to failed for retry
await self.job_store.fail_execution(
execution.id,
"Job stuck - worker may have crashed"
)
# Re-queue if retries remaining
job = await self.job_store.get_job(execution.job_name)
if execution.retry_count < job.max_retries:
await self.job_queue.enqueue({
'execution_id': execution.id,
'job_name': execution.job_name,
'handler': job.handler,
'timeout': job.timeout,
'fencing_token': self.fencing_token,
'retry_count': execution.retry_count + 1
})
except Exception as e:
logger.error("Stuck job detector error", error=str(e))
await asyncio.sleep(60)Locks, leader election and job guarantees
Q1/4 A job takes a database lock (
locked = true) with no expiry, and the server loses power mid-job. What happens next?Answer & explanation
Answer: A
Nothing ever clears
locked = true, so every other server is blocked forever. That is why the next attempt adds an expiry, which brings its own problem.Q2/4 A lock expires after 5 minutes, but the report job takes 8. What goes wrong?
Answer & explanation
Answer: C
An expiring lock fixes crashed holders but breaks slow ones: the job runs twice and lock state gets corrupted. Leases, heartbeats and fencing tokens address this.
Q3/4 After a partition heals, old leader A (token 1) tries to start a job, but leader B has already run it with token 2. What makes the database reject A?
Answer & explanation
Answer: A
Fencing tokens rise with every election, and the resource refuses operations carrying a token older than one it has seen. A then knows to step down. Nothing about A's own connection changes; protection has to live in the resource being written.
Q4/4 Why do long-running jobs send heartbeats every 30 seconds?
Answer & explanation
Answer: B
A job running past its expected time is only treated as stuck if its heartbeats have also stopped. Without them, you would either kill slow jobs or never recover crashed ones.
Part II: The Design Challenge#
Chapter 5: "The Leader Dies Mid-Job"#
Estimate it: how long is the scheduler gone?
The leader holds an etcd lease with a 10-second TTL and refreshes it every TTL ÷ 3. It loses power right after a refresh at 10:00:30. Roughly when can another server take over? The scheduler looks back 60 seconds for due jobs. If a job was due at 10:00:35, does it get skipped?
One strong answer
- Lease expiry: 10:00:30 + 10 s = 10:00:40; Server B is leader at about 10:00:41, roughly 11 seconds without a scheduler.
- Not skipped: on each tick the scheduler looks for runs due in the last 60 seconds. At 10:00:41 the 10:00:35 run is only 6 seconds old, so B's first tick queues it, a few seconds late but not lost.
- The trade-off: a shorter TTL shortens the gap but risks a healthy leader losing its lease on a brief hiccup (flapping); a longer one means slower failover.
5.1 The Scenario#
This is the classic distributed systems interview question:
Timeline
- 10:00:00 — Leader (Server A) triggers daily report job
- 10:00:01 — Worker starts generating report
- 10:00:30 — Leader (Server A) crashes (power failure)
- 10:00:40 — etcd lease expires (10s TTL since its last refresh at ~10:00:30)
- 10:00:41 — Server B becomes new leader
Questions
- What happens to the in-progress job?
- Will the job run twice?
- How do we ensure exactly-once semantics?
- What if the job was half-finished (e.g., sent some emails)?
5.2 Analyzing the Failure Modes#
Mode 1: Leader crash, job still running on worker
Solution: Job execution is tracked in persistent storage, not just in leader's memory.
# Before scheduling, check if already running
existing = await self.job_store.get_running_execution(job_name)
if existing:
if existing.is_stale(): # No heartbeat for 5+ minutes
# Assume dead, reschedule
pass
else:
# Still running, skip
returnMode 2: Leader crash, job on same server
Solution: Make jobs idempotent and resumable.
class IdempotentEmailJob:
"""Send daily email report - idempotent implementation."""
async def run(self, execution_id: str):
# Get all users who should receive email
users = await self.db.get_email_recipients()
for user in users:
# Check if already sent (idempotency)
already_sent = await self.db.check_email_sent(
execution_id=execution_id,
user_id=user.id
)
if already_sent:
continue
# Send email
await self.email_service.send(user.email, self.report)
# Mark as sent (for idempotency)
await self.db.mark_email_sent(
execution_id=execution_id,
user_id=user.id
)Mode 3: Split-brain during failover
Solution: Fencing tokens.
# Worker checks fencing token before executing
async def execute_job(self, task):
current_token = await self.get_current_fencing_token()
if task['fencing_token'] < current_token:
# Stale task from old leader
logger.warning("Rejecting stale task",
task_token=task['fencing_token'],
current_token=current_token)
return
# Safe to execute
await self._do_execute(task)5.3 Complete Solution#
class ResilientJobExecution:
"""
Job execution that handles leader failure gracefully.
"""
def __init__(self, job_store, fencing_store):
self.job_store = job_store
self.fencing_store = fencing_store
async def execute_with_resilience(
self,
job: JobDefinition,
execution: JobExecution,
handler: Callable
):
"""
Execute job with full resilience:
- Fencing token validation
- Idempotency support
- Progress tracking
- Graceful failure handling
"""
# Step 1: Validate fencing token
current_token = await self.fencing_store.get_current_token()
if execution.fencing_token < current_token:
raise StaleExecutionError(
f"Execution {execution.id} has stale token"
)
# Step 2: Create execution context for idempotency
context = ExecutionContext(
execution_id=execution.id,
job_name=job.name,
checkpoint_store=self.job_store
)
try:
# Step 3: Execute with progress tracking
await handler(context)
# Step 4: Mark complete
await self.job_store.complete_execution(
execution.id,
fencing_token=execution.fencing_token
)
except Exception as e:
# Step 5: Handle failure
await self._handle_failure(execution, e)
raise
async def _handle_failure(self, execution: JobExecution, error: Exception):
"""Handle job failure."""
# Update execution record
await self.job_store.fail_execution(
execution.id,
str(error),
fencing_token=execution.fencing_token
)
# Check if retryable
job = await self.job_store.get_job(execution.job_name)
if execution.retry_count < job.max_retries:
# Schedule retry
await self.job_store.schedule_retry(
execution.id,
delay_seconds=self._calculate_backoff(execution.retry_count)
)
else:
# Move to dead letter
await self.job_store.mark_dead(execution.id)
await self._alert_dead_job(execution, error)
class ExecutionContext:
"""
Context for idempotent job execution.
Provides checkpoint/resume capabilities.
"""
def __init__(self, execution_id: str, job_name: str, checkpoint_store):
self.execution_id = execution_id
self.job_name = job_name
self.checkpoint_store = checkpoint_store
async def checkpoint(self, key: str, data: dict):
"""Save progress checkpoint."""
await self.checkpoint_store.save_checkpoint(
self.execution_id,
key,
data
)
async def get_checkpoint(self, key: str) -> Optional[dict]:
"""Get saved checkpoint."""
return await self.checkpoint_store.get_checkpoint(
self.execution_id,
key
)
async def mark_step_complete(self, step: str):
"""Mark a step as complete (for idempotency)."""
await self.checkpoint_store.mark_step_complete(
self.execution_id,
step
)
async def is_step_complete(self, step: str) -> bool:
"""Check if step already completed."""
return await self.checkpoint_store.is_step_complete(
self.execution_id,
step
)5.4 Example: Resumable Invoice Generation#
async def generate_monthly_invoices(ctx: ExecutionContext):
"""
Generate invoices for all customers.
Resumable and idempotent.
"""
# Get last checkpoint (if resuming)
checkpoint = await ctx.get_checkpoint("progress")
last_customer_id = checkpoint.get("last_customer_id") if checkpoint else None
# Get customers to process
customers = await db.get_customers_for_invoicing(after_id=last_customer_id)
for customer in customers:
# Check if already processed (idempotency)
step_key = f"customer_{customer.id}"
if await ctx.is_step_complete(step_key):
continue
# Generate invoice
invoice = await generate_invoice(customer)
# Save invoice
await db.save_invoice(invoice)
# Send email
await send_invoice_email(customer, invoice)
# Mark step complete
await ctx.mark_step_complete(step_key)
# Checkpoint progress
await ctx.checkpoint("progress", {"last_customer_id": customer.id})
logger.info("Invoice generation complete",
execution_id=ctx.execution_id,
customers_processed=len(customers))| Value | |
|---|---|
| Customers to invoice (assumed) | 100,000 |
| Time per customer: build, save, email (assumed) | 200 ms |
| Full run | 100,000 × 0.2 s = 20,000 s ≈ 5.6 hours |
| Crash at 60% done, no checkpoint | redo ~60,000 customers ≈ 3.3 hours, and they'd be emailed twice |
| Crash at 60% done, with step markers and checkpoint | redo at most the 1 customer in flight |
| Extra writes (one step marker + one checkpoint per customer) | 200,000 over 5.6 h ≈ 10 a second |
| This lesson's default job timeout | 3,600 s = 1 hour |
Conclusion: checkpointing turns a crash from hours of repeated work (and duplicate emails) into one repeated customer, for about 10 small writes a second. The run also takes over five times the default 1-hour timeout, so this job needs its own timeout or has to be split into batches.
Chapter 6: Comparing Real-World Solutions#
Trade-off: which scheduler for which job?
You have three jobs: a nightly cache warm-up in a small Python app that already uses Celery, a daily report on a team that runs everything on Kubernetes, and a multi-step monthly billing run that must survive crashes mid-way with a full history of what happened. Which tool would you pick for each, and what does each choice still leave to you?
One strong answer
- Cache warm-up: Celery Beat with a Redis lock (RedBeat). Simple, but one beat process schedules, so high availability needs extra work.
- Daily report: Kubernetes CronJob with
concurrencyPolicy: Forbid. Built in, but a Job is created only approximately once per schedule, so the job must be idempotent. - Billing: Temporal. Workflow state survives crashes and completed steps aren't repeated, at the cost of running more infrastructure; activities are still retried, so keep them idempotent.
6.1 Celery Beat#
What it is: Periodic task scheduler for Celery (Python).
How it works:
# celeryconfig.py
from celery.schedules import crontab
beat_schedule = {
'send-daily-report': {
'task': 'tasks.send_daily_report',
'schedule': crontab(hour=9, minute=0),
},
}Single-node problem: By default, only ONE Celery Beat process should run.
Solution: celery-redbeat (RedBeat scheduler)
# Use Redis for distributed locking
from redbeat import RedBeatSchedulerEntry
# Only one beat will schedule tasksPros:
- Simple setup
- Integrates with Celery ecosystem
- Many community extensions
Cons:
- Not truly distributed (single beat process)
- Requires external solution for HA
- No built-in exactly-once guarantees
6.2 Kubernetes CronJobs#
What it is: Kubernetes-native scheduled jobs.
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-report
spec:
schedule: "0 9 * * *"
concurrencyPolicy: Forbid # Don't run if previous still running
jobTemplate:
spec:
template:
spec:
containers:
- name: report
image: my-report-generator
restartPolicy: OnFailureKey features:
concurrencyPolicy: Forbid- prevents overlapping runsstartingDeadlineSeconds- miss window for scheduling- Automatic retries with
backoffLimit
Pros:
- Built into Kubernetes
- Handles pod failures
- Good observability
Cons:
- No exactly-once: the docs say a CronJob creates a Job "approximately once" per schedule—occasionally two, or none—so jobs must be idempotent
- 1-minute minimum granularity
- Can miss jobs during cluster issues
6.3 Temporal (a fork of Uber's Cadence)#
What it is: Workflow orchestration platform with built-in durability.
from temporalio import workflow
from datetime import timedelta
@workflow.defn
class DailyReportWorkflow:
@workflow.run
async def run(self):
# This is durable - survives crashes
await workflow.execute_activity(
generate_report,
start_to_close_timeout=timedelta(hours=1)
)
await workflow.execute_activity(
send_emails,
start_to_close_timeout=timedelta(minutes=30)
)
# Schedule
schedule = Schedule(
action=ScheduleActionStartWorkflow(
DailyReportWorkflow.run,
id="daily-report",
task_queue="reports",
),
spec=ScheduleSpec(cron_expressions=["0 9 * * *"]),
)Key features:
- Durable execution: Workflow state survives crashes
- Exactly-once workflow logic: The workflow's own decisions take effect once; activities (the code with side effects) are at-least-once, so they still need idempotency
- Automatic retries: With exponential backoff
- Visibility: Full execution history
How it achieves this:
- Event sourcing of the workflow's history
- Deterministic replay
- Completed activity results are recorded and reused on replay instead of re-run (an activity that fails or times out is retried)
Pros:
- Exactly-once orchestration of multi-step workflows
- Handles long-running jobs elegantly
- Great debugging/visibility
Cons:
- Additional infrastructure
- Learning curve
- Overkill for simple cron jobs
6.4 Comparison Table#
| Feature | Celery Beat | K8s CronJobs | Temporal |
|---|---|---|---|
| Deployment | Simple | Kubernetes required | Dedicated cluster |
| Exactly-once | No | No | Workflow logic yes; activities at-least-once |
| Crash recovery | Manual | Automatic | Automatic |
| Long-running jobs | Timeout issues | Pod limits | Native support |
| Visibility | Basic | kubectl | Excellent UI |
| Complexity | Low | Medium | High |
| Best for | Simple Python apps | Cloud-native apps | Critical workflows |
6.5 When to Use What#
%% title: Decision flowchart
flowchart TD
N(["Need scheduled jobs?"]):::client --> Q1{"Simple cron,<br/>single server?"}
Q1 -->|"YES"| A1["Use Linux cron"]:::ok
Q1 -->|"NO"| Q2{"Using Kubernetes?"}
Q2 -->|"YES"| A2["Start with K8s CronJobs"]:::ok
A2 --> Q2B{"Need exactly-once?"}
Q2B --> A2B["Add idempotency<br/>in your job"]:::core
Q2 -->|"NO"| Q3{"Python/Celery app?"}
Q3 -->|"YES"| A3["Use Celery Beat<br/>+ Redis lock"]:::ok
Q3 -->|"NO"| Q4{"Critical financial/<br/>compliance jobs?"}
Q4 -->|"YES"| A4["Use Temporal<br/>or similar"]:::ok
Q4 -->|"NO"| A5["Build custom solution using<br/>this day's patterns"]:::coreThe leader dies mid-job
Q1/3 The leader crashes, but the job it triggered keeps running on a worker on another machine. Why doesn't the new leader schedule it again?
Answer & explanation
Answer: C
Execution state lives in the job store, not in the leader's memory. A fresh heartbeat means "still running"; a stale one means "assume dead and reschedule".
Q2/3 A daily email job crashes after sending 50 of 200 emails, then is re-run. How does the idempotent version avoid emailing those 50 people twice?
Answer & explanation
Answer: B
Per-item markers make the job resumable: a retry picks up where it left off. Checkpoints such as
last_customer_idadd the same idea for large batches.Q3/3 A monthly billing workflow has several steps, must survive crashes mid-way, and needs exactly-once behavior with full execution history. Which tool does the lesson point to?
Answer & explanation
Answer: A
Temporal persists workflow state (event sourcing plus deterministic replay), so completed steps survive crashes and are not repeated; activities are still retried on failure, so keep them idempotent. That is worth the extra infrastructure for critical financial jobs. K8s CronJobs prevent overlapping runs but guarantee neither exactly-once nor at-least-once.
Part III: Week 2 Synthesis#
Chapter 7: Bringing It All Together#
7.1 The Failure-First Mindset#
This week, we've internalized a crucial mindset shift:
Before Week 2
"How do I make this work?"
- → Design for the happy path
- → Handle errors as afterthought
- → Hope failures are rare
After Week 2
"How will this fail?"
- → Design for failures first
- → Make failures visible and recoverable
- → Assume failures are constant
7.2 The Patterns We've Learned#
- DAY 1: TIMEOUTS
- Timeout budgets — total time allocation
- Cascading timeouts — shrinking budgets through call chain
- Deadline propagation — pass deadline, not duration
- Connection vs read vs write timeouts
- DAY 2: IDEMPOTENCY
- Idempotency keys — client-generated unique IDs
- Request fingerprinting — hash of request content
- Idempotency store — remember processed requests
- TTL management — how long to remember
- DAY 3: CIRCUIT BREAKERS
- Three states — Closed → Open → Half-Open
- Failure rate vs count thresholds
- Gradual recovery — prevent thundering herd
- Fallback strategies — graceful degradation
- DAY 4: WEBHOOKS
- At-least-once delivery — retry until acknowledged
- Exponential backoff with jitter
- Dead letter queues — for failed deliveries
- Signature verification — security
- DAY 5: DISTRIBUTED CRON
- Leader election — single scheduler
- Fencing tokens — prevent split-brain
- Job heartbeats — detect stuck jobs
- Checkpoint/resume — idempotent execution
7.3 How They Connect#
%% title: How they connect flowchart TD DC["DISTRIBUTED CRON (Day 5)<br/>Uses leader election<br/>+ fencing tokens"]:::core DC --> TO["TIMEOUTS (Day 1)<br/>Job timeouts,<br/>execution limits"]:::service DC --> ID["IDEMPOTENCY (Day 2)<br/>Jobs must be idempotent<br/>for retries"]:::service TO --> CB["CIRCUIT BREAKERS (Day 3)<br/>Jobs calling<br/>external APIs"]:::service ID --> CB CB --> WH["WEBHOOKS (Day 4)<br/>Jobs trigger<br/>async events"]:::service
7.4 A Complete Example: Order Processing Job#
Let's see all patterns working together:
class OrderProcessingJob:
"""
Daily job to process pending orders.
Demonstrates all Week 2 patterns.
"""
def __init__(
self,
db,
payment_client,
inventory_client,
webhook_service,
circuit_breaker,
idempotency_store
):
self.db = db
self.payment = payment_client
self.inventory = inventory_client
self.webhooks = webhook_service
self.circuit = circuit_breaker
self.idempotency = idempotency_store
async def run(self, ctx: ExecutionContext):
"""
Process all pending orders.
Patterns used:
- Idempotency: Skip already-processed orders
- Timeouts: Limit time per order
- Circuit breaker: Protect external calls
- Webhooks: Notify external systems
- Checkpointing: Resume after crash
"""
# Get checkpoint (for resume after crash) [Day 5]
checkpoint = await ctx.get_checkpoint("progress")
last_order_id = checkpoint.get("last_order_id") if checkpoint else None
# Get pending orders
orders = await self.db.get_pending_orders(after_id=last_order_id)
for order in orders:
# Idempotency check [Day 2]
idem_key = f"process_order_{order.id}"
if await self.idempotency.exists(idem_key):
continue
try:
# Process with timeout [Day 1]
await asyncio.wait_for(
self._process_order(order, ctx),
timeout=30
)
# Mark idempotent [Day 2]
await self.idempotency.set(idem_key, ttl=86400)
except asyncio.TimeoutError:
logger.error("Order processing timeout", order_id=order.id)
# Don't checkpoint this order: the checkpoint still points at the
# last *completed* one, so this order is retried on resume
raise
except CircuitOpenError:
# Circuit open, stop processing [Day 3]
logger.warning("Circuit open, pausing job")
# Checkpoint already points at the last completed order
raise
# Checkpoint progress [Day 5]
await ctx.checkpoint("progress", {"last_order_id": order.id})
async def _process_order(self, order, ctx: ExecutionContext):
"""Process single order with all patterns."""
# Step 1: Check inventory (with circuit breaker) [Day 3]
try:
available = await self.circuit.call(
lambda: self.inventory.check(order.items)
)
except CircuitOpenError:
raise # Propagate to caller
if not available:
await self.db.mark_order_backordered(order.id)
return
# Step 2: Charge payment (with timeout) [Day 1]
try:
payment_result = await asyncio.wait_for(
self.payment.charge(
order.user_id,
order.total,
idempotency_key=f"order_{order.id}" # Day 2
),
timeout=10
)
except asyncio.TimeoutError:
logger.error("Payment timeout", order_id=order.id)
raise
# Step 3: Reserve inventory
await self.inventory.reserve(order.items, order.id)
# Step 4: Update order status
await self.db.mark_order_processing(order.id, payment_result.id)
# Step 5: Send webhook (async) [Day 4]
await self.webhooks.emit(
event_type="order.processing",
data={
"order_id": order.id,
"status": "processing",
"payment_id": payment_result.id
}
)All Week 2 patterns together
Q1/2 In the order-processing job, the payment circuit breaker opens halfway through the batch. What does the job do?
Answer & explanation
Answer: A
An open circuit means "stop calling for now". Checkpointing plus idempotency means the retry resumes cleanly without redoing finished orders.
Q2/2 The job already checks an idempotency store before each order. Why does it also pass
idempotency_key=order_<id>to the payment provider?Answer & explanation
Answer: B
The local marker is written after the charge, so a crash in between leaves no record. The provider-side key closes that gap, which is the Day 2 lesson applied end to end.
Part IV: Interview Questions#
Chapter 8: Week 2 Interview Mastery#
8.1 Distributed Cron Questions#
Q: "How would you design a job scheduler that ensures jobs run exactly once across multiple servers?"
Strong Answer:
"This is a distributed coordination problem. Here's my approach:
Leader Election First, I need a single scheduler to prevent duplicate scheduling. I'd use etcd or ZooKeeper for leader election. The leader has a lease that must be renewed; if it crashes, the lease expires and another node becomes leader.
Fencing Tokens To prevent split-brain, each leader gets a monotonically increasing fencing token. Workers validate this token before executing. Stale tokens from old leaders are rejected.
At-Least-Once Execution True exactly-once is impossible, so I implement at-least-once with idempotent jobs. Each execution has a unique ID. Jobs use this ID to check if work was already done.
Stuck Job Detection Workers send heartbeats during execution. A background process finds jobs that started but haven't heartbeated recently — these are stuck. We reset them to 'failed' for retry.
Checkpointing Long-running jobs checkpoint progress. If a job crashes at 50%, it resumes from the checkpoint rather than starting over.
Connection to Week 2 This combines everything from this week:
- Day 1: Job timeouts prevent infinite execution
- Day 2: Idempotent jobs handle retries safely
- Day 3: Circuit breakers for jobs calling external services
- Day 4: Jobs triggering webhooks for notifications
- Day 5: Leader election and fencing tokens"
8.2 "The Leader Dies" Question#
Q: "The leader crashes while a job is running. What happens?"
Strong Answer:
"Three scenarios to consider:
Scenario 1: Job on different worker The job continues running. New leader checks job store before scheduling and sees execution is in-progress with recent heartbeat. It skips scheduling until that execution completes or times out.
Scenario 2: Job on same server as leader Both crash. The execution record shows 'running' but heartbeat stops. Stuck job detector (on new leader) finds it after threshold, marks it failed, and reschedules.
Scenario 3: Job was just scheduled The job message is in the queue carrying the old leader's token. If workers validate against the current token (as in Mode 3), they reject it; the new leader sees no completed execution for that slot and re-schedules it under its own token. (Alternatively, fence only the scheduler's writes and let already-queued tasks run—either way, jobs must be idempotent.)
Key protections:
- Persistent execution records — not just in-memory
- Heartbeats — distinguish 'running' from 'stuck'
- Fencing tokens — prevent stale leaders from interfering
- Idempotent jobs — safe to re-run if uncertain
I'd also have comprehensive alerting: alert on stuck jobs, alert on jobs exceeding retry limits, alert on leader failovers."
8.3 Week 2 Synthesis Question#
Q: "Walk me through how you'd make a payment processing job reliable."
Strong Answer:
"I'll use all of Week 2's patterns:
Scheduling (Day 5): Job scheduled via distributed cron with leader election. Only one instance schedules. Fencing tokens prevent split-brain.
Execution Setup:
- Execution ID: unique per run
- Idempotency key: derived from job + date
- Timeout: 30 minutes max
- Heartbeat: every 30 seconds
Processing Loop: For each pending payment:
- Idempotency check (Day 2): Skip if
processed:{payment_id}exists - Call payment provider with circuit breaker (Day 3): If circuit open, pause job
- Timeout the call (Day 1): Max 10 seconds per payment
- Mark processed (Day 2): Set
processed:{payment_id}with TTL - Emit webhook (Day 4):
payment.completedevent - Checkpoint (Day 5): Save
last_payment_idfor resume
Failure Handling:
- Timeout → Log, checkpoint, continue
- Circuit open → Checkpoint, pause, retry later
- Crash → Resume from checkpoint, idempotency prevents duplicates
Monitoring:
- Track: success rate, latency, circuit state
- Alert: high failure rate, stuck jobs, circuit open
- Dashboard: pending payments, processing rate, DLQ depth"
Chapter 9: Week 2 Checklist#
Concepts You Should Master#
- Explain why timeouts are essential in distributed systems
- Implement timeout budgets that propagate through service calls
- Design idempotency key strategies for different use cases
- Explain the three states of a circuit breaker
- Choose between count-based and rate-based circuit breakers
- Design at-least-once webhook delivery with retries
- Implement HMAC signature verification
- Explain why exactly-once is impossible and what to do instead
- Implement leader election using etcd or ZooKeeper
- Explain fencing tokens and their role in preventing split-brain
- Design a distributed job scheduler
- Handle "leader dies mid-job" scenario
Implementations You Should Be Able to Code#
- Timeout budget wrapper
- Idempotency store with Redis
- Circuit breaker with three states
- Webhook signature generation and verification
- Retry scheduler with exponential backoff
- Leader election client
- Job executor with heartbeats
- Stuck job detector
Trade-offs You Should Articulate#
| Decision | Trade-off |
|---|---|
| Timeout duration | Too short = false failures; Too long = resource waste |
| Idempotency TTL | Too short = duplicates; Too long = storage cost |
| Circuit threshold | Too low = false opens; Too high = slow detection |
| Retry count | Too few = lost events; Too many = delayed failures |
| Leader lease TTL | Too short = flapping; Too long = slow failover |
| Heartbeat interval | Too frequent = overhead; Too infrequent = slow detection |
Part V: Resources and Further Learning#
Books#
-
"Designing Data-Intensive Applications" by Martin Kleppmann
- Chapter 8: The Trouble with Distributed Systems
- Chapter 9: Consistency and Consensus
- Essential reading for understanding distributed coordination
-
"Database Internals" by Alex Petrov
- Deep dive into consensus algorithms
- Leader election implementations
-
"Site Reliability Engineering" by Google
- Chapter 22: Addressing Cascading Failures
- Chapter 23: Managing Critical State
Papers#
-
"In Search of an Understandable Consensus Algorithm" (Ongaro & Ousterhout), the Raft paper
- https://raft.github.io/raft.pdf
- Understandable consensus
-
"ZooKeeper: Wait-free coordination"
-
"FLP Impossibility Result"
- Why consensus is fundamentally hard
Tools and Libraries#
- etcd: https://etcd.io/ - Distributed key-value store
- ZooKeeper: https://zookeeper.apache.org/ - Coordination service
- Temporal: https://temporal.io/ - Workflow orchestration
- Celery: https://docs.celeryq.dev/ - Task queue for Python
- APScheduler: https://apscheduler.readthedocs.io/ - Python job scheduling
Online Resources#
- Jepsen: https://jepsen.io/ - Distributed systems testing
- Fly.io Blog: Excellent distributed systems articles
- Cloudflare Blog: Real-world distributed systems stories
Exercises#
Exercise 1: Implement Leader Election#
Build a leader election system using Redis:
- Use SETNX for atomic leader claim
- Implement lease renewal
- Handle leader failure detection
- Add fencing tokens
Exercise 2: Build Distributed Cron#
Create a complete distributed cron system:
- Leader election for scheduler
- Job queue for workers
- At-least-once execution
- Stuck job detection
- Comprehensive logging
Exercise 3: Chaos Testing#
Write chaos tests for your distributed cron:
- Kill leader mid-job
- Network partition simulation
- Clock skew injection
- Verify no duplicate executions
Week 2 Conclusion#
What We've Accomplished#
This week transformed how you think about distributed systems:
- Week 1: Data at Scale — "How do I handle more traffic?"
- Week 2: Failure-First Design — "How do I handle failures gracefully?"
You now have a complete toolkit for building reliable systems:
| Day | Pattern | Superpower |
|---|---|---|
| 1 | Timeouts | Never wait forever |
| 2 | Idempotency | Safe to retry anything |
| 3 | Circuit Breakers | Fail fast, protect the system |
| 4 | Webhooks | Reliable async communication |
| 5 | Distributed Cron | Effectively-once jobs (at-least-once + idempotency) |
Looking Ahead#
Week 3 will build on this foundation as we dive into Messaging and Async Processing:
- Queues vs streams
- The transactional outbox
- Backpressure and flow control
- Dead letters and poison pills
The failure-first mindset you've developed this week will be essential as we tackle these more complex topics.
Congratulations on completing Week 2! 🎉
You've completed the "Failure-First Design" week. Next up: Week 3 — Messaging and Async Processing.
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.
Distributed Periodic Scheduling with Cron
Štěpán Davidovič · 2016- What
- The SRE book's description of Google's datacenter-wide cron service.
- Key idea
- Replicas elect a leader with Paxos and record each launch's state before and after starting it, so a new leader knows whether a job already ran.
- Why here
- This lesson's 'leader dies mid-job' scenario solved at Google: decide between skipping and double-running, and persist launch state to make that call.
Executing Cron Scripts Reliably At Scale
Claire Adams · 2023- What
- How Slack replaced a single cron box with a scheduling service.
- Key idea
- One Kubernetes pod is elected leader to schedule jobs while others stand by; the work runs on Slack's Job Queue and a Vitess table dedupes and tracks each run.
- Why here
- The complete distributed cron design from this lesson: leader election for the scheduler, a queue for execution and a dedupe table for exactly-once runs.
Clockwork: The Backbone of PhonePe's 2 Billion Daily Jobs
Snehasish Roy · 2024- What
- PhonePe's central job scheduler, used for merchant settlements and coupon expiry.
- Key idea
- Clients submit one-off or recurring jobs that fire HTTP callbacks; jobs are stored durably and spread across workers, handling over 100K schedules per second.
- Why here
- Shows why this lesson centralises scheduling: embedded schedulers lose jobs on crashes and need per-service leader election, a platform solves it once.
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.
