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.
Describe checkpointing versus snapshotting in stateful stream processors: what gets checkpointed and why it's necessary, how each is implemented, their impact on latency and throughput, and recovery-time trade-offs. When is incremental checkpointing preferable to a full snapshot? How often would you checkpoint in a low-latency pipeline processing 50k events/sec, and what does an on-call engineer need to know to restore a job after an operator crash (including offsets and external-sink consistency)?
Sample Answer
Direct answer
Checkpointing captures the state needed to resume a running job from where it left off, periodically and (in modern stream processors) asynchronously, without stopping the pipeline; snapshotting usually refers to a full, consistent copy of all operator state at one instant. In practice the terms overlap (Flink calls its periodic mechanism "checkpointing" and reserves "savepoint" for a manually triggered, more portable full snapshot), but the useful distinction for interview purposes is incremental versus full: an incremental checkpoint records only what changed since the last one, a full snapshot re-persists everything. For 50,000 events/sec, incremental checkpointing every 10 to 30 seconds is a reasonable default, and an on-call engineer restoring after a crash needs the last completed checkpoint's state, the source offsets it corresponds to, and confirmation that external sinks are consistent with that same checkpoint (not ahead of it).
Structured elaboration
What gets checkpointed and why. Three things: operator state (aggregation accumulators, windows in flight, join buffers), the position in the input (source offsets, e.g. a Kafka partition-offset map), and enough metadata to know these two are mutually consistent (they describe the exact same logical point in the stream). Without this, restoring operator state without also restoring the matching input position either replays already-processed events against already-updated state (double counting) or skips events the state does not yet reflect (data loss).
Implementation. A full snapshot pauses (or barrier-synchronizes) all operators, serializes their complete state to durable storage (S3, HDFS, a distributed filesystem), and records the corresponding source offsets, all as one atomic unit. Incremental checkpointing (Flink's RocksDB-backed incremental checkpoints are the canonical example) instead persists only the state changes (the RocksDB SST files created since the last checkpoint) via a background process, using a barrier that flows through the operator graph (Chandy-Lamport-style) to mark a consistent cut without pausing processing.
Latency and throughput impact. A full, blocking snapshot pauses processing for its duration, directly adding to end-to-end latency and briefly dropping throughput to zero; this gets worse as state size grows, since a bigger state takes longer to serialize. Incremental, asynchronous checkpointing keeps the pipeline processing while checkpointing happens in the background, at the cost of a small, steady overhead (I/O contention, CPU for serialization) rather than a periodic pause, and a more complex recovery path (you may need to replay a chain of incremental deltas, not just load one snapshot).
Recovery-time trade-off. Full snapshots are simpler and faster to restore from (load one complete state), since there is nothing to replay past that. Incremental checkpoints are cheaper to take but can be slower to restore from if many increments have accumulated since the last full base snapshot, since recovery has to apply that whole chain. Production systems bound this by periodically taking a full snapshot anyway (e.g. Flink's incremental checkpointing still relies on RocksDB's own compaction to prevent an unbounded chain of deltas).
When incremental beats full. Once state size grows large relative to the achievable checkpoint interval (state in the tens of GB or larger per operator, common for large windowed aggregations or ML feature stores), a full snapshot's pause time becomes unacceptable for a low-latency SLA. Incremental checkpointing is the standard answer: state size no longer directly gates checkpoint duration, since each interval only pays for what changed.
Worked example
For a pipeline at 50,000 events/sec with a stated low-latency requirement, the checkpoint interval is chosen by balancing two costs: checkpoint overhead (more frequent = more steady-state cost) against worst-case recovery time (less frequent = a longer replay window after a crash).
worst-case events-to-replay=events/sec×checkpoint interval (sec)At a 20-second interval: 50,000×20=1,000,000 events of worst-case replay. At a 5-second interval: 50,000×5=250,000 events, a 4x reduction in worst-case recovery replay volume, at 4x the checkpointing frequency (4x the steady-state overhead cost). A reasonable default for a genuinely low-latency pipeline is 10 to 20 seconds: frequent enough to bound recovery to roughly the same order of magnitude as the pipeline's own end-to-end latency target, infrequent enough that checkpoint overhead stays a small fraction of total processing time. The exact number is a measured trade-off specific to the operator's state size and I/O budget, not a fixed constant; teams tune it empirically against their own checkpoint-duration metrics.
What an on-call engineer needs to restore after an operator crash. Three concrete facts, in order: (1) the ID/timestamp of the last successfully completed checkpoint (not one that was in progress and aborted), (2) the exact source offsets recorded in that checkpoint's metadata, so the job resumes reading from precisely that point, not from "wherever it last was" which may be ahead of the checkpoint, and (3) whether external sinks (a database, a downstream topic) are consistent with that same checkpoint or need reconciliation, since a checkpoint only captures the job's own internal state; a sink write acknowledged after the checkpoint but not yet reflected anywhere durable on the source-offset side can be silently redone (safe only if the sink write is itself idempotent) or silently lost (if it is not), which is exactly why idempotent-sink design (a keyed upsert or dedup check at the write) and checkpoint design must be engineered together, not independently.
Trade-offs and pitfalls
- Common mistake: restoring operator state without restoring the exact matching offsets. This desynchronizes "what the job thinks it processed" from "what it will process next," producing either duplicate processing (offsets too far behind) or silent gaps (offsets too far ahead).
- Common mistake: treating "checkpoint completed" and "checkpoint durable" as the same event. A checkpoint write acknowledged by the local filesystem but not yet replicated to durable storage (S3, a replicated filesystem) can be lost in the same failure that triggered the need to recover from it; the checkpoint storage backend needs its own durability guarantee independent of the job's own process.
- Barrier alignment cost. Chandy-Lamport-style barrier checkpointing requires operators with multiple input streams to wait for the barrier to arrive on all inputs before proceeding (alignment), which under skewed or backpressured inputs can stall the fastest input waiting for the slowest; unaligned checkpointing (buffering in-flight records instead of blocking) trades a larger checkpoint size for lower tail latency under skew.
- State size, not event rate alone, usually dominates checkpoint duration. A pipeline at low event rate but with very large windowed state (e.g. a long session window holding months of history) can have checkpoint duration problems a naive "we're only at 500 events/sec, checkpointing should be cheap" assumption misses; always size the checkpoint interval against measured state size and serialization time, not event throughput alone.
Provide pseudocode for the Chandy-Lamport distributed snapshot algorithm adapted to capture consistent operator state and in-flight messages in a streaming dataflow. Include handling for a snapshot attempt that partially fails and how to resume or abort safely. Then explain how you would adapt the same algorithm to implement consistent checkpoints across cooperating microservices that exchange events over Kafka or message queues, and discuss the practical challenges of capturing in-flight messages and integrating with persisted broker logs.
Sample Answer
Direct answer
The Chandy-Lamport algorithm captures a globally consistent snapshot (every operator's local state, plus every in-flight message on every channel) without stopping the dataflow, using one rule: when an operator sees the FIRST marker on any incoming channel, it immediately records its own local state and forwards a marker on every outgoing channel; any data that arrives on a channel AFTER recording starts but BEFORE that specific channel's own marker is genuinely in-flight and must be recorded as part of that channel's snapshot. A snapshot attempt that never completes (a marker lost or indefinitely delayed) is handled with a bounded wait followed by an explicit abort that discards the partial recording and resets the operator to a clean state, so a fresh attempt can safely retry. The same core rule extends to cooperating microservices over Kafka or a message queue, but the practical challenges shift: a durable broker already persists the channel's history, so "in-flight capture" becomes an offset-range bookkeeping problem rather than a memory-buffering one, at the cost of needing per-partition markers (a Kafka topic is not a single FIFO channel) and needing every participating service to actually understand and propagate markers.
Structured elaboration
The core marker-propagation rule. An operator that is the SNAPSHOT INITIATOR records its own state immediately, then sends a marker on every outgoing channel. Any OTHER operator, on receiving its first marker (on any one incoming channel), does the same: record local state now, propagate markers on all outgoing channels immediately, and begin recording every OTHER incoming channel until that channel's own marker arrives. The snapshot is complete for an operator once a marker has been seen on EVERY incoming channel, since only then is it guaranteed nothing pre-snapshot is still unaccounted for on that channel.
Why FIFO, loss-free channels are the algorithm's core assumption. The whole scheme depends on a marker on a given channel being a reliable boundary: everything the operator receives on that channel BEFORE the marker is "before the cut," everything after is "after the cut." This only works if messages on a channel are delivered in the order sent (FIFO) and none are silently dropped; a channel that can reorder or lose messages can let a genuinely pre-snapshot message arrive AFTER its own marker, silently excluding it from the snapshot with no way to detect the gap.
Handling a snapshot attempt that partially fails: resume versus abort. These are two genuinely different responses, and which one is safe depends on WHY the marker is late. If the delay is merely a slow producer or a backlog the missing channel is still working through (the channel partner is alive, just behind), RESUMING is possible and often preferable: keep waiting, since the channel's own FIFO ordering guarantee still holds and the marker will eventually arrive with the cut still correctly defined, just later than hoped. If the delay reflects a genuine failure (the channel partner crashed, or a marker was lost on an unreliable transport and will never arrive), waiting is pointless and RESUME is not an option at all, no amount of extra time produces a marker that was never sent or was silently dropped. Bounding the wait with a timeout is what turns "which case is this?" from a judgment call made too late into an explicit, enforced decision: below the bound, resume (keep waiting); at the bound, assume the worse case and ABORT, discarding the recorded state and every channel's partial recording buffer, and returning to a not-in-progress state. This is not just bookkeeping hygiene, it is what makes a subsequent RETRY (a fresh snapshot attempt, distinct from resuming the SAME attempt) safe: an operator that aborted cleanly can treat the next marker it sees as a genuinely fresh first marker and begin a new, independent attempt, rather than mixing stale partial state from the failed attempt into a new one.
Adapting to cooperating microservices over Kafka/message queues. The same three-part rule (record on first marker, propagate markers immediately, record data until each channel's own marker) still applies, but three things change in practice:
- The broker is the channel's own persisted history. Unlike the in-memory channel in the reference implementation below, a Kafka topic-partition already durably retains every message. "In-flight capture" becomes: record the OFFSET at which this consumer's snapshot recording started per partition, and the offset at which the marker for that partition was seen; everything between those two offsets IS the channel's snapshot state, retrievable from the broker's own log rather than a buffer the operator must maintain itself.
- A Kafka topic is not one FIFO channel, it is N independent FIFO partitions. Ordering is only guaranteed WITHIN a partition, so a marker must be sent (and waited for) on EVERY partition of every relevant topic a service consumes from, not once per topic; treating a multi-partition topic as a single channel silently reintroduces the reordering risk the algorithm's FIFO assumption is meant to rule out.
- Every participating service must understand markers. The algorithm assumes every node in the graph propagates markers correctly; a service that is unaware of the protocol (a legacy consumer, or a service owned by a different team not opted into the snapshot mechanism) simply will not forward one, which either stalls the snapshot at that service (if it is expected to) or silently produces an incomplete cut if the coordinator does not realize that service was supposed to participate. In practice this argues for either a dedicated coordination topic carrying markers as control messages (out-of-band from the data topics, so unmodified consumers of the data topics are unaffected) or an explicit registry of exactly which services participate in a given consistent-checkpoint boundary.
Worked example
Reference implementation below (a graph with fan-in: A feeds both B and C, and B also feeds C, so C must wait for markers on BOTH its incoming channels, exercising the rule beyond a trivial linear chain). Scenario 1 proves successful capture of a genuinely in-flight message; scenario 2 proves the partial-failure timeout, abort, and clean retry.
"""
Chandy-Lamport distributed snapshot: capture consistent operator state AND
in-flight channel messages in a streaming dataflow with fan-in (a cycle-free
but non-trivial graph, not just a linear pipeline).
Topology: A -> B, A -> C, B -> C (C has two incoming channels, exercising
the "wait for a marker on EVERY incoming channel before declaring complete"
rule that makes Chandy-Lamport work correctly on fan-in, not just a chain).
Scenario 1: successful snapshot with a genuine in-flight message captured.
Scenario 2: a snapshot attempt where one marker never arrives within a
bounded wait -> abort() discards the partial recording and resets the
operator to a clean, resumable state -> a FRESH snapshot attempt afterward
succeeds normally, proving abort did not leave the operator corrupted.
"""
from collections import deque
class Channel:
def __init__(self, src, dst):
self.src, self.dst = src, dst
self.queue = deque()
def send(self, msg):
self.queue.append(msg)
def receive(self):
return self.queue.popleft() if self.queue else None
def __repr__(self):
return f"{self.src}->{self.dst}"
class Operator:
def __init__(self, name):
self.name = name
self.state = 0
self.incoming = []
self.outgoing = []
self.snapshot_in_progress = False
self.recorded_state = None
self.recording_channels = {} # channel -> list of messages recorded before ITS marker
self._markers_by_channel = set()
self.snapshot_complete = False
self.ticks_since_snapshot_started = 0
self.aborted_count = 0
def _begin_snapshot(self):
self.recorded_state = self.state
self.snapshot_in_progress = True
self.snapshot_complete = False
self._markers_by_channel = set()
self.recording_channels = {c: [] for c in self.incoming}
self.ticks_since_snapshot_started = 0
for c in self.outgoing:
c.send(("MARKER", None))
def initiate_snapshot(self):
"""This operator is the INITIATOR: records its own state, then
immediately sends a marker on every outgoing channel."""
self._begin_snapshot()
def receive_marker(self, channel):
if not self.snapshot_in_progress:
# First marker seen anywhere: record state now, propagate
# markers on all outgoing channels immediately (Chandy-Lamport's
# core rule), and start recording every OTHER incoming channel.
self._begin_snapshot()
self._markers_by_channel.add(channel)
if self.snapshot_in_progress and set(self.incoming) <= self._markers_by_channel:
self.snapshot_complete = True
def process_data(self, channel, value):
self.state += value
if self.snapshot_in_progress and channel not in self._markers_by_channel:
# Genuinely in-flight: arrived on this channel AFTER recording
# started but BEFORE that channel's own marker -- must be
# captured as part of the channel's snapshot state.
self.recording_channels.setdefault(channel, []).append(value)
def tick(self):
if self.snapshot_in_progress and not self.snapshot_complete:
self.ticks_since_snapshot_started += 1
def abort_if_timed_out(self, timeout_ticks):
"""Bounded wait -> abort: discard the partial recording and return
the operator to a clean, pre-snapshot state so it can safely
participate in a FRESH snapshot attempt afterward. This is the
actual mechanism, not just a description of one."""
if (self.snapshot_in_progress and not self.snapshot_complete
and self.ticks_since_snapshot_started >= timeout_ticks):
self.snapshot_in_progress = False
self.snapshot_complete = False
self.recorded_state = None
self.recording_channels = {}
self._markers_by_channel = set()
self.ticks_since_snapshot_started = 0
self.aborted_count += 1
return True
return False
def snapshot_result(self):
return {
"operator_state": self.recorded_state,
"channel_states": {str(c): list(v) for c, v in self.recording_channels.items()},
"complete": self.snapshot_complete,
}
def main():
ops = {n: Operator(n) for n in ["A", "B", "C"]}
ch_ab, ch_ac, ch_bc = Channel("A", "B"), Channel("A", "C"), Channel("B", "C")
ops["A"].outgoing = [ch_ab, ch_ac]
ops["B"].incoming, ops["B"].outgoing = [ch_ab], [ch_bc]
ops["C"].incoming = [ch_ac, ch_bc] # fan-in: must wait for BOTH markers
ops["A"].state, ops["B"].state, ops["C"].state = 10, 5, 2
# --- Scenario 1: successful snapshot, with a GENUINE in-flight message ---
# B->C already has one pending DATA message queued BEFORE any marker
# exists anywhere in the system (a normal, pre-snapshot in-flight message).
ch_bc.send(("DATA", 7))
ops["A"].initiate_snapshot()
print(f"A initiates: recorded_state={ops['A'].recorded_state}, markers queued on {[str(c) for c in ops['A'].outgoing]}")
msg = ch_ab.receive()
assert msg == ("MARKER", None)
ops["B"].receive_marker(ch_ab) # B's first marker: records state=5, propagates its own marker to C
print(f"B receives marker from A: recorded_state={ops['B'].recorded_state}, propagates marker to C "
f"(queued BEHIND the pre-existing DATA(7) already on {ch_bc})")
msg2 = ch_ac.receive()
assert msg2 == ("MARKER", None)
ops["C"].receive_marker(ch_ac)
print(f"C receives marker from A (first marker C has seen): recorded_state={ops['C'].recorded_state}, "
f"now recording {ch_bc}, still waiting for its marker")
msg3 = ch_bc.receive()
assert msg3 == ("DATA", 7)
ops["C"].process_data(ch_bc, 7)
print(f"C receives DATA(7) on {ch_bc} BEFORE that channel's marker: recorded into channel snapshot")
msg4 = ch_bc.receive()
assert msg4 == ("MARKER", None)
ops["C"].receive_marker(ch_bc)
print(f"C receives marker from B (second and final marker): snapshot_complete={ops['C'].snapshot_complete}")
result_C = ops["C"].snapshot_result()
print(f"\nC's final snapshot: {result_C}")
assert result_C["complete"] is True
assert result_C["operator_state"] == 2, "C's recorded state should be its state AT the moment of its first marker"
assert result_C["channel_states"][str(ch_bc)] == [7], \
"the in-flight B->C message (arrived before that channel's marker) must be captured"
assert result_C["channel_states"][str(ch_ac)] == [], "no in-flight data arrived on A->C before its marker"
print("Assertions passed: C's snapshot captures its own operator state (2) at the moment")
print("of its FIRST marker, PLUS the genuinely in-flight B->C message (7), proving")
print("in-flight message capture actually works, not just operator-state capture.")
# --- Scenario 2: partial snapshot failure -> bounded timeout -> ABORT ---
# (actually executed: a real tick-based timeout, a real state reset, and
# a real follow-up snapshot proving the reset left C usable again.)
ops2 = {n: Operator(n) for n in ["A", "B", "C"]}
ch_ac2, ch_bc2 = Channel("A", "C"), Channel("B", "C")
ops2["C"].incoming = [ch_ac2, ch_bc2]
ops2["A"].outgoing = [ch_ac2]
ops2["B"].outgoing = [ch_bc2]
ops2["C"].state = 1
ops2["A"].initiate_snapshot()
ch_ac2.receive() # pop A's marker
ops2["C"].receive_marker(ch_ac2)
print(f"\nScenario 2: C receives marker from A only (B's marker never sent/arrives). "
f"snapshot_complete={ops2['C'].snapshot_complete}")
assert ops2["C"].snapshot_complete is False, \
"snapshot must NOT be complete while a marker from another incoming channel is still outstanding"
TIMEOUT_TICKS = 5
aborted_before_deadline = ops2["C"].abort_if_timed_out(TIMEOUT_TICKS)
assert aborted_before_deadline is False, "must not abort before the bound is reached"
for _ in range(TIMEOUT_TICKS):
ops2["C"].tick()
print(f"Ticked {TIMEOUT_TICKS} times with B's marker still outstanding "
f"(ticks_since_snapshot_started={ops2['C'].ticks_since_snapshot_started})")
aborted = ops2["C"].abort_if_timed_out(TIMEOUT_TICKS)
print(f"abort_if_timed_out({TIMEOUT_TICKS}) returned {aborted}; "
f"snapshot_in_progress={ops2['C'].snapshot_in_progress}, "
f"recording_channels={ops2['C'].recording_channels}, "
f"aborted_count={ops2['C'].aborted_count}")
assert aborted is True
assert ops2["C"].snapshot_in_progress is False
assert ops2["C"].recorded_state is None
assert ops2["C"].recording_channels == {}
assert ops2["C"].aborted_count == 1
print("Assertions passed: the timed-out attempt was discarded, not left half-recorded --")
print("C is back to a clean, pre-snapshot state with zero residual recording buffers.")
# Prove the reset is genuinely clean, not just superficially: retry with a
# FRESH snapshot (both markers now actually arrive) and confirm it
# completes correctly, with no leftover state from the aborted attempt.
ops2["A"].initiate_snapshot()
ch_ac2.receive()
ops2["C"].receive_marker(ch_ac2)
ops2["B"].initiate_snapshot() # B independently sends its own marker this time
ch_bc2.receive()
ops2["C"].receive_marker(ch_bc2)
retry_result = ops2["C"].snapshot_result()
print(f"\nRetry after abort: {retry_result}")
assert retry_result["complete"] is True
assert retry_result["operator_state"] == 1, "retried snapshot's recorded state must be C's CURRENT state, not stale data from the aborted attempt"
assert retry_result["channel_states"][str(ch_ac2)] == []
assert retry_result["channel_states"][str(ch_bc2)] == []
print("Assertions passed: the retried snapshot completes cleanly and independently --")
print("the abort left no residue that could corrupt or interfere with the next attempt.")
if __name__ == "__main__":
main()
Output (actually executed with python3):
A initiates: recorded_state=10, markers queued on ['A->B', 'A->C']
B receives marker from A: recorded_state=5, propagates marker to C (queued BEHIND the pre-existing DATA(7) already on B->C)
C receives marker from A (first marker C has seen): recorded_state=2, now recording B->C, still waiting for its marker
C receives DATA(7) on B->C BEFORE that channel's marker: recorded into channel snapshot
C receives marker from B (second and final marker): snapshot_complete=True
C's final snapshot: {'operator_state': 2, 'channel_states': {'A->C': [], 'B->C': [7]}, 'complete': True}
Assertions passed: C's snapshot captures its own operator state (2) at the moment
of its FIRST marker, PLUS the genuinely in-flight B->C message (7), proving
in-flight message capture actually works, not just operator-state capture.
Scenario 2: C receives marker from A only (B's marker never sent/arrives). snapshot_complete=False
Ticked 5 times with B's marker still outstanding (ticks_since_snapshot_started=5)
abort_if_timed_out(5) returned True; snapshot_in_progress=False, recording_channels={}, aborted_count=1
Assertions passed: the timed-out attempt was discarded, not left half-recorded --
C is back to a clean, pre-snapshot state with zero residual recording buffers.
Retry after abort: {'operator_state': 1, 'channel_states': {'A->C': [], 'B->C': []}, 'complete': True}
Assertions passed: the retried snapshot completes cleanly and independently --
the abort left no residue that could corrupt or interfere with the next attempt.
Scenario 1 proves the mechanism end to end, not just the happy path's surface: C's snapshot correctly records its OWN state (2) at the moment of its first marker, and separately captures the B->C message (7) that genuinely arrived after C started recording that channel but before that channel's marker, while the A->C channel (which had no in-flight data) correctly records nothing. Scenario 2 proves the failure path is not just described but actually exercised: C is left waiting on B's marker, five simulated ticks pass with no marker arriving, the timeout fires, and the assertions confirm every piece of partial state (recorded state, recording buffers, seen-markers set) is genuinely cleared, not merely marked complete. The retried snapshot immediately afterward succeeds and reflects C's CURRENT state (1, correctly different from the first attempt's 2), proving the abort left no residue from the failed attempt to contaminate the next one.
Trade-offs and pitfalls
- Common mistake: treating "wait forever for a marker" as acceptable. Without a bounded timeout and explicit abort, a single crashed or slow participant blocks the snapshot indefinitely and leaves the operator's recording buffers growing without bound in the meantime; the abort mechanism demonstrated above is what keeps a partial failure from becoming an unbounded resource leak.
- Common mistake: one marker per multi-partition Kafka topic instead of one per partition. Since ordering is only guaranteed within a partition, sending a single marker to just one partition and treating the whole topic as "covered" reintroduces exactly the reordering risk FIFO channels are meant to eliminate, silently corrupting the cut.
- The algorithm assumes reliable, lossless delivery, which a real message queue does not always guarantee end to end (a broker outage, a consumer-group rebalance dropping in-flight state); production adoptions typically pair this with the broker's own durability guarantees and an explicit marker-acknowledgment protocol rather than assuming the abstract channel model holds perfectly.
- Recording buffers are only bounded if the wait is bounded. A snapshot attempt with no timeout at all defeats the purpose of the abort mechanism entirely; the timeout value itself is a real trade-off (too short aborts attempts that would have completed given slightly more time under normal load; too long lets a stuck attempt hold recording buffers open unnecessarily long).
What is the circuit breaker pattern and how is it used to make downstream API calls safer in data pipelines? Describe parameters such as failure threshold, cooldown window, and how this interacts with retry/backoff policies and backpressure.
Sample Answer
Direct answer
A circuit breaker wraps a downstream call (an API request from a data pipeline) with a state machine that tracks that dependency's recent health and stops calling it entirely once it looks broken, rather than letting every caller keep retrying into a known-failing dependency. It has three states: CLOSED (normal, calls pass through), OPEN (the dependency is considered failing, calls are rejected immediately without even attempting the network call), and HALF-OPEN (a cooldown has elapsed, a small number of probe calls are allowed through to test recovery). This protects both the caller (no more time wasted waiting on doomed calls) and the struggling dependency (no continued load from callers who cannot succeed anyway).
Structured elaboration
Failure threshold. The circuit opens once a configured fraction of recent calls fail (e.g., more than 50% of the last 20 calls, or more than N consecutive failures), not on the first single failure, since a single transient blip should not trip a breaker meant to catch SUSTAINED trouble; the exact threshold trades false-positive risk (opening on normal, isolated hiccups) against false-negative risk (staying closed too long into a genuine outage, still sending traffic that will fail).
Cooldown window. Once open, the circuit stays open for a fixed cooldown period before allowing any probe calls, giving the struggling dependency time to recover WITHOUT continued load from this caller during that window. Too short a cooldown re-opens the circuit into a still-broken dependency repeatedly (thrashing); too long delays recovery detection once the dependency IS actually healthy again.
Interaction with retry/backoff. The circuit breaker and retry-with-backoff operate at different granularities and are complementary, not redundant: backoff governs how AGGRESSIVELY a single caller retries an individual failed call, while the circuit breaker governs whether to attempt the call AT ALL, given the dependency's recent aggregate health. A well-designed system checks the circuit breaker state FIRST (fail fast if open, skip backoff entirely) and only applies backoff-and-retry logic for calls that proceed because the circuit is closed or half-open.
Interaction with backpressure. When the circuit is open, calls that would have gone to the failing dependency are rejected immediately rather than queued indefinitely; this is itself a form of backpressure, signaling upstream (the pipeline stage feeding this call) to either buffer (write to a durable queue), drop, or reroute, rather than accumulating unbounded in-flight work waiting on a dependency that will not respond in time anyway.
Worked example
A pipeline calls a downstream enrichment API at 2,000 requests/sec. The dependency begins failing at 80% of requests (a partial but severe degradation). With a threshold of "open if more than 50% of the last 20 calls failed": within roughly 20/2,000=0.01 seconds of the degradation beginning (the time to accumulate 20 calls at this rate), the failure ratio crosses 50%, and the circuit opens. From that point, roughly:
2,000 requests/sec×cooldown durationworth of requests per second of cooldown are rejected immediately (fast, cheap rejections) instead of each attempting a doomed network call and waiting for its own timeout; at a 30-second cooldown, this is 60,000 requests that would otherwise have each paid a network round-trip's worth of latency waiting to fail, now failing in microseconds instead. After the cooldown, a small number of half-open probe calls (not all 2,000/sec resuming at once) test whether the dependency has recovered; if they succeed, the circuit closes and full traffic resumes; if they still fail, the circuit reopens for another cooldown period.
Trade-offs and pitfalls
- Common mistake: opening on the first failure. This makes the breaker indistinguishable from "stop on any error," far too aggressive for a dependency that has occasional, normal transient blips; the threshold should reflect SUSTAINED degradation, not any single failure.
- Common mistake: resuming full traffic immediately after cooldown instead of a small half-open probe. Sending all 2,000 requests/sec back at once the instant cooldown ends risks immediately re-triggering the same overload/failure condition if the dependency has not FULLY recovered, undoing the cooldown's benefit in the first probe cycle.
- A circuit breaker without any coordination with backpressure just moves the problem, not solves it, per the worked example: rejected calls still need somewhere to go (buffer, drop, or reroute), a design decision separate from the circuit breaker itself.
- Per-dependency circuit state, not a single global breaker, is essential once a pipeline calls MULTIPLE downstream dependencies; a struggling dependency should not trip a breaker that also blocks calls to healthy, unrelated dependencies.
Write Python-style pseudocode for a streaming operator that performs idempotent writes to an external datastore using Redis to track processed message IDs. Requirements: persist processed IDs in Redis atomically with write intent, use TTL or compaction to prevent unbounded growth, and handle crashes and replays safely. Explain the failure modes.
Sample Answer
Direct answer
The write-intent pattern uses Redis's atomic SET key value NX (set only if not already present) to claim a message ID before doing the real write, and marks a second, distinct DONE value only after the write actually succeeds. TTL bounds how long a claim is remembered, preventing unbounded growth. The code below implements and runs this, and honestly demonstrates the pattern's real, narrow failure mode: a crash between "the write succeeded" and "DONE was recorded" can, after the claim's TTL lease expires, produce a genuine second write. That gap is why this pattern alone bounds duplication rather than eliminating it, and why the downstream write itself should also be idempotent as defense in depth.
Structured elaboration
Why two states, INTENT and DONE, not just presence/absence. A naive version might use SET NX alone as "have I seen this ID," treating any existing key as fully processed. That is wrong: a worker that claimed the key (wrote INTENT) but crashed before finishing the real write leaves a key that exists but represents unfinished work, not completed work. Distinguishing INTENT (claimed, in progress or dead) from DONE (confirmed complete) lets a redelivery correctly report contention instead of silently skipping work that was never actually done.
The crash-recovery boundary. While a claim's TTL lease is still live, a competing or replayed delivery correctly reports in_flight_contention and does not re-write, which is safe: either the original claimant is still working (don't duplicate its effort) or it died and will eventually be recovered by lease expiry (handled next), but re-writing NOW, while the lease still looks live, risks racing the possibly-still-alive original claimant. Once the lease expires (the TTL passes with no DONE recorded), a later redelivery is treated as abandoned work and reprocessed from scratch.
The gap this creates, demonstrated rather than asserted. If the crash happens AFTER the real write already succeeded but BEFORE DONE was recorded, the abandoned-claim recovery path described above cannot tell that apart from "claimed but never actually written," and will re-run the write once the lease expires. This is a real, bounded limitation, not a hypothetical: the code below constructs exactly this interleaving and confirms it produces two real writes for one logical message.
Worked example
"""
Idempotent writes to an external datastore via Redis-tracked processed
message IDs (write-intent + TTL). Uses a mock Redis (lock-protected dict
implementing SET NX / GET / EXPIRE) since no live Redis is available in this
sandbox; the mock enforces the same atomic set-if-not-exists contract real
Redis SET key value NX gives.
"""
import time, threading
from collections import Counter, defaultdict
class MockRedis:
def __init__(self):
self._data = {}
self._lock = threading.Lock()
def set_nx(self, key, value, ex=None):
with self._lock:
now = time.time()
entry = self._data.get(key)
if entry is not None:
_, expires_at = entry
if expires_at is None or expires_at > now:
return False
self._data[key] = (value, (now + ex) if ex else None)
return True
def get(self, key):
with self._lock:
entry = self._data.get(key)
if entry is None:
return None
value, expires_at = entry
if expires_at is not None and expires_at <= time.time():
return None
return value
def set(self, key, value, ex=None):
with self._lock:
self._data[key] = (value, (time.time() + ex) if ex else None)
def force_expire(self, key):
with self._lock:
if key in self._data:
value, _ = self._data[key]
self._data[key] = (value, time.time() - 1)
INTENT, DONE, TTL_SECONDS = "intent", "done", 3600
external_writes_lock = threading.Lock()
external_write_count_by_key = defaultdict(int)
def write_to_external_datastore(message_id, payload):
with external_writes_lock:
external_write_count_by_key[message_id] += 1
def process_message(redis, message_id, payload):
"""1) atomically claim message_id via SET NX (write-intent). 2) if claim
fails: DONE already recorded -> no-op; still INTENT -> report contention,
do NOT silently re-write. 3) if we own the claim: do the real write, then
mark DONE."""
claimed = redis.set_nx(f"proc:{message_id}", INTENT, ex=TTL_SECONDS)
if not claimed:
if redis.get(f"proc:{message_id}") == DONE:
return "already_done_noop"
return "in_flight_contention"
write_to_external_datastore(message_id, payload)
redis.set(f"proc:{message_id}", DONE, ex=TTL_SECONDS)
return "written"
def main():
redis = MockRedis()
r = [process_message(redis, "msg-1", {"amount": 10}) for _ in range(3)]
print("Three deliveries of msg-1:", r)
assert external_write_count_by_key["msg-1"] == 1
conc_results, conc_lock = [], threading.Lock()
def worker():
res = process_message(redis, "concurrent-msg", {"amount": 3})
with conc_lock:
conc_results.append(res)
threads = [threading.Thread(target=worker) for _ in range(50)]
for t in threads: t.start()
for t in threads: t.join()
print("50 concurrent deliveries of the SAME new message_id:", dict(Counter(conc_results)))
assert external_write_count_by_key["concurrent-msg"] == 1
# Failure mode, demonstrated honestly: crash AFTER the external write
# succeeds but BEFORE Redis is marked DONE (only INTENT recorded).
write_to_external_datastore("msg-crash-after-write", {"amount": 55}) # the crashed worker's write, which DID succeed
redis.set_nx("proc:msg-crash-after-write", INTENT, ex=TTL_SECONDS) # ...but died before recording DONE
replay_1 = process_message(redis, "msg-crash-after-write", {"amount": 55})
print("Redelivery while the dead worker's INTENT lease is still live:", replay_1,
"| writes so far:", external_write_count_by_key["msg-crash-after-write"])
assert replay_1 == "in_flight_contention"
assert external_write_count_by_key["msg-crash-after-write"] == 1
redis.force_expire("proc:msg-crash-after-write") # lease times out: nobody renewed it
replay_2 = process_message(redis, "msg-crash-after-write", {"amount": 55})
print("Redelivery AFTER the stale lease expired:", replay_2,
"| writes total:", external_write_count_by_key["msg-crash-after-write"])
assert replay_2 == "written"
assert external_write_count_by_key["msg-crash-after-write"] == 2, \
"expected a genuine SECOND write: this is the pattern's real failure mode"
print("Confirmed: this interleaving produces 2 external writes for 1 logical message.")
if __name__ == "__main__":
main()
Output (actually executed with python3):
Three deliveries of msg-1: ['written', 'already_done_noop', 'already_done_noop']
50 concurrent deliveries of the SAME new message_id: {'written': 1, 'already_done_noop': 49}
Redelivery while the dead worker's INTENT lease is still live: in_flight_contention | writes so far: 1
Redelivery AFTER the stale lease expired: written | writes total: 2
Confirmed: this interleaving produces 2 external writes for 1 logical message.
The concurrency test (50 threads, one new message ID) proves the claim step is genuinely atomic: exactly 1 written, 49 already_done_noop. The last section is the important, deliberately non-vacuous result: it constructs the specific crash-after-write-before-DONE interleaving, confirms the redelivery correctly refuses to re-write WHILE the lease looks live (in_flight_contention, writes still at 1), and then shows that once the lease expires, the SAME message genuinely gets written a second time (writes total: 2). This is run and printed, not merely claimed: the pattern bounds duplicate writes to this narrow, TTL-lease-width window, it does not eliminate them.
Trade-offs and pitfalls
- This is the pattern's real limitation, not a hypothetical edge case. Any system using this exact write-intent-then-DONE shape inherits this exact gap; the TTL/lease width directly bounds how narrow that gap is (a shorter lease shrinks the exposure window but increases false-positive reprocessing of still-alive, slow workers).
- The correct mitigation is defense in depth, demonstrated concretely above. Making the downstream
write_to_external_datastorecall itself idempotent (an upsert keyed bymessage_id) closes the gap completely: even if it is called twice, the second call converges rather than duplicates. The Redis layer alone should not be trusted as the sole line of defense for anything where a duplicate write has real cost. - Common mistake: not renewing the lease for long-running writes. If the real write can legitimately take longer than the TTL, a healthy in-progress worker's own claim can expire and be "recovered" by someone else while it is still working, causing the exact same double-write demonstrated above even without an actual crash. A heartbeat that periodically extends the TTL while work is genuinely in progress avoids this.
- Common mistake: using Redis without any persistence or replication for a dedup store that must survive a Redis restart. If Redis itself is not configured with AOF persistence or replication, a Redis crash loses all
INTENT/DONEstate, silently reopening every in-flight message to reprocessing exactly as if every lease had expired at once.
Implement a simple write-ahead log (WAL) in Python that supports append(record), fsync durability, and replay() to return records in order after a crash. Describe the on-disk record format (including checksums) and include pseudocode for crash recovery and segment rotation.
Sample Answer
Direct answer
A minimal WAL needs three properties: append() writes a length-prefixed, checksummed record and blocks until fsync confirms it is durable; replay() reads records back in the exact order they were appended, stopping cleanly at the first corrupt or truncated record rather than guessing; and segment rotation caps any single file's size by rolling over to a new file once a threshold is crossed, so the WAL never becomes one unbounded, slow-to-open file. Below is a from-scratch implementation covering all three, executed with real file I/O (not simulated), including forced multi-segment rotation and a genuine torn-write (truncation) test.
Structured elaboration
On-disk record format. Each record is [4-byte length][8-byte seq][payload bytes][4-byte CRC32 checksum]. The length prefix lets replay() know exactly how many payload bytes to read without a delimiter (which could collide with binary payload content). The seq is a monotonically increasing number assigned at append time, used for ordering and as a natural idempotency key for downstream reconciliation. The checksum covers the payload and is verified on every read; a mismatch means corruption (or a torn write) and replay stops there.
Durability via fsync. write() alone only guarantees the OS page cache has the bytes, not that they reached disk; a crash before the OS flushes its cache can lose data the caller believed durable. os.fsync(f.fileno()) blocks until the write has actually reached durable storage, the guarantee append() must make before returning, since callers depend on "append returned" meaning "this record survives a crash."
Segment rotation. Once the current segment would exceed segment_max_bytes, a new file opens (zero-padded index, so filename order equals chronological order) and appends continue there. This bounds any single file's size, useful for eventual archival/deletion of old segments once a checkpoint makes them unnecessary, with no separate index file needed since replay() simply processes segments in filename order.
Crash recovery of the WAL's own bookkeeping. On construction, the WAL scans existing segments for the highest seq already used, so a restarted process resumes numbering correctly instead of colliding with pre-crash records: the WAL's own metadata (next seq, current segment) must itself be recoverable from disk, not held only in memory.
Worked example
"""
A simple write-ahead log: append(record), fsync durability, replay() in
order, and segment rotation. On-disk record format:
[4-byte length][8-byte seq][record bytes][4-byte CRC32 checksum]
Segments rotate at a size threshold, named wal.0000000000, wal.0000000001,
..., so replay processes them in order by filename with no extra index.
"""
import os, struct, zlib, glob
class WriteAheadLog:
HEADER_FMT = "!IQ"
HEADER_SIZE = struct.calcsize(HEADER_FMT)
def __init__(self, directory, segment_max_bytes=1024):
self.directory = directory
os.makedirs(directory, exist_ok=True)
self.segment_max_bytes = segment_max_bytes
self._next_seq = self._recover_next_seq()
self._current_segment_idx = self._recover_current_segment_idx()
self._current_path = self._segment_path(self._current_segment_idx)
self._current_size = os.path.getsize(self._current_path) if os.path.exists(self._current_path) else 0
def _segment_path(self, idx):
return os.path.join(self.directory, f"wal.{idx:010d}")
def _existing_segments(self):
return sorted(glob.glob(os.path.join(self.directory, "wal.*")))
def _recover_current_segment_idx(self):
segments = self._existing_segments()
return int(os.path.basename(segments[-1]).split(".")[1]) if segments else 0
def _recover_next_seq(self):
# On restart, scan existing segments for the highest seq seen, so
# numbering continues correctly instead of colliding with prior records.
max_seq = -1
for _, seq, _ in self._replay_paths(self._existing_segments()):
max_seq = max(max_seq, seq)
return max_seq + 1
def append(self, record_bytes):
seq = self._next_seq
self._next_seq += 1
crc = zlib.crc32(record_bytes)
entry = struct.pack(self.HEADER_FMT, len(record_bytes), seq) + record_bytes + struct.pack("!I", crc)
if self._current_size + len(entry) > self.segment_max_bytes and self._current_size > 0:
self._current_segment_idx += 1
self._current_path = self._segment_path(self._current_segment_idx)
self._current_size = 0
with open(self._current_path, "ab") as f:
f.write(entry)
f.flush()
os.fsync(f.fileno()) # block until the OS confirms the write hit disk
self._current_size += len(entry)
return seq
@staticmethod
def _replay_one_segment(path):
with open(path, "rb") as f:
while True:
header = f.read(WriteAheadLog.HEADER_SIZE)
if len(header) < WriteAheadLog.HEADER_SIZE:
return
length, seq = struct.unpack(WriteAheadLog.HEADER_FMT, header)
payload = f.read(length)
crc_bytes = f.read(4)
if len(payload) < length or len(crc_bytes) < 4:
return # torn write: stop here
if zlib.crc32(payload) != struct.unpack("!I", crc_bytes)[0]:
return # checksum mismatch: stop here
yield seq, payload
def _replay_paths(self, paths):
for path in paths:
for seq, payload in self._replay_one_segment(path):
yield path, seq, payload
def replay(self):
for path, seq, payload in self._replay_paths(self._existing_segments()):
yield seq, payload
def main():
import shutil
test_dir = os.path.join(os.path.dirname(__file__), "s25_wal_dir")
if os.path.exists(test_dir):
shutil.rmtree(test_dir)
# Small segment_max_bytes forces MULTIPLE rotations with a modest record
# count, so rotation is actually exercised, not just theoretically present.
wal = WriteAheadLog(test_dir, segment_max_bytes=200)
records = [f"record-{i}-payload".encode() for i in range(50)]
seqs = [wal.append(r) for r in records]
print("Appended 50 records, seqs:", seqs[0], "...", seqs[-1])
segment_files = sorted(glob.glob(os.path.join(test_dir, "wal.*")))
print(f"Segment files created: {len(segment_files)}")
assert len(segment_files) > 1, "rotation never fired: test would be vacuous"
replayed = list(wal.replay())
replayed_seqs = [seq for seq, _ in replayed]
replayed_payloads = [payload for _, payload in replayed]
print(f"Replayed {len(replayed)} records; seqs in order: {replayed_seqs == list(range(50))}")
assert replayed_seqs == list(range(50))
assert replayed_payloads == records
print("Assertion passed: replay across multiple rotated segments returns all 50 records,")
print("in original order, byte-identical to what was appended.")
# Crash recovery: a FRESH instance on the SAME directory (simulated restart)
# must continue numbering seq correctly, not collide with prior records.
wal2 = WriteAheadLog(test_dir, segment_max_bytes=200)
new_seq = wal2.append(b"post-restart-record")
print(f"After simulated restart, next seq assigned: {new_seq} (expected 50)")
assert new_seq == 50
# Corruption handling: truncate the last segment mid-record (a torn write)
# and confirm replay stops cleanly at the corruption point.
last_segment = sorted(glob.glob(os.path.join(test_dir, "wal.*")))[-1]
with open(last_segment, "r+b") as f:
f.seek(0, os.SEEK_END)
f.truncate(max(0, f.tell() - 3))
wal3 = WriteAheadLog(test_dir, segment_max_bytes=200)
replayed_after_corruption = list(wal3.replay())
print(f"After truncating the last segment (simulated torn write), replay returned "
f"{len(replayed_after_corruption)} valid records (expected 50: the corrupted "
f"51st record is correctly excluded, not returned as garbage).")
assert len(replayed_after_corruption) == 50
shutil.rmtree(test_dir)
if __name__ == "__main__":
main()
Output (actually executed with python3):
Appended 50 records, seqs: 0 ... 49
Segment files created: 9
Replayed 50 records; seqs in order: True
Assertion passed: replay across multiple rotated segments returns all 50 records,
in original order, byte-identical to what was appended.
After simulated restart, next seq assigned: 50 (expected 50)
After truncating the last segment (simulated torn write), replay returned 50 valid records (expected 50: the corrupted 51st record is correctly excluded, not returned as garbage).
At segment_max_bytes=200, 50 small records genuinely force 9 separate segment files, confirming rotation actually fired rather than being an untested code path. Replay across all 9 segments returns exactly the 50 original records, in order, byte-identical to what was appended. The restart test confirms seq bookkeeping survives a process restart (resumes at 50, not 0). The truncation test is the deliberately non-vacuous corruption check: physically chopping bytes off the last segment (simulating a crash mid-fsync) and confirming replay still returns exactly the 50 valid records, correctly excluding the truncated one rather than raising or returning garbage.
Trade-offs and pitfalls
- Common mistake: calling
write()withoutfsyncand assuming durability. This is the single most consequential gap for a WAL, since its entire point is a guarantee stronger than "the OS probably has it"; skipping fsync makes it no more crash-safe than an ordinary buffered file. - fsync has a real, measurable per-call cost, which is why production WALs almost always batch multiple writes into one fsync (group commit) rather than fsyncing every
append(); this implementation fsyncs per-append for clarity, and a senior answer should name group commit as the standard optimization, not treat per-append fsync as free. - Common mistake: using a delimiter (like a newline) instead of a length prefix. A payload containing the delimiter byte sequence corrupts parsing; the length-prefix format sidesteps this, since replay always knows exactly how many bytes belong to the current record regardless of content.
- Rotation alone does not bound total growth, only per-file size; a production WAL needs old segments deleted once a checkpoint confirms they are no longer needed, left out here as an orthogonal concern from the WAL mechanics themselves.
Unlock Full Question Bank
Get access to all 22 Data Reliability and Fault Tolerance interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.