Class: Y::Document
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- Y::Document
- Defined in:
- app/models/y/document.rb
Overview
One row per collaborative document, addressed two ways:
key what a channel addresses: one opaque, unique string.
Apps can supply their own ("room-42"), so nothing
parses meaning out of a key.
record + name which model attribute the document backs, where name
is the attribute name ("body") — optional, one
document per attribute per record, the same scheme as
ActionText::RichText.
When a binding exists and no key was supplied, the key derives as
post/1/body. Either side can arrive first — a channel can write under a
key before any binding exists — so .for adopts a key-only row whose
key matches the derived one, converging both on one row.
state holds the merged snapshot; the update rows are the uncompacted
tail, so a load reads the snapshot plus whatever the tail currently
holds. The models store CRDT state only: derived data (rendered HTML,
search text) is the application's job, typically done in the channel's
on_change.
Class Method Summary collapse
- .append(key, update) ⇒ Object
-
.for(record, name) ⇒ Object
The document bound to a record's attribute, created on first use.
-
.load_state(key) ⇒ Object
The store contract for a sync channel, keyed by the transport key.
- .locate(key) ⇒ Object
- .locate!(key) ⇒ Object
Instance Method Summary collapse
-
#append(bytes) ⇒ Object
Record one delta.
-
#compact! ⇒ Object
Compact the tail into state.
-
#load_state ⇒ Object
The merged document: state plus the whole tail.
Class Method Details
.append(key, update) ⇒ Object
80 81 82 |
# File 'app/models/y/document.rb', line 80 def append(key, update) (select(:id).find_by(key: key) || create_or_find_by!(key: key)).append(update) end |
.for(record, name) ⇒ Object
The document bound to a record's attribute, created on first use. Find first (after the first call, every call is a read), then adopt: if a channel already appended under the key this binding derives, a key-only row exists whose key is taken — claiming it converges the two identities where a plain insert would collide on the key index.
The insert can still lose a race it can't see: a channel creates the key-only row after adopt looked and before the insert lands, so create_or_find_by! collides on the key index — and its internal retry, which looks up by record + name, misses the key-only row and raises RecordNotFound. One more pass adopts the row that won.
61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'app/models/y/document.rb', line 61 def for(record, name) attempts = 0 begin find_by(record: record, name: name.to_s) || adopt(record, name) || create_or_find_by!(record: record, name: name.to_s) rescue ActiveRecord::RecordNotFound raise if (attempts += 1) > 1 retry end end |
.load_state(key) ⇒ Object
The store contract for a sync channel, keyed by the transport key. Both skip the state blob (select(:id)): append never reads it, and load_state re-reads it fresh after the tail (see below), so neither should drag a potentially large snapshot over the wire per call.
78 |
# File 'app/models/y/document.rb', line 78 def load_state(key) = select(:id).find_by(key: key)&.load_state |
.locate(key) ⇒ Object
44 |
# File 'app/models/y/document.rb', line 44 def locate(key) = find_by(key: key) |
.locate!(key) ⇒ Object
46 47 48 |
# File 'app/models/y/document.rb', line 46 def locate!(key) find_by(key: key) || create_or_find_by!(key: key) end |
Instance Method Details
#append(bytes) ⇒ Object
Record one delta. The trigger is at-or-over rather than an exact multiple (concurrent appends can jump past one) and counts only clean rows: pending rows never satisfy it, so a quarantined gap doesn't retrigger compaction on every append.
106 107 108 109 |
# File 'app/models/y/document.rb', line 106 def append(bytes) updates.create!(payload: bytes) compact! if updates.where(pending: false).count >= compact_every end |
#compact! ⇒ Object
Compact the tail into state. The row lock serializes racing
compactions; a delta landing mid-compaction isn't in rows, so it
survives the delete and compacts next time.
A causally-gapped batch is never compacted whole and never deleted: state would silently exclude the gap, destroying the only healable copy. If the clean rows alone merge gap-free, they compact and only the gap is quarantined (marked pending); rows that causally build on quarantined content quarantine with it.
141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'app/models/y/document.rb', line 141 def compact! with_lock do rows = updates.pluck(:id, :payload, :pending) next if rows.empty? unless compact_rows(rows) clean = rows.reject { |_, _, pending| pending } remainder = compact_rows(clean) ? rows - clean : rows updates.where(id: remainder.map(&:first)).update_all(pending: true) end end end |
#load_state ⇒ Object
The merged document: state plus the whole tail. The tail is read first and the snapshot re-read after it, both straight from the database: a compaction committing between the two reads then hands us rows already folded into the fresh snapshot — an idempotent double-apply — where the reverse order could pair a pre-compaction snapshot with an empty tail and omit committed changes. Quarantined rows are applied too — the output goes through compacted_state_update, which is gap-free by construction, so an unhealed gap contributes nothing while a gap healed by a newer tail row is served immediately instead of waiting for the next compaction.
121 122 123 124 125 126 127 128 129 130 |
# File 'app/models/y/document.rb', line 121 def load_state tail = Y::DocumentUpdate.where(document_id: id).pluck(:payload) snapshot = self.class.where(id: id).pick(:state) return snapshot if tail.empty? doc = Y::Doc.new doc.apply_update(snapshot) if snapshot tail.each { |payload| doc.apply_update(payload) } doc.compacted_state_update end |