Module: Mbeditor::CollaborationDocStore
- Defined in:
- app/services/mbeditor/collaboration_doc_store.rb
Overview
Thread-safe, in-memory cache of opaque Yjs bytes per active file. It never interprets the bytes (no Ruby CRDT): it only buffers the latest snapshot plus recent deltas so a late-joiner can sync instantly. Modeled on the existing TTL caches (GitInfoService, AvailabilityProbe): Mutex + monotonic clock.
Constant Summary collapse
- GRACE_TTL =
Grace window (seconds): a room with no activity for longer than this is evicted by sweep!. Because every op refreshes last_activity, a room that just went empty survives this long, so a quick reopen recovers its buffer.
300- ROOM_CAP =
Hard cap on cached rooms. Exceeding it evicts the least-recently-active room, so process memory stays bounded even if rooms are never swept.
200- SWEEP_INTERVAL =
Idle GC rides on traffic: every write/read attempts a sweep, but the scan runs at most once per this interval so a busy room doesn't pay for it on every op. With no explicit scheduler, memory stays bounded over a long session as long as some activity continues. Kept well under GRACE_TTL so an idle room is reclaimed soon after its grace window elapses.
60- MAX_DELTAS =
Hard cap on buffered deltas per room; a long editing session on one file would otherwise grow it without limit. Over the cap the oldest go, which costs a late joiner an incomplete replay — it then waits for the next snapshot instead of syncing from the buffer.
500
Class Method Summary collapse
-
.claim_seed(path, now: monotonic) ⇒ Object
Grant the right to seed a room's shared document from disk, to exactly one client.
-
.join(path, now: monotonic) ⇒ Object
Subscriber accounting.
- .leave(path, now: monotonic) ⇒ Object
- .record_update(path, bytes, now: monotonic) ⇒ Object
- .replace_snapshot(path, bytes, now: monotonic) ⇒ Object
- .reset! ⇒ Object
- .state_for(path, now: monotonic) ⇒ Object
- .sweep!(now: monotonic, grace: GRACE_TTL) ⇒ Object
Class Method Details
.claim_seed(path, now: monotonic) ⇒ Object
Grant the right to seed a room's shared document from disk, to exactly one client. Without this every client that finds the room empty inserts the whole file at offset 0, and Yjs merges those inserts into two concatenated copies — the file appended to itself. That happens whenever two editor tabs attach to a room the server has no state for: a fresh room, or one that was evicted while both were idle. The claim is per room, so the loser waits for the winner's content instead of inventing its own.
43 44 45 46 47 48 49 50 |
# File 'app/services/mbeditor/collaboration_doc_store.rb', line 43 def claim_seed(path, now: monotonic) MUTEX.synchronize do room = touch(path, now) next false if room[:seed_claimed] || room[:snapshot] || !room[:deltas].empty? room[:seed_claimed] = true end end |
.join(path, now: monotonic) ⇒ Object
Subscriber accounting. A room with a live subscriber must never be evicted: eviction empties the server's copy while clients still hold content, and the next client to attach would be granted a seed it must not perform.
55 56 57 58 |
# File 'app/services/mbeditor/collaboration_doc_store.rb', line 55 def join(path, now: monotonic) MUTEX.synchronize { touch(path, now)[:subscribers] += 1 } nil end |
.leave(path, now: monotonic) ⇒ Object
60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'app/services/mbeditor/collaboration_doc_store.rb', line 60 def leave(path, now: monotonic) MUTEX.synchronize do room = rooms[path] next unless room room[:subscribers] -= 1 if room[:subscribers] > 0 # The claimer can leave before it ever seeds (a tab closed during the # handshake). Releasing the claim on an empty, empty-handed room keeps the # next opener from deferring to content that will never arrive. room[:seed_claimed] = false if room[:subscribers].zero? && room[:snapshot].nil? && room[:deltas].empty? end nil end |
.record_update(path, bytes, now: monotonic) ⇒ Object
74 75 76 77 78 79 80 81 |
# File 'app/services/mbeditor/collaboration_doc_store.rb', line 74 def record_update(path, bytes, now: monotonic) MUTEX.synchronize do room = touch(path, now) room[:deltas] << bytes room[:deltas].shift while room[:deltas].size > MAX_DELTAS end nil end |
.replace_snapshot(path, bytes, now: monotonic) ⇒ Object
83 84 85 86 87 88 89 90 |
# File 'app/services/mbeditor/collaboration_doc_store.rb', line 83 def replace_snapshot(path, bytes, now: monotonic) MUTEX.synchronize do room = touch(path, now) room[:snapshot] = bytes room[:deltas] = [] end nil end |
.reset! ⇒ Object
111 112 113 114 115 116 117 |
# File 'app/services/mbeditor/collaboration_doc_store.rb', line 111 def reset! MUTEX.synchronize do @rooms = {} @last_sweep = nil end nil end |
.state_for(path, now: monotonic) ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'app/services/mbeditor/collaboration_doc_store.rb', line 92 def state_for(path, now: monotonic) MUTEX.synchronize do room = rooms[path] if room room[:last_activity] = now result = { snapshot: room[:snapshot], deltas: room[:deltas].dup } else result = { snapshot: nil, deltas: [] } end maybe_sweep(now) result end end |
.sweep!(now: monotonic, grace: GRACE_TTL) ⇒ Object
106 107 108 109 |
# File 'app/services/mbeditor/collaboration_doc_store.rb', line 106 def sweep!(now: monotonic, grace: GRACE_TTL) MUTEX.synchronize { evict_idle(now, grace) } nil end |