yrby
Docs · 7 of 7 View as Markdown

AnyCable and multi-process

Most Rails apps run several processes, and any of them might handle a given document. Two things keep them in sync.

Broadcasts have to cross processes

The Action Cable adapter sends a broadcast from the process that received a change to the processes serving the other clients. Use redis, solid_cable, or another adapter that works across processes. The async adapter only works inside one process. If you run two processes with it, clients on one process never see edits made on the other.

Every process rebuilds from the store

yrby doesn't keep documents in process memory between messages. Every process loads the document from your store through on_load. Whichever process receives a change writes it to the store before any client sees it.

You don't need sticky routing, per-document ownership, or any coordination between processes.

The demo app's multiprocess.mjs test runs clients against two processes. It checks that every client ends up with the same document, that both processes read the latest state, that presence reaches clients on the other process, and that both processes write to the same log.

AnyCable

yrby works on AnyCable. The demo app tests it against a real anycable-go server and RPC server in frontend/anycable_probe.mjs and frontend/anycable_concurrent.mjs. Those scripts check that the connection stays up, that a different process can read the latest state, and that concurrent edits end up the same everywhere.

This site runs the smallest possible AnyCable setup. anycable-thruster bundles anycable-go into the Thruster proxy, so thrust bin/serve starts everything. The Go server handles /cable and calls Rails over HTTP at a path AnyCable mounts in the app, and Rails sends broadcasts back over localhost. On one machine there's no Redis and no separate RPC process. The site README has the configuration.

AnyCable runs channels differently from Action Cable in two ways that matter here. Neither is specific to yrby.

The channel instance doesn't survive between messages

AnyCable builds a new channel object for each RPC command. An instance variable you set in subscribed is gone by the time receive runs, so pass the key on every action:

def subscribed    = sync_subscribed(params[:id])
def receive(data) = sync_receive(data, params[:id])

That's why every channel example in these docs passes the key to sync_receive. When anycable-rails is loaded, the gem's Y::DocumentChannel stores the authorized key as channel state, and looks the record up again from the token when it needs it. On plain Action Cable an instance variable would work, but the examples are written to run on both.

Connection-scoped state has to be declared

If you keep a temporary document on the connection, declare it as channel state with state_attr_accessor from anycable-rails. Base64-encode the bytes, because AnyCable sends that state as JSON with every RPC call:

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.encode_state_as_update)
  end

  def subscribed    = sync_subscribed(params[:id])
  def receive(data) = sync_receive(data, params[:id])

  private

  def authorized?(_key) = true # each subscriber gets its own scratchpad on this connection
end

The state goes back and forth with every message, so this only makes sense for small documents.

Awareness whispers

On AnyCable, the channel opens a second stream for presence with whisper: true. A whisper goes from one browser to the others through anycable-go. Only presence uses it. Document edits still go through the server, which saves and confirms them.

Plain Action Cable has no whispers, so presence goes through the server too. Your channel code doesn't change, because the concern checks whether whispers are available.

In the browser, the provider whispers presence when the subscription has a whisper method. Consumers from @anycable/web have one, and @rails/actioncable consumers don't. The provider takes either, so switching is one import:

import { createConsumer } from "@anycable/web"

Every cursor move sends presence. With whispers, the Go server relays those between browsers, and none of them turn into a call into Ruby.

Threads and the GVL

You can share a Doc across Ruby threads (Puma threads, Action Cable connection threads, background jobs) without adding locks. test/thread_safety_test.rb runs shared docs and full sync handshakes from 8 threads at once, and checks that every thread still ends up with the same document.

Methods that do real CRDT work release the Global VM Lock while the native code runs. So CRDT work runs in parallel on regular MRI, and you don't need JRuby or TruffleRuby for it. bench/parallelism_bench.rb shows more than a 2x speedup when applying a roughly 900 KB update on several threads at once. A thread applying a large update holds the doc's write lock but not the GVL, so other Ruby threads keep running.

Each of those methods works the same way. It copies the Ruby strings it needs, releases the GVL, and does the yrs work, taking and releasing native locks along the way. Then it takes the GVL back and builds the Ruby objects. Ruby is only called while holding the GVL, and no native lock is held while waiting for it, so the two can't deadlock. If the native code panics, Ruby raises an exception.

This page is copied from the repo README. If the two disagree, go by the README.