Data Consistency and Distributed Transactions Questions
Maintaining correctness of state across services and replicas: eventual consistency, conflict resolution (last-write-wins, CRDTs, vector clocks), the saga pattern, two-phase commit, and idempotency keys for exactly-once effects. Covers when to trade strict consistency for availability and how to reason about read-your-writes and monotonic guarantees. Focuses on the application/service layer rather than storage-engine internals.
Describe a zero-downtime migration strategy to change a service's consistency model from strong to eventual. Include feature flags, dual-writes, read-path toggles, monitoring to verify correctness, and a rollback path if anomalies appear. Explain how you would validate data correctness throughout the migration.
Sample Answer
Direct answer: A zero-downtime migration from strong to eventual consistency uses feature flags to control which consistency path is active, dual-writes to keep both models correct during the transition, a read-path toggle so you can validate the new (eventually-consistent) reads against the old (strong) ones before trusting them, ongoing monitoring to catch correctness regressions early, and a rollback plan that doesn't require another migration to execute.
Structured elaboration
Feature flags. A flag (ideally per-tenant or percentage-rollout capable, not a single global switch) controls whether a given request path uses the old strongly-consistent read/write logic or the new eventually-consistent one, letting you migrate gradually and limit the blast radius of any problem to whatever slice of traffic the flag currently covers.
Dual-writes. During the transition, writes go to BOTH the old strongly-consistent path and the new eventually-consistent path (e.g. writing synchronously to the primary datastore as before, while also publishing to whatever asynchronous replication or eventual-consistency mechanism the new path relies on). This keeps both models populated and comparable while neither is fully trusted alone yet.
Read-path toggle plus shadow reads. Before actually SERVING reads from the new eventually-consistent path, run it in shadow mode: for a sample of requests, read from both paths, compare the results, and log any divergence, without letting the new path's result affect the response the user actually sees. This surfaces correctness bugs (staleness beyond expected bounds, a merge bug, a missed write) before they're customer-visible.
Monitoring to verify correctness. Track divergence rate between the two paths (from shadow reads), staleness distribution on the new path (how far behind is a typical eventually-consistent read, and what's the tail), and business-relevant anomaly signals specific to the domain (e.g. for inventory, oversell events). These metrics are what tell you when it's actually safe to flip more traffic to the new path, not just elapsed time or gut feel.
Rollback path. Because writes are dual-written throughout the migration, rolling back is just flipping the feature flag back to serve reads from the still-current strongly-consistent path, no data migration or backfill needed, since that path was never stopped. This is why dual-writing throughout the transition (rather than a hard cutover) is the key enabler of true zero-downtime rollback.
Worked example. A product-catalog service migrates from a single strongly-consistent primary to a multi-region, eventually-consistent replicated store. Phase 1: dual-write catalog updates to both the existing primary and the new replicated store; reads still come exclusively from the primary. Phase 2: enable shadow reads for 5% of traffic, comparing primary reads against the new store's reads, this surfaces that replication lag occasionally exceeds 2 seconds during peak write bursts, more staleness than the product team is comfortable with, so the team adds a bounded-staleness check (reject a shadow-read comparison, and hold back cutover, if lag exceeds a threshold) before proceeding. Phase 3: once divergence and staleness metrics are within agreed bounds for a sustained period, gradually flip the read-path flag to serve real traffic from the new store, monitoring the same metrics the whole time, with the flag remaining flippable back to the primary for weeks afterward as a safety net.
Trade-offs and pitfalls. Skipping the shadow-read validation phase (going straight from dual-write to serving real traffic from the new path) is the most common shortcut that causes incidents, correctness bugs in an eventually-consistent path often only show up under real production write patterns and timing, not in staging, and shadow reads are what let you catch them without a customer-visible impact.
Explain the differences between Lamport clocks, vector clocks, and logical timestamps in general. For each, state what it can and cannot detect (ordering versus true concurrency) and walk through a brief example of how it's updated on send and receive events.
Sample Answer
Direct answer: Lamport clocks give you a total ordering consistent with causality (if A happened-before B, A's Lamport timestamp is smaller), but they CAN'T tell you whether two events are truly concurrent, two unrelated events can end up with different Lamport timestamps that make them look ordered even though neither actually caused the other. Vector clocks fix exactly this gap: they can tell you happened-before, happened-after, AND genuinely concurrent, at the cost of a counter per replica instead of a single number.
Structured elaboration
Lamport clocks: mechanism. Each process keeps a single integer counter. On a LOCAL event, increment the counter. On SENDING a message, attach the current counter value. On RECEIVING a message with timestamp T, set the local counter to max(local_counter, T) + 1. This guarantees: if event A causally precedes event B (a happens-before relationship via a chain of local events and messages), then timestamp(A) < timestamp(B). But the CONVERSE isn't guaranteed: timestamp(A) < timestamp(B) does NOT imply A happened before B, they might be completely unrelated events that just happened to get ordered that way by the counter mechanics.
Vector clocks: mechanism. As described for causality tracking, each process keeps a vector of counters, one per process, and comparison (element-wise) tells you happened-before, happened-after, or concurrent, unambiguously. This is strictly more information than a Lamport clock provides.
What each can and cannot detect.
| Can detect happened-before? | Can detect true concurrency? | Metadata overhead | |
|---|---|---|---|
| Lamport clock | Yes (via the ordering property) | No, false ordering can appear between unrelated events | One integer per event |
| Vector clock | Yes | Yes, this is exactly what element-wise incomparability signals | One integer PER PROCESS per event |
| Logical timestamps (general term) | Depends on the specific scheme | Depends on the specific scheme | Depends |
Worked example. Two independent processes P1 and P2 never communicate. P1's local Lamport counter reaches 5 after some local events; P2's, independently, reaches 3. If you now compare timestamp(P1's event) = 5 and timestamp(P2's event) = 3, it LOOKS like P2's event happened before P1's (smaller timestamp), but there's no actual causal relationship at all, they're on completely independent tracks. A vector clock comparing {P1:5, P2:0} against {P1:0, P2:3} correctly identifies these as concurrent (neither vector dominates), the Lamport clock's single integer has no way to express that.
Send/receive update walkthrough (Lamport). P1 has local counter 2 and sends a message; the message carries timestamp 3 (2, then incremented for the send event itself). P2 has local counter 1 when it receives this message; it computes max(1, 3) + 1 = 4 and sets its own counter to 4. Any subsequent event P2 does will carry a timestamp of at least 4, correctly reflecting that it happens after P1's send (which had timestamp 3), preserving the causal ordering property even though P2's OWN prior activity (counter 1) was much lower.
Send/receive update walkthrough (vector clock). Two processes, P1 and P2, start at {P1:0, P2:0}. P1 does a local event, incrementing its own entry: {P1:1, P2:0}, then sends a message carrying that vector. P2, before receiving it, does its own local event, incrementing its own entry: {P1:0, P2:1}. When P2 RECEIVES P1's message, it merges element-wise (max per entry: {P1: max(0,1)=1, P2: max(1,0)=1}) and then increments its own entry for the receive event itself, landing at {P1:1, P2:2}. Comparing P2's pre-receive vector {P1:0, P2:1} against P1's sent vector {P1:1, P2:0} shows neither dominates, correctly flagging P1's local event and P2's local event as concurrent, exactly the distinction a Lamport clock's single integer cannot make (a Lamport clock would just assign each an arbitrary-looking total order with no way to mark them as concurrent).
Trade-offs and pitfalls. Choosing Lamport clocks when you actually NEED to detect true concurrency (e.g. for conflict detection in a replicated data store) is a real design bug, not a simplification, since Lamport clocks will silently impose a false ordering on genuinely conflicting concurrent writes rather than flagging them as needing resolution. Lamport clocks are the right, cheaper choice specifically when all you need is a consistent total order for something like event logging or debugging causality chains, not for detecting write-write conflicts.
Design a saga orchestrator that guarantees exactly-once external side effects (like sending notification emails or charging cards) in the presence of retries and orchestrator crashes. Include durable command logs, deduplication of outbound commands, transactional outbox patterns, compensating actions, idempotency tokens for external systems, and reconciliation mechanisms for failures.
Sample Answer
Direct answer: To guarantee exactly-once external side effects (like sending an email or charging a card) despite retries and orchestrator crashes, the orchestrator needs a durable command log recording every side-effecting call it has made, keyed so a retried or resumed saga recognizes "I already issued this command" and skips re-issuing it, combined with idempotency tokens the external system itself can use to deduplicate on its side.
Structured elaboration
Durable command log. Before calling an external system (charge card, send email), the orchestrator writes a durable record: (saga_id, step, command_id, status=pending). It only sends the actual external call after that write succeeds. If the orchestrator crashes after the write but before getting a response, on recovery it finds the pending record and must resolve the ambiguity, not by blindly retrying (the original call may have already succeeded on the external system's side), but by checking status first where possible, or retrying with the SAME command_id as an idempotency token so the external system can recognize the duplicate.
Idempotency tokens for external systems. Any external side effect worth protecting this way needs to accept an idempotency key from the caller (most payment gateways and transactional email providers do): the orchestrator generates a stable command_id once per logical command and reuses it on every retry of that same command, so the external system either applies it once and returns the same result on retries, or explicitly rejects a duplicate.
Deduplication of outbound commands. Before issuing any command, the orchestrator checks its own log for an existing record with that command_id, if status=applied already, it skips the call entirely (no network round-trip needed, we already know the outcome); if status=pending, it either polls the external system's status endpoint (if one exists) or safely retries using the same idempotency token.
Transactional outbox pattern for writing the command log. When the decision to issue a side-effecting command is itself derived from a change the orchestrator is making to its own database (e.g. "saga step N just completed, therefore issue the notification-email command"), writing the (command_id, status=pending) row to the command log and committing the saga's own state change must happen atomically, otherwise you get the same dual-write problem the command log exists to prevent, just one level up: the saga state could commit while the command-log row is lost, or vice versa. The transactional outbox pattern is how this is made atomic: the pending command-log row is inserted in the SAME local database transaction as the saga's state update, and a separate relay (polling or change-data-capture based) is what actually issues the external call and marks the row applied, rather than the orchestrator calling the external system synchronously inline. This decouples "decide to send the command, durably" from "actually send it," and gives the orchestrator a natural resume point after a crash: any outbox row still pending is a command the relay has not yet confirmed was issued.
Compensating actions and reconciliation. If a step later in the saga fails and this side effect needs to be "undone" (refund a charge, can't literally un-send an email), the compensating action is itself logged and issued the same way, with its own idempotency token. A periodic reconciliation job cross-checks the command log against the external system's own record of what was applied (where the external system offers a lookup API), to catch and alert on any command stuck in pending for longer than expected, evidence of a bug or an external outage rather than a normal race.
Worked example. Saga step "charge card" generates command_id = S-991-charge. Orchestrator logs (S-991, charge, S-991-charge, pending), then calls the payment gateway's charge API with Idempotency-Key: S-991-charge. The call times out with no response (ambiguous: could have succeeded server-side). Orchestrator does NOT immediately retry blindly assuming failure; it either calls the gateway's "look up by idempotency key" endpoint if available, or safely retries the SAME charge call with the same key, the gateway either recognizes the duplicate and returns the original result, or, if the first attempt genuinely never reached it, applies it now. Either way exactly one real charge happens, and once the orchestrator gets a definitive applied response it updates the log to status=applied and proceeds.
Trade-offs and pitfalls. The recurring failure mode is generating a NEW command_id on every retry instead of reusing the same one, that defeats the whole point, since the external system can no longer tell a retry from a genuinely new request, and you're back to the double-charge/double-email risk this design exists to prevent. The command_id must be stable per LOGICAL command (tied to the saga+step), generated once and reused for every retry attempt of that specific command.
Design a saga orchestration for a multi-service order workflow (for example: Orders, Payments, Inventory, Shipping). Specify the normal-step flow and the compensating actions for failures, how you ensure idempotency of each step, how the orchestrator persists saga state and recovers from crashes, and the retry/backoff strategy.
Sample Answer
Direct answer: For an order workflow spanning Orders, Payments, Inventory, and Shipping, I'd use an orchestrated saga: a dedicated orchestrator process calls each service in sequence, persists the saga's progress after every step so it can resume after a crash, and drives a reverse-order compensation sequence if any step fails, with every step (forward and compensating) designed to be safely retryable.
Structured elaboration
Normal-step flow.
- Create order (Orders service, local transaction, status = "pending").
- Reserve inventory (Inventory service; a reservation, not a final decrement, so it's cleanly reversible).
- Charge payment (Payments service).
- Confirm the reservation into a real decrement and mark shipping as "ready to ship" (Inventory + Shipping).
- Mark order "confirmed" (Orders service).
Compensating actions, defined per step, triggered in reverse order from wherever the failure occurred:
- Shipping not yet started at failure time: nothing to compensate there.
- Payment charged but inventory confirmation failed: refund the payment.
- Inventory reserved but payment failed: release the reservation.
- Order created but nothing else succeeded: cancel the order.
Persisting saga state. The orchestrator writes the saga's current step and status to its own durable store BEFORE calling the next service, keyed by a saga ID. On crash, the orchestrator (or a fresh instance) reads any saga rows that are "in progress" and resumes from the last recorded step, either continuing forward or, if the failure happened mid-flight, running the compensation sequence from wherever it got to. This state machine is exactly what lets the orchestrator be restarted safely without losing track of a saga.
Idempotency. Every call the orchestrator makes (both forward steps and compensations) is made with an idempotency key derived from (saga_id, step_name), so a retried call after a timeout doesn't double-charge, double-reserve, or double-refund. This matters especially on resume after a crash: the orchestrator can't always be sure whether its last call before the crash actually landed, so it must retry safely rather than skip or assume.
Retry/backoff strategy. Transient failures (timeouts, 5xx responses) get retried with exponential backoff and a bounded number of attempts before the step is treated as a hard failure that triggers compensation; a definitive rejection (e.g. payment declined, out of stock) triggers compensation immediately without retrying.
Worked example. Saga S-4471 reaches step 3 (charge payment), the orchestrator logs S-4471: step=payment, status=in_progress before calling Payments, then crashes before receiving the response. On restart, it reads that row, sees payment is in_progress, and re-issues the charge call with the SAME idempotency key it would have used originally; Payments either applies it for the first time or recognizes the duplicate key and returns the already-applied result, either way, exactly one charge happens. If Payments instead returns "declined," the orchestrator logs the failure and runs the reverse sequence: release the inventory reservation, cancel the order.
Trade-offs and pitfalls. The most common bug in this pattern is persisting saga state AFTER the service call instead of before, if the orchestrator crashes between calling a service and recording that it did, resuming can't tell whether the call landed and is forced to guess, which is exactly the ambiguity idempotency keys exist to make safe to retry through rather than avoid.
Design an architecture for coordinating cross-service transactions in a distributed data platform where ACID guarantees across services are infeasible. Walk through how you would make forward progress safely, detect when something has gone wrong, and recover, so the platform reaches a consistent state without ever needing a single distributed transaction across every participant.
Sample Answer
Direct answer: Where ACID guarantees across services aren't feasible, the architecture combines sagas with compensating transactions to make forward progress and correct partial failures after the fact, idempotency on every step so retries under failure are safe, monitoring that surfaces sagas stuck mid-flight, and a human-in-the-loop remediation path for the cases automation genuinely can't resolve on its own.
Structured elaboration
Why full ACID is infeasible here. Each service owns its own datastore, and a real distributed-transaction protocol (2PC or consensus-backed) across all of them would mean every service blocks holding locks for the duration of a cross-platform operation, an availability cost most data-platform workloads (analytics pipelines, cross-domain reporting, multi-team data products) can't tolerate, especially when the services involved don't share an operational team or even a network boundary.
Sagas plus compensation as the coordination mechanism. Each cross-service operation is modeled as a saga: a sequence of local, independently-committing steps, each with a defined compensating action. This gets you eventual consistency (the platform converges to a correct state) without requiring any step to block on another.
Idempotency at every step. Since network failures mean the platform can't always tell whether a step's call succeeded, every step (forward and compensating) is keyed with an idempotency token so a retry doesn't double-apply. This is the property that makes "just retry on failure" a safe default strategy rather than a source of duplicate side effects.
Monitoring for stuck sagas. Every saga's progress is tracked (as in the audit-trail design), and an automated monitor flags any saga that has been sitting in an intermediate, non-terminal state for longer than its expected duration, this is the platform's early-warning signal that something needs attention, rather than discovering it only when a downstream consumer complains about missing or inconsistent data.
Human-in-the-loop remediation. For sagas that genuinely can't be resolved automatically (an external dependency is down for an extended period, a compensating action itself keeps failing, or the situation requires a judgment call not encoded in the automated logic), the platform surfaces them in an operator queue with enough context to decide: force-retry, force-compensate, or manually correct the underlying data, with every manual action itself logged as part of the saga's audit trail.
Worked example. A data platform propagates a schema change across three downstream systems (a warehouse, a search index, and a caching layer) as a saga. The warehouse and search index apply the change successfully; the caching layer's apply step fails repeatedly due to a transient outage. The stuck-saga monitor flags this after it's been "in progress" for longer than the platform's SLA for this kind of propagation. An operator is alerted, sees the caching layer has been down for 40 minutes (from the same monitoring), and either waits for it to recover (retries resume automatically once it's healthy) or, if urgent, manually applies the schema change to the cache and marks that step "manually completed" in the saga's state, an action itself recorded in the audit trail.
Trade-offs and pitfalls. A platform that builds the saga-plus-compensation machinery but skips the monitoring and human-remediation path ends up with sagas that silently stall forever with no one aware, which is worse for data correctness than a system that never attempted eventual consistency in the first place, because nothing signals that the data is now actually inconsistent. The monitoring and remediation path is not optional polish, it's what makes "eventual" in "eventual consistency" an operational promise rather than a hope.
Unlock Full Question Bank
Get access to all 49 Data Consistency and Distributed Transactions interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.