๐ฌ Saturday Deep Dive: Operational Transformation & CRDTs โ ๅๅ็ผ่พๅจ็ๅบๅฑๅผๆ (18 min read)
๐ NeetCode: 50/150 ยท SysDesign: 49/40 ยท Behavioral: 49/40 ยท Frontend: 37/50 ยท AI: 24/30
๐ฅ 60-day streak!
Overview / ๆฆ่ฟฐ
ไธบไปไน่ฟไธช่ฏ้ขๅผๅพ่ฑ 18 ๅ้๏ผ
ไฝ ๆฏๆฌกๅจ Google Docs ้ๅๅไบๅๆถ็ผ่พไธไธชๆๆกฃ๏ผ่ๅ้ฝๆไธๅฅ็ฒพๅฆ็็ฎๆณๅจไฟ่ฏๅคงๅฎถ็ๅฐ็ๅ ๅฎนไธ่ดใ่ฟๅฅ็ฎๆณไธๆฏ้๏ผๅคชๆ ข๏ผ๏ผไธๆฏ"ๆๅๅๅ ฅ่ ่ตข"๏ผๆฐๆฎไธขๅคฑ๏ผ๏ผ่ๆฏไธ็งๅซๅๆไฝๅๆข๏ผOperational Transformation, OT๏ผๆๆ ๅฒ็ชๅคๅถๆฐๆฎ็ฑปๅ๏ผCRDT๏ผ็ๆๆฏใ
่ฟไธค่ ๆฏๅๅ็ผ่พ้ขๅ็ๆ ธๅฟๆๆฏ๏ผไนๆฏ็ณป็ป่ฎพ่ฎก้ข่ฏ้ๆๅผๅทฎ่ท็ๅ ณ้ฎ็ฅ่ฏ็นใๅคงๅคๆฐๅ้ไบบ่ฝ็ปๅบ WebSocket + ๅ็ซฏ + ๆฐๆฎๅบ็ๆถๆๅพ๏ผไฝๆๅฐๆไบบ่ฝ่งฃ้ๅฒ็ชๆฏๆไน่งฃๅณ็ใ
Why this topic is worth 18 minutes:
When you and a colleague edit a Google Doc simultaneously, a sophisticated algorithm silently resolves conflicts in real time. It's not a lock (too slow), not "last write wins" (data loss), but rather Operational Transformation (OT) or Conflict-free Replicated Data Types (CRDTs). These are what separate good system design answers from great ones.
Part 1: Theory / ็่ฎบๅบ็ก (5 min)
ๆ ธๅฟ้ฎ้ข๏ผๅนถๅๅฒ็ช
ๆณ่ฑกไธคไธช็จๆทๅๆถ็ผ่พๅไธไธชๆๆกฃ๏ผๅๅงๅ
ๅฎนๆฏ "abc"๏ผ
่ฟๅฐฑๆฏ OT๏ผๆไฝๅๆข๏ผ่ฆ่งฃๅณ็้ฎ้ข๏ผๅฆไฝ่ฐๆดไธไธชๆไฝ๏ผไฝฟๅฎๅจๅฆไธไธชๅนถๅๆไฝๅทฒ็ปๆง่กไนๅไป็ถ่ฝไบง็ๆญฃ็กฎ็็ปๆใ
The Core Problem: Concurrent Conflicts
ไธค็ง่งฃๆณๅฏนๆฏ / Two Approaches
| ็ปดๅบฆ | OT (Operational Transformation) | CRDT |
|---|---|---|
| ๅๅฒ | 1989ๅนด๏ผGoogle Docs ็จ็ | 2006ๅนด่ตท๏ผFigmaใNotionใLinear ่ฝฌๅ |
| ๆ ธๅฟๆ่ทฏ | ๅจๆๅกๅจ็ซฏๅๆขๆไฝๅบๅ | ๆฐๆฎ็ปๆๆฌ่บซไฟ่ฏๆถๆ |
| ้่ฆไธญๅฟๆๅกๅจ | ๆฏ๏ผๆไฝๅๆข้ป่พ้ไธญๅจๆๅก็ซฏ๏ผ | ๅฆ๏ผP2P ๅฏ็จ๏ผ็ฆป็บฟไผๅ ๏ผ |
| ๅฎ็ฐๅคๆๅบฆ | ้ซ๏ผOT correctness ๅพ้พ่ฏๆ๏ผ | ไธญ้ซ๏ผไธๅ CRDT ็ฑปๅไธๅ๏ผ |
| ไปฃ่กจไบงๅ | Google Docs, Etherpad | Figma, Apple Notes, Automerge |
OT ็ไธคไธชๆ ธๅฟๆง่ดจ / OT's Two Core Properties
CRDT ็ๆ ธๅฟๆๆณ / CRDT Core Idea
CRDT ไธๆฏๅๆขๆไฝ๏ผ่ๆฏ่ฎพ่ฎกไธ็ง็นๆฎ็ๆฐๆฎ็ปๆ๏ผไฝฟๅพไปปๆ้กบๅบ็ๅๅนถ้ฝ่ฝไบง็็ธๅ็ปๆใ
ๅ ณ้ฎๆง่ดจ๏ผSemilattice๏ผ๏ผ
- ็ปๅๅพ๏ผ
merge(a, merge(b, c)) = merge(merge(a, b), c) - ไบคๆขๅพ๏ผ
merge(a, b) = merge(b, a) - ๅน็ญๆง๏ผ
merge(a, a) = a
ๆปก่ถณ่ฟไธๆก๏ผๅฐฑ่ฝไฟ่ฏๆ็ปไธ่ดๆง๏ผไธ้่ฆไธญๅฟๅ่ฐ่ ใ
CRDT doesn't transform operations โ it designs data structures where any order of merges produces the same result. The three laws above guarantee eventual consistency without any central coordinator.
Part 2: Step-by-Step Implementation / ไธๆญฅไธๆญฅๅฎ็ฐ (8 min)
ๆนๆกไธ๏ผOT ๅฎ็ฐๆๆฌๅๅ / OT Implementation
from dataclasses import dataclass
from enum import Enum
from typing import Optional
class OpType(Enum):
INSERT = "insert"
DELETE = "delete"
RETAIN = "retain" # skip N characters unchanged
@dataclass
class Operation:
"""
Represents a single atomic edit operation.
Uses the 'retain/insert/delete' triple common in OT libraries (like OT.js / ShareDB).
"""
type: OpType
pos: int # position in the ORIGINAL document (before this op)
char: Optional[str] = None # only for INSERT
length: int = 1 # for DELETE or RETAIN
def transform(op_a: Operation, op_b: Operation, priority: str = "left") -> Operation:
"""
Transform op_a against op_b.
priority="left" means op_a wins on tie (same position insert).
This implements the INSERT/DELETE x INSERT/DELETE matrix.
"""
if op_a.type == OpType.INSERT and op_b.type == OpType.INSERT:
# Both insert at same position: priority breaks tie
if op_b.pos < op_a.pos or (op_b.pos == op_a.pos and priority == "right"):
# op_b inserts before (or at same pos with lower priority)
# โ op_a's position shifts right by 1
return Operation(OpType.INSERT, op_a.pos + 1, op_a.char)
return op_a # op_b is at or after op_a, no shift needed
elif op_a.type == OpType.INSERT and op_b.type == OpType.DELETE:
if op_b.pos < op_a.pos:
# op_b deleted something before our insert position
# โ our position shifts left by 1
return Operation(OpType.INSERT, op_a.pos - 1, op_a.char)
return op_a # deletion was at or after our position, no shift
elif op_a.type == OpType.DELETE and op_b.type == OpType.INSERT:
if op_b.pos <= op_a.pos:
# op_b inserted before our deletion target
# โ our target shifts right by 1
return Operation(OpType.DELETE, op_a.pos + 1)
return op_a
elif op_a.type == OpType.DELETE and op_b.type == OpType.DELETE:
if op_b.pos < op_a.pos:
# op_b deleted something before our target โ shift left
return Operation(OpType.DELETE, op_a.pos - 1)
elif op_b.pos == op_a.pos:
# Both deleting the same character! op_a becomes a no-op.
# We return a RETAIN(0) to signal "already done"
return Operation(OpType.RETAIN, op_a.pos, length=0)
return op_a
class OTDocument:
"""
Simple OT-based collaborative document (server-side coordinator).
"""
def __init__(self):
self.text = ""
self.history: list[Operation] = [] # all committed ops in order
def apply(self, op: Operation) -> str:
"""Apply an operation to the document text."""
if op.type == OpType.INSERT:
self.text = self.text[:op.pos] + op.char + self.text[op.pos:]
elif op.type == OpType.DELETE:
if op.length > 0: # skip no-op deletes
self.text = self.text[:op.pos] + self.text[op.pos + 1:]
return self.text
def receive(self, op: Operation, client_version: int) -> Operation:
"""
Server receives an op from a client.
client_version = how many ops the client has seen (its 'parent' revision).
We must transform op against all ops since client_version.
"""
# Transform op against every concurrent op the client hasn't seen yet
concurrent_ops = self.history[client_version:]
transformed_op = op
for server_op in concurrent_ops:
transformed_op = transform(transformed_op, server_op)
self.apply(transformed_op)
self.history.append(transformed_op)
return transformed_op # broadcast this to all other clients
# โโโ Demo: The classic conflict scenario โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
doc = OTDocument()
doc.text = "abc"
doc.history = []
# User A: insert "X" at position 0
op_a = Operation(OpType.INSERT, pos=0, char="X")
# User B: delete 'c' at position 2
op_b = Operation(OpType.DELETE, pos=2)
# A's op arrives first at version 0 โ applied directly
doc.receive(op_a, client_version=0)
print(f"After A: '{doc.text}'") # "Xabc"
# B's op arrives (still thinks it's at version 0, before A)
# server transforms B against A's insert
doc.receive(op_b, client_version=0)
print(f"After B: '{doc.text}'") # "Xab" โ 'c' correctly deleted โ
ๆนๆกไบ๏ผCRDT ๅฎ็ฐ โ Logoot / RGA ๆ่ทฏ / CRDT Approach
import uuid
from dataclasses import dataclass, field
from typing import Optional
@dataclass(order=True)
class Identifier:
"""
Every character in a CRDT document gets a globally unique,
totally-ordered identifier. No two identifiers are equal.
This is the key insight: positions become stable IDs, not integers.
When A inserts between IDs 10 and 20, it picks ID 15.
No matter what B does concurrently, character 15 stays where it is.
"""
pos: float # fractional position for ordering (Logoot-style)
site_id: str # which client created this (for tiebreaking)
counter: int # lamport clock to ensure uniqueness
def __eq__(self, other):
return self.pos == other.pos and self.site_id == other.site_id
@dataclass
class CRDTChar:
"""A single character in the CRDT sequence."""
id: Identifier
char: str
deleted: bool = False # tombstone โ deleted chars stay in the structure
class RGADocument:
"""
Replicated Growable Array (RGA) โ a popular CRDT for text.
Key properties:
- Each char has a unique ID (site_id + lamport clock)
- Causality: an insert references its left neighbor's ID
- Delete = tombstone (never physically remove, just mark)
- Merge: union of all char sets, order by ID
"""
def __init__(self, site_id: str):
self.site_id = site_id
self.clock = 0
# chars sorted by their stable IDs
self._chars: list[CRDTChar] = []
def _new_id(self, left_pos: float, right_pos: float) -> Identifier:
"""
Generate a new identifier that falls between left and right positions.
Uses fractional indexing โ the classic "pick a number between 10 and 20".
"""
self.clock += 1
mid_pos = (left_pos + right_pos) / 2
return Identifier(pos=mid_pos, site_id=self.site_id, counter=self.clock)
def local_insert(self, char: str, after_index: int) -> CRDTChar:
"""
Insert a character after the visible character at after_index.
Returns the new CRDTChar (to be broadcast to peers).
"""
# Find the fractional position boundaries
left_pos = self._chars[after_index].id.pos if after_index >= 0 else 0.0
right_pos = (
self._chars[after_index + 1].id.pos
if after_index + 1 < len(self._chars)
else 1.0
)
new_id = self._new_id(left_pos, right_pos)
new_char = CRDTChar(id=new_id, char=char)
# Insert in sorted position
insert_at = after_index + 1
self._chars.insert(insert_at, new_char)
return new_char
def local_delete(self, index: int) -> CRDTChar:
"""
Delete the visible character at index.
Tombstone only โ the char stays in _chars forever.
"""
# Find the nth visible (non-deleted) char
count = 0
for c in self._chars:
if not c.deleted:
if count == index:
c.deleted = True
return c # broadcast this tombstone
count += 1
raise IndexError("No visible character at that index")
def apply_remote(self, op: CRDTChar):
"""
Merge a remote operation (insert or delete) into our local state.
This is the magic: it's commutative, associative, idempotent.
"""
if op.deleted:
# Remote delete: find and tombstone our local copy
for c in self._chars:
if c.id == op.id:
c.deleted = True
return
else:
# Remote insert: find the right sorted position by ID
for i, c in enumerate(self._chars):
if c.id > op.id:
self._chars.insert(i, op)
return
self._chars.append(op)
@property
def text(self) -> str:
"""Return the visible (non-tombstoned) text."""
return "".join(c.char for c in self._chars if not c.deleted)
# โโโ Demo: Same conflict, CRDT way โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
doc_a = RGADocument(site_id="A")
doc_b = RGADocument(site_id="B")
# Both start with "abc" โ in a real system, initial state is synced
# (simplified: skip initial state sync for clarity)
# The point: concurrent operations just get merged, no transform needed.
# doc_a.local_insert("X", 0) โ broadcasts CRDTChar with ID pos~0.5
# doc_b.local_delete(2) โ broadcasts tombstone for 'c'
# Both sides call apply_remote() with the other's op
# Result is always identical regardless of order โ
็ณป็ปๆถๆ / Full System Architecture
ๆถๆ่ฏทๆฑๆต / Request Trace:
Part 3: Edge Cases & Gotchas / ่พน็ๆ ๅต (2 min)
1. Tombstone ็งฏ็ดฏ / Tombstone Accumulation (CRDT)
# ้ฎ้ข๏ผๅคง้ๅ ้คๅ๏ผ_chars ่ถๆฅ่ถๅคง
# "Hello World" typed then deleted โ 11 tombstones remain in memory
#
# ่งฃๆณ๏ผๅฎๆ Compaction๏ผ้่ฆๅ
จๅฑๅ่ฐ๏ผ
# ๅชๆๅฝๆๆๅฎขๆท็ซฏ้ฝ >= ๆไธช็ๆฌๆถ๏ผๆ่ฝๅฎๅ
จๆธ
้ค tombstone
# Google Docs ้่ฟๅฎๆ snapshot + log replay ่งฃๅณ่ฟไธช้ฎ้ข
2. OT ็ TP2 ้ฎ้ข / OT's TP2 Property
Jupiter protocol (Google Docs) uses a simpler "server as single source of truth" model that only requires TP1 (inclusion transformation). TP2 (handling three concurrent ops) is notoriously hard to implement correctly โ that's why peer-to-peer OT was abandoned.
3. ็ฝ็ปๅๅบ / Network Partition
CRDT wins here: offline edits accumulate as local ops.
On reconnect, just apply_remote() all the remote ops.
No conflict โ the math guarantees convergence.
OT requires the server version number, so offline OT is tricky.
Google Docs handles this by queuing ops locally and replaying on reconnect.
4. ๆๅพไฟ็ / Intention Preservation
# ็จๆทๆๅพ๏ผ"ๅ ้ค็ฌฌไบไธช่ฏ"
# ๅนถๅๆไฝๅฏ่ฝๅจๅ ้คไนๅๆๅ
ฅไบไธๅ ๅ
ๅฎน
# OT ๅๆขๅๅ ้คไบ"ๆญฃ็กฎไฝ็ฝฎ"็ๅญ็ฌฆ๏ผไฝๅฏ่ฝไธๆฏ็จๆทๆณๅ ็้ฃไธช่ฏ
#
# ่ฟๆฏ OT ๆๆทฑ็ๅฒๅญฆ้ฎ้ข๏ผๆฒกๆๅฎ็พ่งฃๆณ
# ๅฎ่ทตไธญ๏ผ็จๅ
ๆ heuristics + ็จๆท undo ๆฅ็ผ่งฃ
Part 4: Real-World Application / ๅฎ้ ๅบ็จ (2 min)
่ฐ็จไปไน / Who Uses What
| ไบงๅ | ๆๆฏ | ๅๅ |
|---|---|---|
| Google Docs | OT (Jupiter Protocol) | 2006ๅนด่ฎพ่ฎก๏ผOTๆ็ๆถๆ |
| Figma | CRDT | ่ฎพ่ฎกๅทฅๅ ท็ๆไฝๅคฉ็ถ้ๅ CRDT๏ผ็งปๅจใ้ข่ฒ็ญ๏ผ |
| Notion | CRDT (Yjs) | ็ฆป็บฟไผๅ ๏ผๅ็ปๆ้ๅ CRDT |
| Linear | CRDT | ็ฐไปฃๆถๆ๏ผP2P ๅๆญฅ |
| VS Code Live Share | OT | ไปฃ็ ็ผ่พๅจ๏ผๆๅก็ซฏๅ่ฐๆดๅฏๆง |
| CouchDB | CRDT-style MVCC | ๆๆกฃ็บงๅซๅๅนถ |
้ข่ฏๆถๅฆไฝ่ฐ่ฎบ / How to Talk About This in Interviews
"For a collaborative editor, the hardest problem isn't transport โ it's conflict resolution. I'd choose OT if we need a central server anyway (simpler ops, proven at scale by Google), or CRDT if we want offline-first or P2P capability. Either way, the client applies ops locally immediately for responsiveness, and reconciles with the server in the background."
Part 5: Interview Simulation / ้ข่ฏๆจกๆ (3 min)
Q1: ๅฆๆไธคไธช็จๆทๅๆถๅจๅไธไฝ็ฝฎๆๅ ฅๅญ็ฌฆ๏ผ่ฐ็ๅญ็ฌฆๅบ่ฏฅๆๅจๅ้ข๏ผ
A1: This is the "tie-breaking" problem. In OT, server assigns a timestamp or priority based on connection order โ whichever op the server receives first wins. In CRDT, each site has a unique ID used for deterministic tiebreaking (e.g., site_id lexicographic order). Neither is "correct" UX-wise; it's a deliberate consistency choice. Some systems let the cursor position communicate intent.
Q2: ไธบไปไน Google Docs ไธ็จ CRDT๏ผ
A2: Google Docs predates modern CRDT research (Jupiter Protocol is from 1995/2006). Also, for a centralized product, OT's server-centric model is simpler to reason about for correctness. CRDTs have their own complexity (tombstone management, fractional indexing precision). Google has no strong reason to migrate a system that works at massive scale.
Q3: ๅฆไฝๅค็็จๆท็ฆป็บฟ็ผ่พๅ้ๆฐไธ็บฟ๏ผ
A3:
- OT approach: Client stores ops with the last-known server version. On reconnect, sends all queued ops with their original version. Server replays the transform chain. Risky if offline too long (version gap too large) โ some systems cap offline time.
- CRDT approach: Client accumulates CRDTChar objects locally. On reconnect, exchange ops with server (gossip or vector clock-based sync). apply_remote() handles everything โ guaranteed convergence regardless of offline duration.
Q4: ๅๅ็ผ่พ็ณป็ปๅฆไฝๅ Undo/Redo๏ผ
A4: This is where things get tricky. Local undo in a collaborative context can "undo" someone else's concurrent work accidentally. Modern systems implement selective undo: undo only your own operations, using Exclusion Transformation (ET) to "remove" your past op from the history without affecting others'. Some systems (Figma) implement per-user undo stacks tracked on the server.
Q5: ็ณป็ป่ฎพ่ฎก้ข่ฏไธญ๏ผๅฆไฝๅจ OT ๅ CRDT ไน้ด้ๆฉ๏ผ
A5: Ask these questions:
- Need offline-first / P2P? โ CRDT
- Central server anyway, proven ops needed? โ OT
- Rich text with complex formatting? โ Yjs (CRDT library) handles this well
- Simple shared state (not text)? โ CRDT variants like G-Counter, LWW-Register
- Scale? โ Both scale; OT's server bottleneck is the bigger concern at extreme scale
In an interview, showing you know both and can articulate the tradeoffs is more impressive than picking one confidently.
Further Reading / ๅปถไผธ้ ่ฏป: