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.
Explain session guarantees: read-your-writes, monotonic reads, monotonic writes, and write-follows-reads. Propose an implementation strategy for a client SDK to provide these guarantees against a multi-region replicated datastore, including how you'd persist the necessary metadata across devices and handle token expiry.
Sample Answer
Direct answer: Session guarantees are a set of promises a system can make to a SPECIFIC client about what it will see across its own sequence of reads and writes, weaker than full strong consistency (which promises something to every observer, not just one client's own session), but strong enough to avoid the most confusing user-facing anomalies. The four common ones: read-your-writes (a client always sees its own prior writes), monotonic reads (a client never sees data go backward in time on successive reads), monotonic writes (a client's writes are applied in the order it made them), and write-follows-reads (a client's write is guaranteed to be ordered after any write it has already observed).
Structured elaboration
Read-your-writes. After a client successfully writes something, every subsequent read BY THAT CLIENT reflects that write (or something newer), even if the underlying system is eventually consistent for OTHER clients. Without it: a user updates their profile, refreshes, and sees the old value, confusing even though the system is "eventually" correct.
Monotonic reads. Once a client has read a value at some point in time, it will never later read an OLDER value, even if it's routed to a different, lagging replica on a subsequent request. Without it: a user refreshes a page twice and the second refresh shows STALER data than the first, which reads as the system going backward.
Monotonic writes. A client's own writes are applied in the order the client issued them, even if they're processed by different replicas or arrive out of order over the network. Without it: two edits from the same user could be applied out of order, silently losing the later edit's effect if the earlier one "wins" by arriving second.
Write-follows-reads. If a client reads value V and then makes a write based on what it read, that write is guaranteed to be applied AFTER (causally ordered after) the write that produced V, even on a different replica. Without it: a user could see a comment, reply to it, and have their reply become visible to others before the original comment they're replying to is, a causally backward-looking result.
Implementation strategies for a client SDK
Sticky sessions (server-side). Route a given client's requests to the SAME replica for the duration of a session, so its own reads trivially see its own writes (since they're the same node). Simple, but limits load-balancing flexibility and doesn't survive the sticky replica failing over.
Client-side version tokens / causal tokens. The server returns a version token (a timestamp, a vector clock, or a simple monotonic counter) with every response; the client includes the LATEST token it has seen with every subsequent request; the server serving that request ensures the replica it reads from is at least as current as that token (waiting briefly, or routing to a sufficiently up-to-date replica) before responding. This works across server restarts and load-balancer changes, at the cost of a small amount of extra state the client (or an SDK on its behalf) needs to carry and the server needs to check against.
Metadata persistence and token expiry. For a client SDK, the causal token is typically stored alongside whatever session state the client already keeps (in-memory for a single session, or persisted, e.g. in a mobile app's local storage, if the guarantee needs to survive an app restart). A token needs no explicit expiry for correctness (an old, stale token just means the server might do a bit more waiting to catch up, never incorrect behavior), though in practice you might cap how large it can grow (e.g. compacting a vector-clock-style token) for efficiency.
Persisting across devices. Storing the causal token only in a single device's local storage (as described above) loses the guarantee the moment a user switches devices, a phone-issued token never reaches the laptop the user opens next, so the laptop's first read looks like a fresh, tokenless session and can show stale data relative to a write the user just made on the phone. To make the guarantee follow the USER rather than the device, the server should also persist the client's latest known token keyed by the authenticated user's account (not just return it to the device and forget it), and the SDK should fetch that stored token as part of session bootstrap on login, merging it with (or preferring it over) whatever token the local device already has, before issuing its first read. This costs a small amount of server-side storage per user and an extra lookup at login, but it's what keeps read-your-writes intact across a device switch instead of silently resetting.
Trade-offs and pitfalls. Confusing "the system is eventually consistent" with "session guarantees don't matter" is a common design mistake, an eventually-consistent system with NO session guarantees produces a genuinely confusing user experience (data appearing to go backward, a user not seeing their own actions reflected), while the same system WITH session guarantees can feel completely normal to users despite being eventually consistent under the hood for everyone else.
Implement a VectorClock class in Python that supports: increment(node_id), merge(other_clock), and compare(other_clock) returning one of {'less', 'greater', 'concurrent', 'equal'}. Provide function signatures, brief inline comments, and an example of using compare to detect concurrent updates between two replicas.
Sample Answer
Direct answer: A vector clock implementation needs three operations: increment (record a local event for a given node), merge (combine two vectors by taking the element-wise maximum, used when receiving an update from another replica), and compare (determine the causal relationship between two vectors: one dominates the other, or they're concurrent).
Structured elaboration. The comparison logic is the part most implementations get subtly wrong: you have to check dominance in BOTH directions across every key present in either vector (treating a missing key as implicitly 0), and correctly classify "equal" as its own case distinct from "concurrent."
from typing import Dict
def increment(clock: Dict[str, int], node_id: str) -> Dict[str, int]:
"""Returns a NEW clock with node_id's counter incremented by 1.
Pure function: does not mutate the input."""
new_clock = dict(clock)
new_clock[node_id] = new_clock.get(node_id, 0) + 1
return new_clock
def merge(clock_a: Dict[str, int], clock_b: Dict[str, int]) -> Dict[str, int]:
"""Element-wise max across the union of keys in both clocks."""
all_nodes = set(clock_a) | set(clock_b)
return {
node: max(clock_a.get(node, 0), clock_b.get(node, 0))
for node in all_nodes
}
def compare(clock_a: Dict[str, int], clock_b: Dict[str, int]) -> str:
"""Returns one of: 'equal', 'a-descends', 'b-descends', 'concurrent'.
a-descends means a happened-after b (a causally descends from b)."""
all_nodes = set(clock_a) | set(clock_b)
a_gte_b = all(clock_a.get(n, 0) >= clock_b.get(n, 0) for n in all_nodes)
b_gte_a = all(clock_b.get(n, 0) >= clock_a.get(n, 0) for n in all_nodes)
if a_gte_b and b_gte_a:
return "equal"
if a_gte_b:
return "a-descends"
if b_gte_a:
return "b-descends"
return "concurrent"
Worked walkthrough matching the earlier example. Replica A does a local update: clock_a = increment({}, "A") -> {"A": 1}. Independently, replica B does a local update on its own copy: clock_b = increment({}, "B") -> {"B": 1}. Comparing: compare({"A":1}, {"B":1}) checks a_gte_b: is A's A(=1) >= B's A(=0) (yes, since B's clock has no "A" key, treated as 0) AND A's B(=0) >= B's B(=1) (no). So a_gte_b is False. Checking b_gte_a symmetrically is also False. Result: "concurrent", correctly detecting that these two updates happened independently with neither aware of the other.
If instead B had first MERGED in A's clock before making its own update (clock_b = increment(merge({}, {"A":1}), "B") -> {"A":1, "B":1}), then compare({"A":1}, {"A":1,"B":1}) gives a_gte_b = False (A's clock has no "B" entry, treated as 0, but B's clock has B=1) and b_gte_a = True (B's clock dominates on every key), correctly returning "b-descends", B's update causally happened after and incorporates A's.
Complexity and pruning at scale. Each operation is O(number of distinct nodes ever seen in either clock), fine for small, fixed clusters, but a real concern in systems with many ephemeral clients (mobile devices, short-lived workers) where the SET of node IDs that have ever touched a piece of data can grow unbounded over that data's lifetime, since old node IDs never get removed by this basic implementation. Production systems typically prune entries for nodes that haven't contributed an update in a long time (accepting a small, bounded risk of mis-classifying a very-late-arriving update from a pruned node as having no prior causal context), or use dotted version vectors, which more precisely track per-node contribution and support safer pruning.
Trade-offs and pitfalls. A common bug is treating a missing key as an ERROR or as "incomparable" rather than implicitly 0, that breaks the comparison for any pair of clocks that haven't both been merged from a common history, exactly the concurrent case this data structure needs to handle correctly. The .get(key, 0) pattern in the code above is doing real, necessary work, not just Pythonic convenience.
Define Conflict-free Replicated Data Types (CRDTs) and explain the difference between state-based (CvRDT) and operation-based (CmRDT) CRDTs. Walk through how a couple of common CRDT types merge, and give two realistic use cases where you'd recommend them.
Sample Answer
Direct answer: A CRDT (Conflict-free Replicated Data Type) is a data structure designed so that replicas can be updated independently, without coordinating with each other, and always converge to the same state once they've all seen the same set of updates, regardless of the order those updates arrived in. State-based CRDTs (CvRDTs) replicate by sending the WHOLE current state and merging it with a commutative, associative, idempotent merge function; operation-based CRDTs (CmRDTs) instead replicate the individual OPERATIONS, relying on the operations themselves being designed to commute (apply correctly regardless of order) when delivered at-least-once.
Structured elaboration
State-based (CvRDT). Each replica periodically sends its full local state to others; the receiving replica merges the incoming state with its own via a merge function that must be commutative (merge(A,B) == merge(B,A)), associative (merge(merge(A,B),C) == merge(A,merge(B,C))), and idempotent (merge(A,A) == A), these three properties together guarantee that no matter what order or how many times states are exchanged and merged, every replica converges to the same result. Simple to reason about (you don't need reliable, ordered delivery, since re-merging the same state twice is a no-op), but can be bandwidth-heavy if full state is large and changes frequently.
Operation-based (CmRDT). Each replica broadcasts individual OPERATIONS (e.g. "increment by 1", "add element X") rather than full state; correctness requires that concurrent operations commute (applying them in either order produces the same result) and typically that delivery is reliable (every replica eventually receives every operation, even if not in the same order), since a lost operation, unlike a lost state-sync, isn't automatically corrected by a later full-state exchange. More bandwidth-efficient for high-frequency small changes, but places a stronger requirement on the delivery layer.
Merge semantics, walked through with a concrete type. A G-Counter (grow-only counter, state-based) holds a vector of per-replica counts, e.g. replica A's local view {A: 3, B: 1} and replica B's local view {A: 2, B: 4} (B has incremented more locally, A's copy of B's count is stale). Merging: element-wise max, {A: max(3,2)=3, B: max(1,4)=4}. The TOTAL value is the sum of all entries, 7, and this merge is trivially commutative, associative, and idempotent (max has all three properties), so both replicas converge to the same {A:3, B:4} (total 7) regardless of how many times or in what order they exchange states.
An OR-Set (observed-remove set, supports add AND remove correctly) tags each added element with a unique ID; removing an element records the SPECIFIC IDs being removed (the ones the removing replica has observed), so a concurrent add of the "same" logical element (a new ID) from another replica isn't accidentally removed too, a naive set that just tracked "added" and "removed" element values (without unique IDs) would incorrectly let a remove suppress a concurrent, unrelated add of an equal value.
Two realistic use cases.
- A distributed like-counter that needs to keep incrementing correctly even while regions are partitioned from each other, a PN-Counter (positive-negative counter, supporting both increment and decrement) handles this without any coordination, each region increments its own local entry, and totals converge once partitions heal.
- A multi-device shopping cart or collaborative to-do list where items can be added from different devices while offline, an OR-Set lets each device add/remove items independently, converging correctly on reconnect without a central server arbitrating conflicts in real time.
Trade-offs and pitfalls. CRDTs are not a universal answer to distributed conflict, they only work for data types where a meaningful, automatic merge function exists; something like "the correct final price after two concurrent discount-code applications" often has no context-free merge rule (whether they should stack, or one should override, is a BUSINESS decision, not a data-structure property), and forcing that into a CRDT abstraction usually produces a mathematically well-defined but business-nonsensical result.
Implement a PN-Counter (Positive-Negative counter) CRDT in Python with methods: increment(amount), decrement(amount), merge(other), and value(). Provide the data structure and explain why merge is commutative, associative, and idempotent.
Sample Answer
Direct answer: A PN-Counter (positive-negative counter) supports both increment and decrement by internally tracking two separate G-Counters, one for increments and one for decrements, and reporting the value as their difference; merging two PN-Counters is just merging each half independently (element-wise max per replica, same as a plain G-Counter), which keeps the whole structure commutative, associative, and idempotent.
Structured elaboration. The key design insight is that a PN-Counter is NOT a single vector you merge with subtraction, subtraction isn't idempotent or safely mergeable the way max is. Instead it's two independent G-Counters (grow-only, merge via max) combined only at READ time via subtraction.
class PNCounter:
def __init__(self, replica_id: str):
self.replica_id = replica_id
self._increments = {} # replica_id -> count of increments FROM that replica
self._decrements = {} # replica_id -> count of decrements FROM that replica
def increment(self, amount: int = 1) -> None:
if amount < 0:
raise ValueError("use decrement() for negative changes")
self._increments[self.replica_id] = self._increments.get(self.replica_id, 0) + amount
def decrement(self, amount: int = 1) -> None:
if amount < 0:
raise ValueError("amount must be non-negative; this reduces the counter")
self._decrements[self.replica_id] = self._decrements.get(self.replica_id, 0) + amount
def merge(self, other: "PNCounter") -> "PNCounter":
"""Returns a NEW PNCounter representing the merge of self and other.
Does not mutate either input."""
merged = PNCounter(self.replica_id)
all_inc_replicas = set(self._increments) | set(other._increments)
all_dec_replicas = set(self._decrements) | set(other._decrements)
merged._increments = {
r: max(self._increments.get(r, 0), other._increments.get(r, 0))
for r in all_inc_replicas
}
merged._decrements = {
r: max(self._decrements.get(r, 0), other._decrements.get(r, 0))
for r in all_dec_replicas
}
return merged
def value(self) -> int:
return sum(self._increments.values()) - sum(self._decrements.values())
Why merge is commutative, associative, and idempotent. Each half (_increments, _decrements) is merged via element-wise max, and max itself has all three properties (max(a,b) == max(b,a); max(max(a,b),c) == max(a,max(b,c)); max(a,a) == a), so applying the same max operation per-replica-per-half preserves those properties for the whole structure. value() is a pure, deterministic function of the merged state, it doesn't affect convergence itself, it's just how you read out the current answer, which is why subtraction (not itself idempotent) is safe to use HERE, it's read-time-only, never part of the merge.
Verification. Two replicas, A and B, both increment locally without communicating: A increments by 3, B increments by 2, then B decrements by 1. A's local state: _increments={A:3}, _decrements={}, value = 3. B's local state: _increments={B:2}, _decrements={B:1}, value = 1. Merging A into B (or B into A, order shouldn't matter): _increments = {A:3, B:2}, _decrements = {B:1}, value = (3+2) - 1 = 4. Merging the other direction gives the identical result (commutativity holds by construction, since it's built from max and set union, both order-independent), and merging twice (merged.merge(merged)) leaves the value unchanged (idempotent, since max(x,x) = x).
Trade-offs and pitfalls. A common bug when implementing this is trying to store a SINGLE net value per replica instead of separate increment/decrement counters and merging with subtraction directly, that breaks idempotence: subtracting twice would double-subtract on a re-merge, unlike max, which is safe to reapply. The two-G-Counter design is what makes re-merging (which WILL happen under retries or redundant gossip) safe.
Design a saga coordinator that supports long-running transactions which include human approvals and manual compensations. Describe the state machine, durable state storage, visibility for operators, timeout and escalation policies, and how to ensure auditability and idempotent retries of compensating steps.
Sample Answer
Direct answer: A saga coordinator that supports long-running transactions with human approvals needs an explicit, durable state machine (not just a sequence of automated steps) with a distinct "awaiting approval" state, timeout and escalation policies for approvals that never come, and the same durable-state-plus-idempotent-compensation discipline as any other saga, extended to cover a human-in-the-loop step that can take hours or days rather than milliseconds.
Structured elaboration
State machine design. Beyond the usual step states (pending, in_progress, completed, compensating, compensated), a human-approval step introduces awaiting_approval as a first-class state, entered when the automated part of a step completes and a human decision gates the next one (e.g. a large refund needing manager sign-off). The saga sits in this state, potentially for a long time, without any automated retries firing.
Durable state storage. The full saga state (current step, all prior steps' outcomes, any approval requests issued and their IDs) is persisted to a durable store on every transition, exactly like an automated saga, so an orchestrator restart doesn't lose track of a saga that's been sitting in awaiting_approval for two days.
Visibility for operators. Since a human has to act on the saga, there needs to be a queryable view (a dashboard or queue) listing every saga currently awaiting_approval, who it's assigned to or eligible to act on it, how long it's been waiting, and enough context (what's being approved, why) for the approver to decide without digging through logs.
Timeout and escalation policies. An approval request that sits unanswered past a threshold should escalate, notify a different approver, page an on-call, or auto-escalate to a higher authority level, rather than silently stalling forever. Some designs also define an explicit "default" outcome for a timed-out approval (auto-deny is usually safer than auto-approve for anything consequential).
Auditability and idempotent retries of compensating steps. Every approval decision (who approved, when, any notes) is recorded immutably alongside the saga's state, both for compliance and so a later dispute can be traced. Compensating actions triggered after a rejected approval must be idempotent exactly like any other saga compensation, since an operator might reject the same request twice (e.g. a UI double-click), or a retry might be triggered by an infrastructure blip rather than a genuine second action.
Worked example. A saga for a $50,000 vendor payment reaches step "approve large payment," transitions to awaiting_approval, and creates an approval request visible in an approvals queue with the amount, vendor, and originating order context. Two approvers are eligible; the saga waits. After 24 hours with no response, an escalation job fires: it notifies a backup approver and pages a manager. The manager approves at hour 30; the saga transitions to approved and proceeds to the payment-execution step, using the approval-request ID as part of the idempotency key for that step so a duplicate approval click doesn't trigger two payments.
Trade-offs and pitfalls. A common design mistake is treating the approval step like any other automated step with a short timeout and automatic retry, which either spams the approver with duplicate notifications or, worse, silently drops the request if a retry policy assumes failure after a short window. Human-in-the-loop steps need their OWN timeout scale (hours/days, not seconds) and their own explicit escalation path, not an automated saga's default retry/backoff policy.
Unlock Full Question Bank
Get access to all Data Consistency and Distributed Transactions interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.