yrby
yrby (pronounced "yer-bee") makes Rails a real Yjs backend. It binds y-crdt, the Rust engine behind Y.js, into Ruby, and builds the rest of the stack around it: a sync server for Action Cable and AnyCable, a browser provider, and server-side reading and rendering of the documents. Real-time collaboration in a Rails app with no Node process anywhere in the path.

On the server, yrby-rails implements the full y-websocket protocol
(document sync plus presence) as a channel concern. Its delivery contract is
stricter than the usual Yjs servers: every update is ack-tracked and durably
recorded before it is acknowledged or broadcast to anyone. Replaying your
store always rebuilds the document, across any number of processes.
(Delivery guarantees)
In the browser, yrby-client's ActionCableProvider connects anything that
speaks Yjs. The demo app runs four rich text editors, and CI drives each one
in real Chrome: Tiptap, Lexxy,
Rhino Editor, and CodeMirror. The same channel also syncs Yjs shapes with no
editor at all: a whiteboard on a Y.Map, a kanban board on a Y.Array, a
co-filled form. (Editors)
In Ruby, the documents are readable without a browser. Doc#read_text and
Doc#read_map reconstruct contents for search, validation, and exports.
Y::Tiptap and Y::Lexxy render a document to HTML byte-identical to the
editor's own serializer, take rules for your app's custom nodes, and drop
straight into ActionText. (Rendering to HTML)
Underneath, the core is built for a production Rails deployment. A Doc is
thread-safe across Puma and ActionCable threads. Native CRDT work runs with
the GVL released, so it parallelizes on MRI. Incoming frames are validated
before anything processes them, and multi-process and AnyCable setups are
tested end to end. (Thread Safety)
The whole server side of a collaborative document is one channel:
class DocumentChannel < ApplicationCable::Channel
include Y::ActionCable
on_load { |key| Y::Document.load_state(key) }
on_change { |key, update| Y::Document.append(key, update) }
def subscribed = sync_subscribed(params[:id])
def receive(data) = sync_receive(data, params[:id])
end
Install the gem and the npm package:
gem install yrby-rails # depends on yrby
npm install yrby-client
Contents
- Scope
- Durability and delivery
- What about yrb?
- Testing
- Install
- Docs
- Editors
- Usage
- Thread Safety
- Message Type Constants
- Sync Flow
- Development
- License
- Acknowledgments
Scope
yrby binds just the part of y-crdt you need to sync and persist collaborative
documents - a Doc, awareness, and the y-websocket protocol primitives. By default
the Ruby side treats a document as opaque CRDT state: it applies updates, answers
sync handshakes, and records deltas without reaching into the contents - the browser
editor owns the document's shape. When you do need to look inside, Doc#read_text
and Doc#read_map reconstruct it server-side, in Ruby.
Durability and delivery
The surface is intentionally small, but the focus is durability, resiliency, delivery guarantees, correctness, and thread safety.
Towards that goal, yrby adds opinionated defaults on top of normal Yjs syncing:
- Built-in update acknowledgement: the
ActionCableProviderinyrby-clientkeeps sending an update until the server acks it, andyrby-railsonly acks once the update is durably recorded. That gives you at-least-once delivery, and because CRDT updates are idempotent a duplicate is a no-op. - Gap awareness: an update can arrive before another update it depends on (a
"causal gap").
yrbyrecords and acks it like any other, and the document heals on its own once the missing update arrives; its sender keeps retransmitting it until it is acked.Doc#pending?and theon_gaphook tell you when a document is waiting on a missing update. (Causal gaps)
What about yrb?
yrb has a much larger interface that gives you most of the Yjs type system -
shared text, arrays, maps, XML - to build and query documents in Ruby. It was a great
inspiration for my use of Yjs in Ruby/Rails, and I originally considered building
on top of it. There are a few reasons I went with yrby instead:
yrbis largely unmaintained. It was built as an experiment for GitLab, and the original author mostly moved onto other projects.- It isn't thread-safe. It segfaults in a threaded environment (such as ActionCable...)
- It's a much larger set of features to maintain, which most people don't need. The vast majority of people manipulate Y.js documents in the browser, not from a server-side language.
Testing
Ruby and Rust unit tests cover the core. CI also runs the npm client tests and a Rails demo smoke slice against the real ActionCable stack. The demo includes heavier local suites for hostile input, crash recovery, multi-browser editing, AnyCable, and load testing. The benchmark note below is from a single laptop. Issues and PRs are welcome.
Install
# Core CRDT + protocol primitives:
gem "yrby"
# For the Rails side (the sync channel, document models, the generator).
# Formerly yrby-actioncable; that name stops at 0.3.1.
gem "yrby-rails"
Requires Ruby 3.4 or newer. The release workflow builds precompiled gems for Ruby 3.4 and 4.0 across the supported Ruby platforms, with native smoke tests on Linux x86_64 and macOS arm64. Installing from a matching platform gem needs no Rust; a source build needs Rust.
To work on the gem itself:
git clone https://github.com/jpcamara/yrby
cd yrby
bundle install
bundle exec rake compile test
The rest of the dev setup, plus the demo, is in CONTRIBUTING.md.
Docs
- The ActionCable concern and a quickstart are below.
examples/actioncable-demo: a runnable Rails + Tiptap app with collaborative cursors, the AnyCable setup, a Postgres store, and the test/load suites.- CHANGELOG.md and CONTRIBUTING.md.
Editors
yrby syncs opaque Yjs updates, so it works with any editor that has a Yjs binding. The demo app runs four, and CI drives each one in real Chrome: concurrent typing with every keystroke accounted for, remote cursors, local-only undo, and byte parity between the server-side renderers and the editor's own serializer. Each page is a working integration to copy from:
| Editor | Yjs binding | Demo code |
|---|---|---|
| Tiptap (v2) | @tiptap/extension-collaboration |
app.js |
| Lexxy (Lexical) | lexxy-realtime |
lexxy.js |
| Rhino Editor (Tiptap 3) | @tiptap/extension-collaboration + -caret |
rhino.js |
| CodeMirror 6 | y-codemirror.next |
codemirror.js |
The demo also syncs plain Yjs shapes with no editor at all (a whiteboard
on a Y.Map, a kanban board on a Y.Array, a co-filled form) over the
same channel. The demo README's "Using this in your own app" section has
the integration recipe, and its NoteMaterializer shows how to render a
document to ActionText server-side with Y::Tiptap or Y::Lexxy.
Usage
Doc (Low-Level Document Sync)
require "y"
# Create docs
doc = Y::Doc.new # random client ID
doc = Y::Doc.new(12345) # specific client ID (used for CRDT identity)
# Encoding
doc.encode_state_vector # => current state vector
doc.encode_state_as_update # => full update (lossless: keeps pending)
doc.encode_state_as_update(sv) # => update diff against state vector
doc.compacted_state_update # => full update, gap-free (excludes pending)
# Applying updates
doc.apply_update(update_bytes) # apply raw V1 update
doc.pending? # => true if holding un-integrable pending structs
doc.update_ready?(update) # => true if update would integrate cleanly (no gap)
doc.update_advances?(update) # => true if update moves integrated state forward
# Sync protocol
doc.sync_step1 # => SyncStep1 message (this doc's state vector)
doc.(data) # => [msg_type, sync_type, response]; answers a
# peer's SyncStep1 with full state (lossless,
# pending included, like Y.js)
Reading document contents
Reconstruct a document server-side (search, exports, emails, SSR) with no Node process:
doc.read_text("prosemirror") # => plain text of a Y.Text root, or nil
doc.read_xml("root") # => text of an XML root, one block per line
doc.read_map("state") # => a Y.Map root as a JSON string; JSON.parse it
Pending structs and gap-free state
If a doc applies an update whose causally-prior update is missing (a "gappy"
update), yrs parks it as a pending struct: the integrated state vector stays
empty, but the pending block is held as a recovery buffer and heals if the
missing dependency later arrives. Doc#pending? reports this.
Pending structs travel like any other state. handle_sync_message answers
SyncStep1 with the doc's full state, pending included, just like Y.js's
encodeStateAsUpdate: a peer parks the pending struct the same way this doc
did and heals it the same way. The one place pending must not go is a
compacted snapshot:
Doc#compacted_state_updatereturns a gap-free full-state update for compaction. Folding a log into one blob would otherwise freeze an un-integrable struct into the base state forever. It's non-destructive: the doc keeps its pending.encode_state_as_updatestays lossless, so persistence and serving keep the raw pending bytes and the gap can still heal.
Rendering to HTML
Schema-pinned renderers turn a collaborative document into HTML on the
server, with no Node process or headless editor. Each is an editor-specific
class (byte-for-byte with that editor's own serializer) built on a core base
any other editor extends with rules: Y::Tiptap on Y::ProseMirror for
ProseMirror documents, and Y::Lexxy (the
Lexxy editor) on Y::Lexical. Each
returns nil for a root that belongs to the other schema.
Y::Tiptap (and Y::ProseMirror, its base)
tiptap = Y::Tiptap.new(doc)
tiptap.to_html # the "default" fragment (Tiptap's default root)
tiptap.to_html("content") # or another XML root
The output matches Tiptap's own getHTML(), checked byte-for-byte in the tests
against a document captured from a real editor. It follows
tiptap-php and reads both name
styles editors use: Tiptap's bulletList/bold and prosemirror-schema-basic's
bullet_list/strong.
It covers paragraphs, headings, blockquotes, bullet/ordered/task lists, code
blocks, links, images, mentions, details, hard breaks, horizontal rules,
tables, text styles (color, font family), and every text mark. A table renders
as semantic <table><tbody>, without the column-width styling Tiptap's editor
view adds.
The support is layered like the Lexical side: Y::ProseMirror covers core
ProseMirror natively (prosemirror-schema-basic plus the prosemirror-tables
family) and Tiptap's extension nodes (task lists, mentions, the details
family) are Y::Tiptap's rule set (Y::Tiptap::NODES), built on the
extension API below. Marks stay in the base: mark rendering (nesting order,
textStyle CSS, code exclusivity) runs through native text-run machinery
that node rules don't reach, so Y::ProseMirror renders Tiptap's mark set
as-is and rules.mark overrides individual marks.
Y::Lexxy (and Y::Lexical, its base)
lexxy = Y::Lexxy.new(doc)
lexxy.to_html # the "root" fragment (Lexical's default root name)
lexxy.to_html("notepad") # or another XML root
The HTML is identical to what a lexxy-editor submits to Rails (its value).
The tests check this byte-for-byte against a document captured from a real
editor. Stock Lexical has no canonical serializer (every editor configures
its own), so the editor-specific class carries the editor's name, and
Y::Lexical is the core-Lexical base: paragraphs, headings, quotes, code,
lists, tables, links, and the full text-format model, for any other Lexical
editor to extend with rules.
It handles the whole Lexxy 0.9.x node set: paragraphs, headings, every text
format and their combinations, links, the four list types and nesting,
blockquotes, code blocks, tabs and soft breaks, horizontal rules, tables with
header cells, image galleries, and ActionText attachments (uploads and
mentions both emit <action-text-attachment> elements that ActionText can
re-render).
Internally that support is layered: Y::Lexical covers core Lexical
structure natively, and everything Lexxy adds, its node types (attachments,
galleries) and its decorations of core nodes (the table wrapper, header-cell
styling, nested-list classes), is Y::Lexxy's rule set
(Y::Lexxy::NODES), built on the extension API below. The gem's own Lexxy
support is the API's first consumer: an app rule for one of those types
simply replaces it.
In both renderers an unknown node keeps its content: text and nested blocks fall back to readable markup rather than disappearing.
Custom nodes and marks
The built-in schemas are pinned to what Tiptap and Lexxy ship, but apps add their own node types. Both renderers take rules for them. A rule is checked before the built-in schema, so it can add a node type or replace how a built-in renders.
Rules register in a block, one rules.node call per type. A declarative
rule is markup as data, rendered natively:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.node "callout", tag: "aside",
attrs: { "class" => ["callout callout--", :kind] },
contains: :blocks
end
tag names the element. attrs values are templates: a string is a literal,
a symbol reads that attribute off the node, an array concatenates both kinds;
an attribute that resolves empty is left out. text (same template form)
emits literal text content. contains declares what lives inside the node: :inline (formatted text,
the default), :blocks (child block nodes, a container), or :none (a
leaf). void: true skips the closing tag.
You don't have to guess any of those names or shapes. Editors store types
and attributes under names you'd never predict (Rhino's strike mark is
rhino-strike; Lexical prefixes its own props __), so ask a real
document instead: make one in your editor using your custom node, then:
Y::Tiptap.new(doc).node_types
# => { "callout" => { "count" => 2, "attrs" => ["kind"],
# "children" => ["paragraph"], "text" => false,
# "handled" => nil },
# "paragraph" => { ..., "handled" => "builtin" } }
handled nil marks the types that still need a rule; attrs are the stored
names your templates and blocks will read; children plus text is how you
pick contains: (child block types → :blocks; text → :inline).
When markup-as-data isn't enough, give the node a block:
lexical = Y::Lexical.new(doc) do |rules|
rules.node "video_embed" do |node|
src = ERB::Util.html_escape(node.attrs["__src"])
%(<video controls src="#{src}"></video>)
end
end
The block gets the node's type, its stored attributes, node.content (the
children, already rendered to HTML), and node.child_types, the node's
element/block children by type, in document order. child_types answers the
structural questions attributes can't: how many images a gallery holds, or
whether a list item carries a nested list. Whatever the block returns is
spliced into the output as-is: it's trusted HTML, so escape any values you
interpolate. To set the content mode for a callback, give the node both:
rules.node "embed", contains: :blocks do |node| ... end.
Callbacks never run while the document is locked. The render finishes first
(inside one read transaction, GVL released), then the blocks run and their
output is spliced in, so a callback can safely read or even write the same
doc. With no callback rules, to_html skips the splicing entirely.
Blocks are the escape hatch for everything the declarative form can't say,
and they're proven sufficient: Y::Lexxy and Y::Tiptap are themselves
built on this API (lib/y/lexxy.rb, lib/y/tiptap.rb): simple nodes as
declarative hashes, everything with logic as plain methods mapped by node
type (a Method responds to call like any lambda), and the fixture tests
hold their output byte-identical to a live editor's.
The ProseMirror side also takes custom marks:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.mark "comment", tag: "span", attrs: { "data-comment-id" => :id }
end
Symbol refs resolve against the mark's own attributes. A custom mark wraps
outside every built-in mark; several on one run nest alphabetically. A rule
for a built-in mark name ("bold") replaces its built-in tag.
Worked examples
A video-embed node from an app's Tiptap extension, a type the pinned schema has never heard of:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.node "videoEmbed" do |node|
src = ERB::Util.html_escape(node.attrs["src"])
title = ERB::Util.html_escape(node.attrs["title"] || "Video")
%(<figure class="video"><iframe src="#{src}" title="#{title}" allowfullscreen></iframe></figure>)
end
end
Resolving mentions against the database. Blocks run after the document read has finished, so hitting ActiveRecord (or the doc itself) inside one is safe:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.node "mention" do |node|
user = User.find_by(id: node.attrs["id"])
next "<span>@unknown</span>" unless user
%(<a class="mention" href="/users/#{user.id}">@#{ERB::Util.html_escape(user.handle)}</a>)
end
end
Overriding a shipped rule: rendering Lexxy uploads as real image markup
instead of the <action-text-attachment> elements ActionText re-renders:
lexxy = Y::Lexxy.new(doc) do |rules|
rules.node "action_text_attachment" do |node|
src = ERB::Util.html_escape(node.attrs["src"])
alt = ERB::Util.html_escape(node.attrs["altText"].to_s)
caption = node.attrs["caption"].to_s
html = %(<img src="#{src}" alt="#{alt}" loading="lazy">)
html += "<figcaption>#{ERB::Util.html_escape(caption)}</figcaption>" unless caption.empty?
"<figure>#{html}</figure>"
end
end
Markup that depends on structure: node.child_types lists the node's
element/block children in document order, so a layout container can size
itself by its column count while the columns themselves stay declarative:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.node "columns", contains: :blocks do |node|
%(<div class="columns columns--#{node.child_types.length}">#{node.content}</div>)
end
rules.node "column", tag: "div", attrs: { "class" => "column" }, contains: :blocks
end
Content-aware overrides: dropping the empty paragraphs an editor keeps
around the cursor, since node.content arrives already rendered:
lexical = Y::Lexical.new(doc) do |rules|
rules.node "paragraph" do |node|
node.content.empty? ? "" : "<p>#{node.content}</p>"
end
end
For a larger reference, the gem's own editor schemas ship this way; see
Y::Lexxy::NODES in lib/y/lexxy.rb (declarative hashes for the simple
nodes, a plain method per node that needs logic (galleries, list items,
header cells, both attachment types) mapped with method(:name)) and
Y::Tiptap::NODES in lib/y/tiptap.rb (task lists, mentions, the details
family).
Protocol codec (module functions)
Classifying and unwrapping wire frames is stateless, so it's exposed as
Y module functions rather than a class. The server never holds presence
or document state to route a frame; presence lives in the browser clients, and
the server only relays awareness frames opaquely.
Y.(frame) # => 0 drop / 1 step1 / 2 update / 3 awareness / 4 query
Y.(frame) # => the document delta carried by a frame, or nil
Y.wrap_update(update_bytes) # => wrap a raw doc update as a sync Update frame
ActionCable Integration
In a Rails app, one generator creates the channel and the migration:
bin/rails generate yrby:install
bin/rails db:migrate
The models ship in the gem, the way Action Text owns
ActionText::RichText:
Y::Document: one row per document, addressed two ways: bykey(what a channel addresses; one opaque, unique string, sometimes app-supplied, never parsed) and, optionally, by polymorphicrecord+name(which model attribute it backs;nameis the attribute name,"body"; one document per attribute per record, the ActionText::RichText scheme). Key-only documents leave the binding nil. Either side can arrive first:Y::Document.for(record, name)finds or creates the binding, derives a readable key (post/1/body), and adopts a key-only row already holding that key, so a channel writing first and a binding created later converge on one document. The row also holds the mergedstatesnapshot, CRDT state only; derived data (rendered HTML, search text) is the application's job, typically in the channel's on_change..load_state(key)/.append(key, update)are the store calls the generated channel uses.Y::DocumentUpdate: the uncompacted tail, one delta per row, compacted intostateand deleted once the tail reachescompact_every(default 64). Loading reads the snapshot plus the current tail; an empty tail returnsstatedirectly. Compaction serializes on a per-document row lock and skips causally-gapped rows; they're quarantined until they heal rather than compacted into state or deleted. Destroying a document deletes its updates with it.
Encrypted storage: Y::EncryptedDocument stores state and update
payloads through Active Record encryption on the same tables, the way
ActionText::EncryptedRichText does. Point the channel's
on_load/on_change at it instead and configure your app's encryption
keys. Use one access path per document: rows written encrypted read back
as ciphertext through the plain classes.
The migration creates y_documents and y_document_updates. To rename
them, edit the generated migration and point Y::Document.table_name /
Y::DocumentUpdate.table_name at the new names in an initializer.
Storage is swappable: the channel only needs on_load and on_change
answered, and they can point at anything.
include Y::ActionCable (from the yrby-rails gem) is the channel
integration: the y-websocket protocol (document sync +
awareness/presence) over ActionCable.
# app/channels/document_channel.rb
class DocumentChannel < ApplicationCable::Channel
include Y::ActionCable
on_load { |key| Y::Document.load_state(key) } # rebuild from storage
on_change { |key, update| Y::Document.append(key, update) } # record, then broadcast
def subscribed
return reject unless (params[:id])
sync_subscribed params[:id]
end
def receive(data)
sync_receive(data, params[:id])
end
private
# Everyone is denied until you wire this to your app's auth.
def (_document_key) = false
end
The concern is store-backed. A handshake is answered from on_load; document
changes are recorded through on_change, then broadcast. Nothing
authoritative is kept in ActionCable process memory, so
AnyCable RPC workers, Puma workers, and separate dynos can all handle messages
for the same document as long as they share the same store and cable adapter.
on_load and on_change are required. If either is missing, the channel fails
before it can acknowledge or broadcast edits. Presence is ephemeral:
awareness frames are relayed, and yrby-client sends a best-effort
presence-removal frame on disconnect/pagehide, with the client-side awareness
timeout as the fallback for abrupt disconnects.
Incoming frames are validated as a single well-formed protocol message before anything processes or relays them. Malformed, truncated, multi-message, oversized, or unknown frames are dropped. A bad frame can't crash the process: a Rust panic is caught at the FFI boundary and re-raised as a Ruby exception. And no single client can relay garbage that breaks the others in a room.
Delivery guarantees
The contract is the same at every scale: one process, or hundreds across many servers:
- The document always converges. CRDT updates are commutative and idempotent, so out-of-order, duplicate, or concurrent delivery all converge to the same correct document. This needs no coordination and holds everywhere.
- An acked update is durable, even one that arrived out of order. An update with a missing dependency is recorded and acked like any other, and parks as pending in the document. That missing dependency is an update some client still holds unacked, so that client keeps retransmitting it until the server records it, and the gap closes. The ack loop is the guarantee. See Causal gaps.
on_changeis at-least-once, and the durable guarantee is that replaying the log reconstructs the document. Every update triggerson_changebefore it's acked or broadcast (record-before-distribute). If exactly-once updates matter for you, you must makeon_changeidempotent. But remember that the CRDT can handle duplicates.- A raising
on_changerejects the update implicitly. If the block raises, the update is neither acked nor broadcast (record-before-distribute stops both). There is no negative-ack: the client simply never receives the ack, keeps the update pending, and retransmits on its timer/reconnect. This is built for transient failures (the store is briefly down → a retry lands). A block that raises deterministically (a validation that always fails for this edit) will be retried forever, since nothing tells the client to stop. Enforce hard rejections before the edit reacheson_change(channel authorization insubscribed), not by raising inside it. - An over-cap frame is dropped the same silent way. A frame larger than
max_frame_bytes(default 8 MiB) is dropped before decoding (no ack, no broadcast) to bound the work a client can force. For a genuine document update that means the same implicit rejection as above: unacked, retransmitted forever. Normal typing never approaches the cap, but a large paste, an embedded image, or a big initialSyncStep2can. The drop is logged (warnfor over-cap,debugfor undecodable) with the document key and update id so it's findable; overridesync_log_contexton the channel to add a user/connection id. Size the cap for your largest expected payload, and reject genuinely-too-big content upstream rather than relying on the cap to reject it gracefully.
Causal gaps
Yjs updates can arrive out of order: an update can reach the server before another update it depends on. yrby treats that as normal. The update is recorded and acked like any other, parks as a pending struct in the document, and integrates on its own the moment the missing dependency lands. The write path never rebuilds the document; it appends, relays, and acks, so a gapped update costs the same as any other.
Serving is lossless too, like any Yjs server. handle_sync_message serves
full state, pending included, so a peer parks the same pending struct and
heals it the same way. Healing needs no special machinery: the missing
dependency is an update its sender still holds unacked, and at-least-once
retransmission delivers it. Only compaction excludes pending
(compacted_state_update), because folding a log must not freeze an
un-integrable struct into the base state.
The bundled Y::Document store handles all of this. If you write your own
store, keep two things in mind:
1. Load losslessly, and tolerate duplicates. on_load should return
state that preserves pending: encode_state_as_update, or a replay of the
raw append log. Don't compact with compacted_state_update while
doc.pending?; that strips the pending struct and the acked edit inside it.
(Y::Document quarantines pending rows for exactly this reason.) A lost ack
also means a client resends an update the store already has. Replay converges
anyway, because CRDT apply is idempotent, so deduping is optional. If log
size matters, dedup by content hash:
class DocumentStore
# append tolerates duplicates: a re-delivered update upserts to a no-op.
def append(key, update)
Revision.upsert({ doc_key: key, update_hash: Digest::SHA256.hexdigest(update), update: update },
unique_by: %i[doc_key update_hash])
end
# load is lossless: replay the raw log so a pending struct is preserved and
# heals when its dependency arrives.
def load(key)
updates = Revision.where(doc_key: key).order(:id).pluck(:update)
return nil if updates.empty?
doc = Y::Doc.new
updates.each { |u| doc.apply_update(u) }
doc.encode_state_as_update # lossless: keeps pending
end
# optional compaction: only when there is no open gap, or you would drop it.
def compact(key)
doc = Y::Doc.new
Revision.where(doc_key: key).order(:id).pluck(:update).each { |u| doc.apply_update(u) }
return if doc.pending? # a gap is open; compacting now would drop it
# ... replace the log with a single revision holding doc.compacted_state_update ...
end
end
2. Watch for gaps that never heal. An open gap is quiet: the edit sits
as pending, invisible in the document, until its dependency arrives. Normally
that resolves itself. The sender retransmits the missing update until it is
acked, and every join or reconnect handshake has the client send everything
the server hasn't integrated, so any client holding the dependency supplies
it just by connecting. The gap worth alerting on is one no live client can
supply, and that is what the on_gap hook surfaces. It fires with the
document key whenever a document is loaded to serve state and a gap is still
open. Use it to emit a metric (a pending-document count, or the age of the
oldest open gap) so a stuck gap is visible. Gaps are also logged at info,
and errors raised in the hook are swallowed so observability can never break
frame handling.
class DocumentChannel < ApplicationCable::Channel
include Y::ActionCable
on_gap { |key| StatsD.increment("yrby.gap", tags: ["doc:#{key}"]) }
end
Multi-process deployments
Most Rails apps run several processes, and any of them might serve a given document. Two pieces keep them in step.
Broadcasts cross processes through the Action Cable adapter, so it needs to something
like redis or solid_cable, not async. With that in place, a change
on one process reaches clients on all of them.
Every process rebuilds document state from the durable store through on_load.
Because changes are recorded before broadcast, record-before-distribute holds
across processes: whichever process receives a change records it to the shared
store before anyone, anywhere, sees it.
bun multiprocess.mjs in the demo runs clients across two processes and checks
convergence, fresh reads on both, presence across processes, and one shared log.
AnyCable
yrby fully supports AnyCable.
The demo checks this against a real anycable-go + RPC server
(frontend/anycable_probe.mjs, anycable_concurrent.mjs): liveness, the
yrby client provider, cross-process reads, and concurrent convergence.
Demo
examples/actioncable-demo is a full Rails + Tiptap
app using the yrby provider, with end-to-end tests.
Record Before Distribute
Every document change is handed to the on_change handler before broadcasting.
It is up to you to durably record it:
class DocumentChannel < ApplicationCable::Channel
include Y::ActionCable
# ...
on_change do |key, update|
# Synchronous, durable write. `update` is the exact CRDT delta.
AuditLog.append!(key, update) # raise to REJECT the change
end
# ...
end
If the recorder raises (say the store is down), the change is rejected: not applied, not sent to anyone. The cost is a synchronous durable write on the path of every change. There's no in-gem per-document lock; concurrent writes to one document can both record (at-least-once), and since CRDT apply is idempotent a duplicate record replays to the same document.
The demo wires on_change to a durable Postgres-backed log by default, and checks
end to end that the log alone rebuilds the document.
Ephemeral documents (no database)
on_load and on_change are plain blocks, and nothing requires them to touch
a database. For documents that don't need to outlive their session (a
scratchpad, live form state, a draft you only persist on submit) the store
can be connection state that travels with each request:
class ScratchpadChannel < ApplicationCable::Channel
include Y::ActionCable
on_load { |key| @doc_state }
on_change do |key, update|
doc = Y::Doc.new
doc.apply_update(@doc_state) if @doc_state
doc.apply_update(update)
@doc_state = doc.compacted_state_update
end
def subscribed = sync_subscribed(params[:id])
def receive(data) = sync_receive(data, params[:id])
end
On AnyCable the channel object doesn't survive between messages, so an
instance variable won't hold. Declare the store as channel state instead
(state_attr_accessor comes from anycable-rails) and Base64 it, because that
state is serialized as JSON into each RPC exchange with anycable-go:
class ScratchpadChannel < ApplicationCable::Channel
include Y::ActionCable
state_attr_accessor :doc_state
on_load { |key| doc_state && Base64.strict_decode64(doc_state) }
on_change do |key, update|
doc = Y::Doc.new
doc.apply_update(Base64.strict_decode64(doc_state)) if doc_state
doc.apply_update(update)
self.doc_state = Base64.strict_encode64(doc.compacted_state_update)
end
def subscribed = sync_subscribed(params[:id])
def receive(data) = sync_receive(data, params[:id])
end
Both hooks run in the channel instance (instance_exec), so they can use
anything the channel can, and sync_receive rebuilds the document from
on_load on every update, which is what lets the store live on the
connection. On Action Cable the channel instance lasts as long as the
connection, so an instance variable is the whole store. Merging into
compacted_state_update keeps it one blob instead of a growing update log.
The store is per connection, which shapes what this fits. A single writer gets the full delivery contract with no database anywhere. With several people editing at once, one client's update can depend on edits its own connection has never seen; that update records as pending, and the next handshake with that client (which always holds the full document) supplies the missing state and heals it. The document still converges; heavy concurrent editing just parks more pending between handshakes than a shared store would. On AnyCable, keep the payload in mind too: the blob travels with every message, so that variant suits small documents, not long manuscripts.
Durability is the connection plus the browsers. A reconnecting client re-seeds
an empty server through the ordinary sync handshake, so the document survives
server restarts as long as some client still has it. For ephemeral documents
shared across clients on a single-process deployment, the same two hooks over
a class-level Concurrent::Map work instead; that version stops being
coherent the moment you scale past one process.
Reliable delivery (acks)
yrby document delivery is ack-tracked. Browser document updates carry an
"id", and the server replies { "ack": <id> } once on_change has
successfully fired. Every decodable document update is recorded and acked,
including one that arrives out of order.
client -> server { "update": "<base64 update>", "id": 42 }
server -> client { "ack": 42 } # update accepted; safe to forget
yrby-client's ActionCableProvider handles this automatically. It keeps
the unacknowledged local document tail in a queue and sends the merged tail as a
single causally-complete delta. The id is the highest sequence in the batch, so
one { ack: id } cumulatively confirms everything up to it. Because CRDT apply
is idempotent, a resend that already landed is a harmless no-op that just
re-acks. Awareness stays ephemeral and is not acked.
Presence (cursors, selections) is owned by the browser clients; the server
never sets or holds presence state, it only relays awareness frames opaquely.
See yrby-client for the client-side awareness API.
Thread Safety
A Doc is safe to share across Ruby threads, used concurrently from Puma
workers, ActionCable connection threads, or background jobs without external
locking.
test/thread_safety_test.rb runs shared docs, the full sync handshake, and
fan-in sync across 8 threads at once, and checks the interleaving doesn't change
convergence.
Parallelism (GVL release)
Every method that does real CRDT work (applying updates, encoding state,
handling sync messages) releases Ruby's Global VM Lock
(rb_thread_call_without_gvl) while the native code runs. That buys two things.
CRDT work runs in parallel across Ruby threads on MRI, not just
JRuby/TruffleRuby. bench/parallelism_bench.rb measures over 2x wall-clock
speedup applying a ~900 KB update concurrently; native code that held the GVL
couldn't beat serial time.
A slow operation also can't stall the VM. A thread applying a large update holds the doc's write lock without holding the GVL, so other Ruby threads keep running instead of queuing behind it.
Each method has the same shape: copy Ruby byte strings first, drop the GVL, do the yrs work while taking and releasing native locks entirely inside the closure, take the GVL back, then build Ruby objects. No Ruby API is touched without the GVL, and no native lock is held while reacquiring it, so the lock order can't deadlock. Panics in native code are caught and re-raised as Ruby exceptions.
Message Type Constants
Y::MSG_SYNC # 0 - Document sync messages
Y::MSG_AWARENESS # 1 - User presence data
Y::MSG_SYNC_STEP1 # 0 - State vector request
Y::MSG_SYNC_STEP2 # 1 - Update response
Y::MSG_SYNC_UPDATE # 2 - Incremental update
Sync Flow
Client A Server
| |
|-------- connect() ------------->|
| (SyncStep1 + Awareness) |
| |
|<--- handle_sync_message resp ---|
| (SyncStep2) |
| |
| (Document synchronized!) |
| |
|<------- updates ----------------|
|-------- updates --------------->|
Development
# Setup
bundle install
# Build extension
rake compile
# Run tests
rake test
# Clean build artifacts
rake clean
License
MIT License
Acknowledgments
- y-crdt/yrs - The Rust implementation of Y.js
- Magnus - Ruby bindings for Rust
- rb-sys - Rust extensions for Ruby