|
| 1 | +# Real-Time Collaborative Editing Algorithm |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +This CRDT implementation uses **YATA (Yet Another Transformation Approach)** for text editing with **Peritext** for rich text formatting. It guarantees **strong eventual consistency**: all peers converge to identical states regardless of network conditions or operation ordering. |
| 6 | + |
| 7 | +**Algorithm Stack:** |
| 8 | +- **YATA** (from Yjs) - Position resolution with originRight tracking |
| 9 | +- **RGA** (Replicated Growable Array) - Ordered sequence management |
| 10 | +- **Peritext** - Rich text formatting with anchor-based marks |
| 11 | +- **Lamport timestamps** - Causal ordering |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## Core Data Structure |
| 16 | + |
| 17 | +Each character is a node with immutable causal references: |
| 18 | + |
| 19 | +```javascript |
| 20 | +{ |
| 21 | + opId: "5@alice", // Unique ID: counter@userId |
| 22 | + char: "X", // The character |
| 23 | + afterId: "4@alice", // originLeft: what was left at insertion |
| 24 | + originRight: "6@bob", // originRight: what was right at insertion (YATA) |
| 25 | + rightId: "7@alice", // Actual right neighbor (mutable, maintained by YATA) |
| 26 | + deleted: false, // Tombstone flag |
| 27 | + userId: "alice", // Creator |
| 28 | + counter: 5 // Lamport counter (logical time) |
| 29 | +} |
| 30 | +``` |
| 31 | + |
| 32 | +**Key insight:** `afterId` and `originRight` are immutable snapshots of insertion context, while `rightId` is dynamically maintained to reflect the actual sequence. |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## YATA Insertion Algorithm |
| 37 | + |
| 38 | +**Goal:** Insert character C with `afterId=P` and `originRight=R` into the sequence. |
| 39 | + |
| 40 | +``` |
| 41 | +1. Start scanning right from position P |
| 42 | +2. For each node N encountered: |
| 43 | + a. If N.opId == R → Insert BEFORE N (originRight precedence) |
| 44 | + b. If N.afterId == P (sibling): |
| 45 | + - If counter(C) < counter(N) → Insert BEFORE N |
| 46 | + - If counter(C) == counter(N) → Use userId tie-breaking |
| 47 | + - Else → Skip N and scan its descendants |
| 48 | + c. If N.afterId != P → Stop (left sibling region) |
| 49 | +3. Insert C at determined position |
| 50 | +``` |
| 51 | + |
| 52 | +**YATA extends RGA:** While RGA only uses `afterId`, YATA adds `originRight` to preserve "insert between" semantics, solving the position-0 insertion problem. |
| 53 | + |
| 54 | +--- |
| 55 | + |
| 56 | +## Convergence Guarantee |
| 57 | + |
| 58 | +### Why All Peers Reach the Same State |
| 59 | + |
| 60 | +**1. Total Ordering via Deterministic Rules** |
| 61 | + |
| 62 | +For any set of concurrent insertions at the same position: |
| 63 | + |
| 64 | +```javascript |
| 65 | +Priority 1: originRight match → Insert before originRight |
| 66 | +Priority 2: Lamport counter → Earlier counter inserts first |
| 67 | +Priority 3: UserId lexicographic → Deterministic tie-breaking |
| 68 | +``` |
| 69 | + |
| 70 | +All peers apply identical rules → identical ordering. |
| 71 | + |
| 72 | +**2. Pure Function Property** |
| 73 | + |
| 74 | +``` |
| 75 | +position = f(node.afterId, node.originRight, node.counter, node.userId, currentSequence) |
| 76 | +``` |
| 77 | + |
| 78 | +The insertion position depends **only** on operation metadata and sequence state, not on: |
| 79 | +- Wall-clock timestamps |
| 80 | +- Network arrival order |
| 81 | +- Local peer state |
| 82 | + |
| 83 | +**3. Commutativity** |
| 84 | + |
| 85 | +``` |
| 86 | +insert(insert(seq, OpA), OpB) ≡ insert(insert(seq, OpB), OpA) |
| 87 | +``` |
| 88 | + |
| 89 | +Because ordering is deterministic, application order doesn't affect final state. |
| 90 | + |
| 91 | +**4. Causality via Lamport Counters** |
| 92 | + |
| 93 | +When receiving operation with `counter=C`: |
| 94 | +```javascript |
| 95 | +localCounter = max(localCounter, C) |
| 96 | +``` |
| 97 | + |
| 98 | +This ensures: |
| 99 | +- If A happened-before B → counter(A) < counter(B) |
| 100 | +- Causal relationships are preserved across all replicas |
| 101 | + |
| 102 | +**5. Idempotence via Deduplication** |
| 103 | + |
| 104 | +```javascript |
| 105 | +operationId = hash(operation) |
| 106 | +if (appliedOperations.has(operationId)) return; // Skip duplicate |
| 107 | +``` |
| 108 | + |
| 109 | +Duplicate operations are automatically ignored. |
| 110 | + |
| 111 | +**6. Causal Delivery via Buffering** |
| 112 | + |
| 113 | +Operations with missing dependencies are buffered: |
| 114 | + |
| 115 | +```javascript |
| 116 | +if (!characters.has(op.leftId)) { |
| 117 | + pendingOperations.push(op); // Buffer until dependency arrives |
| 118 | + return; |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +Once dependency arrives, buffered operations are applied in causal order. |
| 123 | + |
| 124 | +--- |
| 125 | + |
| 126 | +## Example: Concurrent Insertion Resolution |
| 127 | + |
| 128 | +``` |
| 129 | +Initial state: "AB" |
| 130 | +- A: { opId: "1@user1", afterId: root, counter: 1 } |
| 131 | +- B: { opId: "2@user1", afterId: "1@user1", counter: 2 } |
| 132 | +
|
| 133 | +Concurrent operations: |
| 134 | +- User1 inserts 'X' between A and B |
| 135 | + → { opId: "3@user1", afterId: "1@user1", originRight: "2@user1", counter: 3 } |
| 136 | +
|
| 137 | +- User2 inserts 'Y' between A and B |
| 138 | + → { opId: "2@user2", afterId: "1@user1", originRight: "2@user1", counter: 2 } |
| 139 | +
|
| 140 | +YATA Resolution: |
| 141 | +1. Both have afterId="1@user1" (siblings of A) |
| 142 | +2. Both have originRight="2@user1" (want to insert before B) |
| 143 | +3. Compare counters: 2 < 3 |
| 144 | +4. Y inserts before X |
| 145 | +
|
| 146 | +Final state on ALL peers: "AYXB" |
| 147 | +``` |
| 148 | + |
| 149 | +**Why deterministic?** |
| 150 | +- Same afterId → recognized as siblings |
| 151 | +- Same originRight → same intent |
| 152 | +- Counter comparison → total ordering |
| 153 | +- All peers execute identical logic → converge |
| 154 | + |
| 155 | +--- |
| 156 | + |
| 157 | +## Deletion: Tombstone Approach |
| 158 | + |
| 159 | +Characters are never removed, only marked: |
| 160 | + |
| 161 | +```javascript |
| 162 | +delete(opId) { |
| 163 | + node.deleted = true; // Preserve node for causal references |
| 164 | +} |
| 165 | + |
| 166 | +getText() { |
| 167 | + return sequence.filter(n => !n.deleted).map(n => n.char).join(''); |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +**Why tombstones?** |
| 172 | +- Future operations may reference deleted characters as `afterId` |
| 173 | +- Formatting marks may anchor on deleted positions |
| 174 | +- Preserves causal history for late-arriving operations |
| 175 | + |
| 176 | +--- |
| 177 | + |
| 178 | +## Rich Text Formatting (Peritext Marks) |
| 179 | + |
| 180 | +Marks use **op-sets** (anchor position tracking): |
| 181 | + |
| 182 | +```javascript |
| 183 | +mark = { |
| 184 | + markId: "5@user1", |
| 185 | + start: { opId: "2@user1", type: "after" }, // Start anchor |
| 186 | + end: { opId: "7@user1", type: "before" }, // End anchor |
| 187 | + markType: "bold", |
| 188 | + attributes: { fontWeight: 700 } |
| 189 | +} |
| 190 | +``` |
| 191 | + |
| 192 | +**Mark inheritance:** When inserting at position P, copy formatting from P's op-set. |
| 193 | + |
| 194 | +**Mark boundaries:** Op-sets at mark endpoints prevent formatting from bleeding to adjacent text. |
| 195 | + |
| 196 | +**Concurrent marks:** Last-write-wins based on Lamport counters in op-sets. |
| 197 | + |
| 198 | +--- |
| 199 | + |
| 200 | +## Network Model |
| 201 | + |
| 202 | +**Assumptions:** |
| 203 | +- Eventually reliable delivery (messages may be delayed, duplicated, or reordered) |
| 204 | +- No Byzantine failures |
| 205 | +- Peer-to-peer or server-mediated topology |
| 206 | + |
| 207 | +**Guarantees:** |
| 208 | +- Operations can arrive in any order → buffering handles dependencies |
| 209 | +- Duplicate operations → deduplication handles |
| 210 | +- Network partitions → eventual consistency after healing |
| 211 | + |
| 212 | +--- |
| 213 | + |
| 214 | +## Key Properties |
| 215 | + |
| 216 | +| Property | Mechanism | Complexity | |
| 217 | +|----------|-----------|------------| |
| 218 | +| **Strong Eventual Consistency** | YATA + deterministic ordering | O(1) after all ops received | |
| 219 | +| **Commutativity** | Pure function insertion | O(k) per insertion* | |
| 220 | +| **Causality Preservation** | Lamport counters + buffering | O(1) counter update | |
| 221 | +| **Conflict-Free** | No conflicts, only deterministic merges | Always succeeds | |
| 222 | +| **Idempotence** | Operation deduplication | O(1) lookup | |
| 223 | +| **Out-of-order Tolerance** | Dependency buffering | O(p) pending ops | |
| 224 | + |
| 225 | +*k = number of concurrent siblings at insertion point (typically 1-3) |
| 226 | + |
| 227 | +--- |
| 228 | + |
| 229 | +## Memory & Performance |
| 230 | + |
| 231 | +**Space complexity:** O(n) where n = total characters inserted (including tombstones) |
| 232 | + |
| 233 | +**Time complexity:** |
| 234 | +- Insert: O(k) where k = siblings with same `afterId` |
| 235 | +- Delete: O(1) tombstone marking |
| 236 | +- getText: O(n) sequence traversal |
| 237 | +- Serialize: O(n + m) where m = number of marks |
| 238 | + |
| 239 | +**Network bandwidth:** ~40-60 bytes per character operation |
| 240 | + |
| 241 | +**Typical case:** O(1) insertion when users type sequentially at different positions |
| 242 | + |
| 243 | +**Worst case:** O(n) when all users insert at the exact same position (rare in practice) |
| 244 | + |
| 245 | +--- |
| 246 | + |
| 247 | +## Comparison with OT (Operational Transformation) |
| 248 | + |
| 249 | +| Aspect | CRDT (This) | OT | |
| 250 | +|--------|-------------|-----| |
| 251 | +| **Convergence** | Guaranteed by math | Requires correct transforms | |
| 252 | +| **Central server** | Optional | Usually required | |
| 253 | +| **Commutative** | Yes | No (order matters) | |
| 254 | +| **Implementation** | Complex data structures | Complex transformation functions | |
| 255 | +| **Peer-to-peer** | Natural | Difficult | |
| 256 | +| **Undo** | Requires inverse ops | Natural | |
| 257 | + |
| 258 | +--- |
| 259 | + |
| 260 | +## References |
| 261 | + |
| 262 | +**Papers:** |
| 263 | +- YATA: "Near Real-Time Peer-to-Peer Shared Editing on Extensible Data Types" (Nicolaescu et al., 2016) |
| 264 | +- RGA: "Replicated abstract data types: Building blocks for collaborative applications" (Roh et al., 2011) |
| 265 | +- Peritext: "Peritext: A CRDT for Collaborative Rich Text Editing" (Litt et al., 2022) |
| 266 | +- CRDTs: "A comprehensive study of Convergent and Commutative Replicated Data Types" (Shapiro et al., 2011) |
| 267 | + |
| 268 | +**Implementations:** |
| 269 | +- Yjs: https://github.com/yjs/yjs |
| 270 | +- Automerge: https://github.com/automerge/automerge |
| 271 | + |
| 272 | +--- |
| 273 | + |
| 274 | +## Testing & Validation |
| 275 | + |
| 276 | +**Tests covering:** |
| 277 | +- Concurrent operations from multiple users |
| 278 | +- Out-of-order delivery and buffering |
| 279 | +- Deterministic ordering verification |
| 280 | +- Serialization/deserialization cycles |
| 281 | +- Convergence properties |
| 282 | +- Integration with WebRTC |
| 283 | + |
| 284 | + |
| 285 | +--- |
| 286 | + |
| 287 | +## Implementation Files |
| 288 | + |
| 289 | +- `peritext-document.js` - Main CRDT logic |
| 290 | +- `yata-sequence.js` - YATA insertion algorithm |
| 291 | +- `crdt-helpers.js` - Utility functions |
| 292 | +- `crdt-serializer.js` - State serialization |
0 commit comments