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.

Direct Known Subclasses

EncryptedDocument

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 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_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, 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