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.
Direct Known Subclasses
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 gapped batch still compacts everything integrable: the fold's compacted_state_update captures every struct that integrates, so rows independent of the gap land in state no matter how they interleave with it. Only the gap tail survives as raw rows, judged per row against the folded state: a row the new state could not integrate cleanly carries the gap (or builds on it) and is quarantined (marked pending); a row that is ready and adds nothing is fully captured and deleted. An acked update never leaves the table before its content is durably in state.
145 146 147 148 149 150 151 152 153 |
# File 'app/models/y/document.rb', line 145 def compact! with_lock do rows = updates.pluck(:id, :payload, :pending) next if rows.empty? next if compact_rows(rows) compact_around_gap(rows) 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, and the output is lossless (encode_state_as_update): an unhealed gap rides along as a pending struct, so a peer loaded mid-gap holds the parked edit and heals it the moment the missing dependency arrives, while a gap healed by a newer tail row is served merged immediately.
121 122 123 124 125 126 127 128 129 130 |
# File 'app/models/y/document.rb', line 121 def load_state tail = updates.reset.pluck(:payload) # reset: never a cached tail 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.encode_state_as_update end |