Data Reliability and Fault Tolerance Questions
Designing pipelines that survive failures: retries, idempotency, checkpointing, exactly-once semantics, dead-letter handling, and recovery/replay. Covers reasoning about partial failures, poison messages, and consistency guarantees under faults. The resilience angle distinct from monitoring (detecting and alerting on a failure) and from Workflow Orchestration and Scheduling (the DAG/scheduler mechanics that decide whether and when a task runs again, including backfills and dependency management): this topic owns whether the data itself stays correct, not lost, not duplicated, not corrupted, when a process is retried or replayed.
Implement an idempotent HTTP POST handler in Python (Flask or plain WSGI) that accepts JSON with a unique request_id and payload. The handler must return a cached response for repeated request_id values. Use an in-memory store with TTL for this exercise and show concurrency-safe code and O(1) lookups.
Sample Answer
Direct answer
An idempotent POST handler needs three things: a client-supplied request_id that uniquely names the logical operation, a store that maps that ID to the response it already produced, and an atomic check-then-set so two retries racing in at once cannot both execute the side effect. Below is a plain WSGI handler (no framework dependency) backed by a lock-protected dictionary with per-entry TTL. Dict lookups are O(1) average case, and the lock is held across the whole check-compute-set sequence, which is the detail that actually makes it concurrency-safe rather than merely "looks idempotent in the happy path."
Structured elaboration
Why check-then-set must be one atomic step, not two. The tempting bug is: check the dict for request_id under the lock, release the lock, then compute and store the result. That reopens exactly the race the handler exists to close: two threads can both see "not present" before either has written its result, and both run the side effect. The lock in the implementation below stays held across compute_fn(), so a second thread arriving mid-computation blocks until the first thread's result is visible, then reads that cached result instead of recomputing.
TTL is a memory-bound, not a correctness guarantee. The store cannot grow forever, so entries expire after ttl_seconds. This means the handler is idempotent only within the TTL window: a retry that arrives after the entry expired is treated as a new operation and reruns the side effect. The TTL must therefore be set to comfortably exceed the caller's maximum retry backoff, not to an arbitrary "cache expiry" value borrowed from an unrelated system.
O(1) lookup, not O(1) memory. A plain Python dict gives average O(1) get/set, but the size of the dict grows with the number of distinct in-flight request IDs. Production versions add either a bounded LRU eviction on top of the TTL, or move the store to something with native TTL and eviction (Redis SETEX, DynamoDB with a TTL attribute) so the process does not accumulate unbounded memory between GC/purge cycles.
Worked example
"""
Idempotent HTTP POST handler using plain WSGI (no Flask dependency needed).
Demonstrates: in-memory store with TTL, thread-safe (lock-protected) O(1) lookups,
returns the cached response for a repeated request_id.
"""
import json
import time
import threading
class IdempotencyStore:
"""Thread-safe in-memory cache: request_id -> (response_body, expires_at)."""
def __init__(self, ttl_seconds=300):
self._ttl = ttl_seconds
self._lock = threading.Lock()
self._store = {} # dict lookups are O(1) average case
def get_or_set(self, request_id, compute_fn):
"""
Atomic check-then-set under ONE lock so two concurrent requests with the
same request_id cannot both "win" and run compute_fn twice.
"""
now = time.time()
with self._lock:
entry = self._store.get(request_id)
if entry is not None:
response, expires_at = entry
if expires_at > now:
return response, True
# expired: fall through and recompute
# Compute WHILE STILL HOLDING THE LOCK, so a racing thread blocks
# here instead of also seeing "not cached".
response = compute_fn()
self._store[request_id] = (response, now + self._ttl)
return response, False
def purge_expired(self):
now = time.time()
with self._lock:
expired = [k for k, (_, exp) in self._store.items() if exp <= now]
for k in expired:
del self._store[k]
return len(expired)
def size(self):
with self._lock:
return len(self._store)
STORE = IdempotencyStore(ttl_seconds=300)
CHARGE_COUNTER = {"n": 0}
COUNTER_LOCK = threading.Lock()
def process_payload(payload):
"""Simulates the actual side-effecting work (e.g., charging a card)."""
with COUNTER_LOCK:
CHARGE_COUNTER["n"] += 1
return {"status": "processed", "charge_seq": CHARGE_COUNTER["n"], "echo": payload}
def application(environ, start_response):
if environ.get("REQUEST_METHOD") != "POST":
start_response("405 Method Not Allowed", [("Content-Type", "application/json")])
return [json.dumps({"error": "POST only"}).encode()]
try:
length = int(environ.get("CONTENT_LENGTH", 0) or 0)
raw = environ["wsgi.input"].read(length)
body = json.loads(raw)
request_id = body["request_id"]
payload = body["payload"]
except (KeyError, ValueError, TypeError):
start_response("400 Bad Request", [("Content-Type", "application/json")])
return [json.dumps({"error": "request_id and payload required"}).encode()]
response, was_cached = STORE.get_or_set(request_id, lambda: process_payload(payload))
headers = [("Content-Type", "application/json"),
("X-Idempotent-Replayed", "true" if was_cached else "false")]
start_response("200 OK", headers)
return [json.dumps(response).encode()]
def call_app(request_id, payload):
import io
body = json.dumps({"request_id": request_id, "payload": payload}).encode()
environ = {"REQUEST_METHOD": "POST", "CONTENT_LENGTH": str(len(body)), "wsgi.input": io.BytesIO(body)}
captured = {}
def start_response(status, headers):
captured["status"] = status
captured["headers"] = dict(headers)
result = application(environ, start_response)
return captured["status"], captured["headers"], json.loads(b"".join(result))
def main():
s1, h1, b1 = call_app("abc-1", {"amount": 42})
print("Call 1:", s1, h1["X-Idempotent-Replayed"], b1)
s2, h2, b2 = call_app("abc-1", {"amount": 42}) # retry, same request_id
print("Call 2 (retry):", s2, h2["X-Idempotent-Replayed"], b2)
s3, h3, b3 = call_app("abc-2", {"amount": 99}) # different request_id
print("Call 3 (new id):", s3, h3["X-Idempotent-Replayed"], b3)
# Concurrency proof: 20 threads hit the SAME new request_id at once.
results, results_lock = [], threading.Lock()
def worker():
_, _, b = call_app("concurrent-1", {"amount": 7})
with results_lock:
results.append(b["charge_seq"])
threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads: t.start()
for t in threads: t.join()
print("Concurrency test: 20 threads, same request_id, distinct charge_seq values:", sorted(set(results)))
print("Total side-effect executions across the run:", CHARGE_COUNTER["n"])
if __name__ == "__main__":
main()
Output (actually executed with python3):
Call 1: 200 OK false {'status': 'processed', 'charge_seq': 1, 'echo': {'amount': 42}}
Call 2 (retry): 200 OK true {'status': 'processed', 'charge_seq': 1, 'echo': {'amount': 42}}
Call 3 (new id): 200 OK false {'status': 'processed', 'charge_seq': 2, 'echo': {'amount': 99}}
Concurrency test: 20 threads, same request_id, distinct charge_seq values: [3]
Total side-effect executions across the run: 3
Call 2 replays call 1's exact result (X-Idempotent-Replayed: true, same charge_seq: 1, no new charge). The concurrency test is the load-bearing proof: 20 threads racing on the identical request_id produce exactly one distinct charge_seq and the global counter shows exactly 3 total executions across the whole run (call 1, call 3, and the single winner of the 20-way race), not 22. That is what "concurrency-safe" concretely means here: the lock, not luck, decided the outcome.
Trade-offs and pitfalls
- This is not a hypothetical bug. Dropping the lock between check and set breaks the guarantee, and it is worth seeing fail, not just asserting:
import threading, time
class BrokenStore:
def __init__(self):
self._lock = threading.Lock()
self._store = {}
def get_or_set(self, request_id, compute_fn):
with self._lock:
entry = self._store.get(request_id)
if entry is not None:
return entry, True
time.sleep(0.001) # widen the check-to-set race window
response = compute_fn()
with self._lock:
self._store[request_id] = response
return response, False
COUNTER = {"n": 0}
lock = threading.Lock()
def process():
with lock:
COUNTER["n"] += 1
return COUNTER["n"]
store = BrokenStore()
results, rl = [], threading.Lock()
def worker():
r, _ = store.get_or_set("same-id", process)
with rl:
results.append(r)
threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads: t.start()
for t in threads: t.join()
print("Broken store, 20 threads, same request_id, distinct results:", sorted(set(results)))
print("Total side-effect executions:", COUNTER["n"])
Output (actually executed with python3):
Broken store, 20 threads, same request_id, distinct results: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
Total side-effect executions: 20
All 20 threads ran the side effect independently, exactly the duplicate-charge bug idempotency exists to prevent. The only structural difference from the working version is where the lock is released.
- Common mistake: client mints a new
request_idon every retry (e.g.uuid4()generated fresh in the retry loop instead of reused from the original attempt). The handler is correct but useless if the caller does not reuse the ID. - Common mistake: no TTL at all. An unbounded dict is a slow memory leak in a long-running process; TTL plus periodic
purge_expired()(or moving to a store with native TTL like Redis or DynamoDB) bounds it. - This in-memory store does not survive a process restart or scale past one process. In production, the store itself needs to be externalized (Redis, DynamoDB) so idempotency holds across restarts and horizontally scaled instances; the in-memory version here is correct for a single process and is the right shape to translate directly onto an external store.
Architect a replayable ingestion pipeline that supports transactional reprocessing: guarantees ordering per key, deduplication, idempotent sinks, and the ability to replay from arbitrary offsets across Kafka and object-storage input sources. Include the metadata/tracking store design, how you will coordinate replays across multiple pipelines, and how to ensure correctness and performance during reprocessing.
Sample Answer
Direct answer
A replayable ingestion pipeline needs four pieces: a REPLAY MANIFEST that is the durable source of truth for what a given replay run covers and how far it has progressed (itself checkpointed and resumable), per-key ordering preserved by re-deriving a stable ordering key from event metadata rather than relying on physical read order (which Kafka and object storage do not share), deduplication and idempotent sinks keyed by an identity that is IDENTICAL whether a record arrives via normal ingestion or replay, and replay execution isolated into a separate shadow output track that is validated and then atomically promoted, rather than replayed directly against the live production output.
Structured elaboration
Metadata/tracking store (the replay manifest). One row per replay run: a replay ID, the pipeline it targets, a range specification (an offset range per Kafka partition; an object-key prefix plus version for object storage), a status (pending, running, validating, promoted, aborted), and its own progress checkpoints. The replay job is itself checkpointed against this manifest, so an interrupted replay is resumable rather than needing to restart from the beginning.
Ordering per key across two source types that do not share a native ordering concept. Kafka gives per-partition FIFO order for free; object storage gives none at all, files simply exist. Relying on either source's physical read or listing order for cross-source ordering is exactly the mistake a naive fold over arrival order, rather than a fixed key, makes. The fix is the same one: every record carries an explicit ordering key at ORIGINAL ingestion time (an event timestamp or a per-key monotonic sequence number), and replay sorts or routes by that key rather than by however the record happens to be read back.
Deduplication and idempotent sinks. A replayed record's identity key (used for the sink's idempotent upsert) must be IDENTICAL to what original ingestion would have assigned it. This means no separate "replay merge logic" is needed: the sink's ordinary idempotent-write path naturally reconciles a replay against anything already applied, whether the original write succeeded, partially succeeded, or never happened.
Coordinating replays across multiple, dependent pipelines. Downstream pipelines that consume this pipeline's output form a dependency graph. A replay must not silently feed a downstream pipeline mid-run, since that mixes replay-track data into the downstream pipeline's normal live processing. Tagging every replayed record with its replay ID lets downstream consumers distinguish replay-track output from production-track output, and a coordinator sequences dependent pipelines' replays in TOPOLOGICAL order (an upstream pipeline's replay must reach "promoted" status before a dependent downstream pipeline's corresponding replay begins), rather than letting them race.
Correctness and performance during reprocessing. Correctness: replay writes into a shadow output (a separate table or namespace), validated against expected checksums or row counts before an atomic promote makes it visible to consumers, so a bug discovered mid-replay never contaminates the live production output. Performance: replay throughput is throttled against the source's and sink's normal capacity budget, since an unthrottled replay competing with live traffic for the same infrastructure risks becoming a NEW incident rather than resolving the one it was meant to fix.
flowchart TB
subgraph Sources
K[Kafka topics]
O[Object storage]
end
K --> R[Replay coordinator]
O --> R
M[(Replay manifest:\nrange, status, progress)] <--> R
R --> S[Shadow output namespace]
S --> V[Validation: checksum / row-count check]
V -->|pass| P[Atomic promote]
V -->|fail| X[Abort, alert]
P --> D1[Downstream pipeline 1]
P -->|replay_id gates start| D2[Downstream pipeline 2]
Worked example
Pipeline P1 ingests raw payment events; pipeline P2 consumes P1's output to build a daily settlement aggregate. A bug in P1 is found and fixed; a replay of P1 for the affected 3-day window is registered in the manifest as replay_id=R100, covering the corresponding Kafka offset ranges and object-storage key prefixes for that window. P1's replay writes to a shadow table, validated (row count and checksum match the expected recomputation) against the affected window, then atomically promoted. Only once the manifest shows R100: promoted does the coordinator register and release P2's OWN replay (replay_id=R101) for the same 3-day window, now correctly consuming P1's corrected, promoted output rather than racing against a still-in-progress P1 replay or against P1's pre-fix production data.
Trade-offs and pitfalls
- Common mistake: replaying directly into the live production output instead of a shadow track. This is faster to build but means a bug discovered mid-replay (or a validation failure after the fact) has already contaminated what consumers are reading; the shadow-then-promote pattern trades some extra storage and compute during the validation window for the ability to catch a bad replay before it ships.
- Common mistake: letting a downstream pipeline's replay start before its upstream dependency's replay is confirmed promoted. This silently mixes replay-track and production-track (or partially-replayed) upstream data into the downstream replay, corrupting its result in a way that is hard to detect after the fact.
- Coordinating replay ordering across many dependent pipelines adds real end-to-end latency to a full-catalog replay. This is worth paying specifically when correctness genuinely depends on the pipelines observing a consistent upstream state, not as a default for every replay regardless of whether the pipelines are actually coupled.
- Throttling replay against live capacity extends how long the replay takes, but an unthrottled replay competing for the same infrastructure as live production traffic risks becoming the SECOND incident on top of the one the replay was meant to fix.
Design a DLQ and retry strategy for a pipeline that writes to a flaky external HTTP API, or depends on several intermittently-failing third-party services more broadly: include error classification, exponential backoff with jitter, circuit breaker thresholds, durable buffering and handoffs to guarantee at-least-once delivery, a DLQ schema, and replay tooling, while avoiding dropping high-priority events during a downstream outage.
Sample Answer
Direct answer
Against a flaky external HTTP API or multiple intermittently-failing third-party services, the DLQ and retry design needs to separate three distinct concerns that a naive implementation conflates: error classification (which failures are worth retrying at all), backoff-with-circuit-breaking (how hard to hammer a struggling dependency), and durable buffering (guaranteeing at-least-once delivery is not lost while the dependency is down), with an explicit priority lane so a downstream outage does not indiscriminately drop or delay high-priority events behind lower-priority ones.
Structured elaboration
Error classification. Distinguish transient errors (5xx, timeouts, connection resets, explicit rate-limit responses) from permanent ones (4xx validation errors, a malformed request the API will never accept) at the FIRST failure: permanent errors route to the DLQ immediately, transient ones enter the retry path.
Exponential backoff with jitter. Each retry waits base_delay * 2^attempt, with random jitter added to avoid synchronized retry storms across many concurrent callers hitting the same recovering dependency simultaneously (the thundering-herd problem: without jitter, every caller that started retrying at the same failure moment also retries at the same moments afterward, re-overwhelming a dependency just as it starts to recover).
Circuit breaker thresholds. Track the recent failure rate against this specific dependency; once it crosses a threshold (e.g., more than 50% of the last 20 calls failed), OPEN the circuit: stop calling the dependency entirely for a cooldown period, failing fast (straight to durable buffering, not even attempting the call) instead of continuing to retry into a known-down service. After the cooldown, allow a small number of HALF-OPEN probe calls; if they succeed, close the circuit and resume normal traffic; if they fail, reopen and extend the cooldown. This protects both the caller (no more wasted retry cycles) and the struggling dependency (no retry storm compounding its own recovery).
Durable buffering for at-least-once delivery. While the circuit is open (or while transient retries are exhausted for an individual event), the event must not simply be held in process memory, a crash of the calling service during an extended outage would lose it. Durable buffering (writing to a persistent queue, or the outbox pattern) ensures the event survives a crash of the CALLING service, independent of how long the DOWNSTREAM dependency stays unavailable.
DLQ schema and replay tooling. Beyond the general DLQ fields (failure reason, offsets, timestamps), for a third-party-API-flakiness DLQ specifically: which dependency failed, the HTTP status/error code observed, and how many retry attempts were exhausted before DLQ routing, so replay tooling can distinguish "this dependency is back up now, safe to mass-replay" from "this specific request has a permanent issue and needs individual review."
Avoiding dropped high-priority events during an outage. A single FIFO retry/buffer queue treats all events equally, so during an extended outage, high-priority events queue behind whatever lower-priority volume arrived first. A priority-lane design (separate buffers or a priority field respected by the retry scheduler) ensures high-priority events are retried and eventually delivered (or DLQ'd for immediate human attention) ahead of routine, lower-priority traffic, rather than waiting in line behind it.
Worked example
A payment-notification service calls three third-party services per transaction: a fraud-check API, an SMS provider, and an analytics webhook, at 10,000 transactions/hour. The SMS provider suffers a 45-minute outage. With per-dependency circuit breakers: the SMS-provider circuit opens after its failure-rate threshold is crossed (say, within the first 2 minutes of the outage), so subsequent calls fail fast to durable buffering instead of each waiting through a full retry-with-backoff cycle before failing; the fraud-check and analytics-webhook circuits stay closed and unaffected, since circuit state is per-dependency, not global. Over the 45-minute outage:
10,000 txns/hour×6045 hours=7,500 SMS notifications durably bufferedWith a priority lane distinguishing "payment confirmation SMS" (high priority) from "marketing SMS" (low priority, if the same provider also carries both), the 7,500 buffered high-priority confirmations are replayed FIRST once the circuit closes (probe calls succeed), rather than interleaved with or queued behind any lower-priority SMS traffic that also backed up during the same outage.
Trade-offs and pitfalls
- Common mistake: one global circuit breaker across all third-party dependencies. A struggling analytics webhook should not stop calls to a healthy fraud-check API; per-dependency circuit state (as in the worked example) is what isolates failures to the specific dependency actually failing.
- Common mistake: retrying without jitter. Under sustained load, synchronized retries from many callers can repeatedly re-trigger the exact failure condition (overload) that caused the outage in the first place, effectively self-inflicting a longer outage.
- Durable buffering has its own capacity limits. An outage lasting far longer than expected (hours, not minutes) can eventually fill even a durable buffer; the design needs an explicit policy (alert and let it grow with monitored bounds, or begin routing the LOWEST-priority tier straight to DLQ once buffer pressure crosses a threshold) rather than an unbounded assumption.
- Half-open probing needs its own rate limit. Too many concurrent probe calls after a cooldown effectively re-opens the flood the circuit breaker was protecting against; a small, fixed probe rate (a handful of calls, not a full traffic resume) is standard.
You have a streaming ETL that consumes messages and processes them one-by-one with a function that may occasionally fail. Design a checkpointing and retry mechanism in Python to ensure exactly-once processing semantics across crashes, considering idempotency, offset storage, and external systems. Outline code-level strategies and discuss trade-offs.
Sample Answer
Direct answer
Exactly-once processing across crashes needs three pieces working together, none sufficient alone: bounded per-message retry for transient failures (using the SAME offset/idempotency key on every retry attempt, never a fresh one), an idempotent write to the external sink keyed by offset (so a replayed message converges rather than duplicates), and an offset checkpoint that only advances AFTER the sink write is CONFIRMED applied, never before. Below is a full implementation proving the crash-recovery property end to end: a message applied to the sink right before a simulated crash, before its offset is checkpointed, is correctly replayed on restart and correctly recognized as a duplicate, converging to the exact same final state as a crash-free run.
Structured elaboration
Why retries must reuse the same key. A retry that mints a fresh identifier per attempt defeats the whole mechanism; here the message's own OFFSET (assigned once by the source) is the natural, stable idempotency key across every retry attempt for that message.
Distinguishing transient from permanent failures. A TransientError (a network blip, a downstream timeout) is retried up to a bounded attempt count; a PermanentError (data the function will never successfully process, e.g. malformed input) routes directly to a DLQ without wasting the retry budget, since retrying a permanent failure can never succeed.
Why the checkpoint commit must happen strictly AFTER the sink write. This is the single detail that makes exactly-once (not merely at-least-once) achievable here: if the offset were checkpointed BEFORE confirming the sink write succeeded, a crash between those two steps would silently lose that message (the checkpoint claims it was handled; it was not). By committing only after confirmed success, the WORST case on a crash is that a message gets REPLAYED (checkpoint had not yet advanced), never silently dropped, and the idempotent sink is what makes that replay safe rather than a duplicate-effect bug.
Complexity and offset storage. The offset store here is a simple in-memory monotonic counter; in production this would itself need to be durable (a database row, or WAL/checkpoint-backed storage) so it survives the crash that triggered the restart in the first place, an important detail this exercise deliberately simplifies to isolate the retry-plus-idempotent-sink mechanism being demonstrated.
Worked example
"""
Streaming ETL: consume messages one-by-one with a function that may
occasionally fail. Checkpointing + retry mechanism for exactly-once
processing semantics across crashes: offset checkpointing (only AFTER
confirmed processing), per-message idempotent apply to an external sink
(keyed by offset), and bounded per-message retry before DLQ routing.
"""
class TransientError(Exception): pass
class PermanentError(Exception): pass
class ExternalSink:
"""Idempotent: apply(offset, ...) is a no-op if that offset was already applied."""
def __init__(self):
self.applied_offsets = set()
self.state = {}
self.write_count = 0
def apply(self, offset, key, value):
self.write_count += 1
if offset in self.applied_offsets:
return "duplicate_noop"
self.applied_offsets.add(offset)
self.state[key] = self.state.get(key, 0) + value
return "applied"
class OffsetStore:
"""Advances ONLY after the sink write for that offset is confirmed applied."""
def __init__(self):
self.committed_offset = -1
def commit(self, offset):
if offset > self.committed_offset:
self.committed_offset = offset
def resume_from(self):
return self.committed_offset + 1
def process_with_retry(sink, offset, key, value, process_fn, max_attempts=5):
for attempt in range(1, max_attempts + 1):
try:
processed_value = process_fn(value)
return sink.apply(offset, key, processed_value), attempt
except PermanentError:
return "dlq", attempt
except TransientError:
continue # (production: sleep with exponential backoff + jitter)
return "failed_after_retries", max_attempts
def run_pipeline(messages, sink, offset_store, process_fn, crash_after_offset=None):
resume_from = offset_store.resume_from()
results = []
for offset, key, value in messages:
if offset < resume_from:
continue # already committed before this run
outcome, attempts = process_with_retry(sink, offset, key, value, process_fn)
results.append((offset, outcome, attempts))
if outcome in ("applied", "duplicate_noop"):
if crash_after_offset is not None and offset == crash_after_offset:
return results # simulated crash: sink wrote, but commit() never runs
offset_store.commit(offset)
return results
def main():
call_count = {"n": 0}
def process_fn(value):
call_count["n"] += 1
if call_count["n"] % 3 == 0:
raise TransientError("simulated transient failure")
return value * 2
messages = [(i, f"key-{i % 3}", i) for i in range(10)]
sink = ExternalSink()
offset_store = OffsetStore()
results1 = run_pipeline(messages, sink, offset_store, process_fn)
print("Phase 1 (no crash) results:", [(o, r) for o, r, a in results1])
assert offset_store.committed_offset == 9
call_count["n"] = 0
sink2 = ExternalSink()
offset_store2 = OffsetStore()
results2 = run_pipeline(messages, sink2, offset_store2, process_fn, crash_after_offset=5)
print("\nPhase 2 (crash after offset 5's sink write, before checkpoint commit):")
print(" Committed offset at crash point:", offset_store2.committed_offset)
assert offset_store2.committed_offset == 4
resume_point = offset_store2.resume_from()
print(f"\nPhase 3 (restart): resuming from offset {resume_point} (offset 5 will REPLAY)")
results3 = run_pipeline(messages, sink2, offset_store2, process_fn)
print(" Results after restart:", [(o, r) for o, r, a in results3])
assert offset_store2.committed_offset == 9
print("\nFinal sink state (post-crash-and-restart):", sink2.state)
print("Final sink state (clean run, for comparison): ", sink.state)
assert sink2.state == sink.state
offset5_outcome = [r for o, r, a in results3 if o == 5]
print(f"Offset 5's outcome on replay: {offset5_outcome}")
assert offset5_outcome == ["duplicate_noop"]
if __name__ == "__main__":
main()
Output (actually executed with python3):
Phase 1 (no crash) results: [(0, 'applied'), (1, 'applied'), (2, 'applied'), (3, 'applied'), (4, 'applied'), (5, 'applied'), (6, 'applied'), (7, 'applied'), (8, 'applied'), (9, 'applied')]
Phase 2 (crash after offset 5's sink write, before checkpoint commit):
Committed offset at crash point: 4
Phase 3 (restart): resuming from offset 5 (offset 5 will REPLAY)
Results after restart: [(5, 'duplicate_noop'), (6, 'applied'), (7, 'applied'), (8, 'applied'), (9, 'applied')]
Final sink state (post-crash-and-restart): {'key-0': 36, 'key-1': 24, 'key-2': 30}
Final sink state (clean run, for comparison): {'key-0': 36, 'key-1': 24, 'key-2': 30}
Offset 5's outcome on replay: ['duplicate_noop']
Offset 5's exact fate is proven, not asserted: after the simulated crash left the checkpoint at offset 4 (one behind offset 5, whose sink write had ALREADY succeeded), restart correctly replays offset 5, and the sink's idempotent apply recognizes it as duplicate_noop, not a second addition. The final state after the crash-and-restart run is byte-identical to the clean, crash-free run, the concrete proof that this design achieves exactly-once EFFECT (correct final state) even though the offset-5 message was technically delivered/processed twice (at-least-once at the message level, exactly-once at the effect level, the standard, achievable distinction most exactly-once designs are built around).
Trade-offs and pitfalls
- Common mistake: committing the offset BEFORE confirming the sink write. This is the single change that would silently break the guarantee: a crash between commit and sink-write would then leave the checkpoint claiming a message was handled that never actually was, permanently losing it (worse than a duplicate, an outright loss).
- This exercise's OffsetStore is in-memory and would itself need to be durable in production (a database, or WAL-backed durable storage), since the crash-recovery guarantee demonstrated here assumes the LAST COMMITTED offset survives the crash; an in-memory-only offset store would lose that information along with everything else on a real crash.
- Retrying with a fixed attempt cap, no offset-specific state carried across restarts. If a message fails all
max_attemptsretries within one run and is not aPermanentError, this simple design does not persist a partial-retry count across a crash-and-restart; production systems often track retry counts durably too, so a message is not given a fresh full retry budget on every restart indefinitely. - "Exactly-once effect, at-least-once delivery" is the correct, achievable framing, not a compromise. Claiming true exactly-once DELIVERY (the message is guaranteed to arrive at the processing function exactly one time, ever) is generally not achievable in a distributed system with crashes; exactly-once EFFECT via idempotent apply is the standard, practical target, and this distinction is worth stating explicitly in an interview to signal precise understanding.
You operate a streaming enrichment pipeline that issues thousands of downstream API requests per second to a third-party service, which sometimes fails or rate-limits you. Design a retry policy and circuit-breaker approach that prevents cascading failures while minimizing data loss. Include backoff strategy, concurrency limits, queuing, local caching and batching, fallback behavior, and monitoring thresholds.
Sample Answer
Direct answer
Against a high-throughput (thousands of requests/sec), occasionally-flaky-or-rate-limited third-party dependency, the design needs to reduce LOAD on the dependency proactively (local caching and request batching, so fewer calls are even needed) alongside reactive protection (circuit breaking and bounded concurrency, so failures do not cascade), plus a fallback (serve a cached or default value rather than nothing) so a downstream outage degrades gracefully instead of stalling the whole enrichment pipeline.
Structured elaboration
Backoff strategy. Exponential backoff with jitter for individual retryable failures, distinguishing a RATE-LIMIT response (typically carries an explicit Retry-After header or similar, which should be honored directly rather than guessed at via generic backoff) from a generic transient failure (backoff without a specified retry time).
Concurrency limits. Cap the number of IN-FLIGHT concurrent requests to the dependency (a semaphore or connection-pool limit), independent of the pipeline's own overall throughput; at "thousands of requests per second," an unbounded concurrency model can itself overwhelm a dependency that would otherwise handle a properly-paced request rate fine, self-inflicting the rate-limiting or degradation the design is meant to avoid.
Queuing. Requests that cannot be sent immediately (concurrency limit reached, or circuit open) queue rather than block the calling thread/task indefinitely; the queue itself needs a bound (sized against a target absorbed-outage duration) and a policy for what happens once full (reject new requests, or apply the fallback immediately rather than queuing further).
Local caching. For enrichment lookups with any meaningful key-repetition rate (the same entity looked up multiple times across the stream), a local cache (in-process LRU, or a shared Redis cache) serves repeat lookups without a network call at all, directly reducing load on the third-party dependency: caching converts a rate-limit problem into a smaller, more manageable one simply by needing fewer real calls.
Batching. If the third-party API supports a batch endpoint (many entity lookups in one HTTP call), grouping requests before sending reduces the CALL COUNT (and often the effective rate-limit consumption, if the API rate-limits per-call rather than per-entity) at the cost of added latency (waiting to accumulate a batch) and complexity (partial-batch-failure handling: what happens if some entities in a batch succeed and others fail).
Fallback behavior. When the circuit is open, concurrency is exhausted, or a request ultimately fails after retries: serve the most recent CACHED value for that entity if one exists (even if slightly stale, better than nothing for most enrichment use cases), or a documented default/null-enrichment value, explicitly flagged as such downstream (so consumers can distinguish "enriched normally" from "enrichment unavailable, using fallback"), rather than either blocking indefinitely or silently passing through unenriched data as if it were fully enriched.
Monitoring thresholds. Circuit state transitions (open/half-open/closed), cache hit rate (a dropping hit rate alongside rising API call volume is an early signal of a cache-effectiveness regression before it becomes a rate-limit incident), queue depth, and fallback-serving rate (how much of current traffic is running on stale/default data, a direct measure of current degradation severity) are the specific signals worth alerting on for this design.
Worked example
At 3,000 enrichment requests/sec with a natural key-repetition rate meaning only 40% of lookups are for genuinely distinct entities within a 10-minute cache TTL window, local caching alone eliminates:
3,000×(1−0.40)=1,800 requests/sec served from cache, never reaching the third-party APIleaving 1,200 requests/sec of genuine API-bound traffic. If the API supports batching at up to 50 entities per call, those 1,200 requests/sec can be grouped into:
501,200=24 batch calls/sec, versus 1,200 individual calls/seca 50x reduction in call count against whatever the API's own rate limit is measured in (call count, not entity count, in many third-party API rate-limit designs), which is often the difference between comfortably staying under a rate limit and triggering it, entirely from caching and batching, BEFORE the circuit breaker and concurrency limits even need to activate under normal conditions.
Trade-offs and pitfalls
- Common mistake: treating caching as a correctness risk to avoid rather than a load-reduction tool to embrace. For most enrichment use cases (a product category lookup, a user-tier flag), a bounded-staleness cache (a sensible TTL) is a clear net win; the risk is usually smaller than the load-reduction benefit, especially at the throughput this question specifies.
- Common mistake: batching without handling partial-batch failure. A batch call where 45 of 50 entities succeed and 5 fail needs per-entity result handling, not an all-or-nothing treatment of the whole batch as failed (which would unnecessarily retry the 45 that actually succeeded) or all-or-nothing success (which would silently accept wrong/missing data for the 5 that failed).
- Fallback data must be distinguishable from real data downstream, or a consumer cannot tell degraded output from normal output, silently propagating a quality issue further downstream than necessary.
- The concurrency limit and the circuit breaker solve different problems and both are needed. A concurrency limit alone still lets every one of those limited-concurrency requests actually attempt (and wait to fail against) a known-down dependency; the circuit breaker is what stops attempting entirely once failure is sustained, and skipping it means the concurrency limit alone still wastes the full timeout duration on every doomed request.
Unlock Full Question Bank
Get access to all Data Reliability and Fault Tolerance interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.