Class: Y::Document

Inherits:
ActiveRecord::Base
  • Object
show all
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

Instance Method Summary collapse

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_stateObject

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