Presence and Conflicts

A bounty board that several visitors edit at once. Each row shows who else has it open and a simple conflict-solving mechanism: keep mine, or take theirs. The conflict is found on the server, by comparing two copies of the row, and the page needs no JavaScript of its own.

Bounty board

Finding you a name…

Shared with every visitor. Put back each night.

# frozen_string_literal: true

# The bounty board. Which row is open belongs to this tab, and so does the
# copy of the bounty its form was filled from. The bounties, and who has
# which one open, belong to everyone: any change to either pings every tab,
# and each tab reads both again.
class Recipes::Presence::BoardChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Bounty = Recipes::Presence::Bounty
  Visitor = Recipes::Presence::Visitor
  BountyForm = Recipes::Presence::BountyForm
  VIEWS = "app/recipes/presence"

  # Only a server that was killed leaves a visitor behind. This keeps the
  # living ones apart from those.
  periodically :beat, every: 30.seconds

  def build_graph
    @version = Hibiki::State.new(0)
    @editing_id = Hibiki::State.new(nil)
    # The line of the board the open form was filled from.
    @base = Hibiki::State.new(nil)
    # Part of the form's ids. A new number makes the next render put in new
    # fields, where a morph would leave what was typed in the old ones.
    @draft = Hibiki::State.new(0)
    @form = BountyForm.new

    # Plain values, so a ping that changes none of them sends nothing.
    rows = Hibiki::Derived.new do
      @version.value
      Bounty.rows
    end
    editors = Hibiki::Derived.new do
      @version.value
      Visitor.editing(except: cid)
    end
    others = Hibiki::Derived.new do
      @version.value
      Visitor.others(except: cid)
    end

    # The conflict: the open bounty as it is now, when that is newer than
    # the copy the form was filled from. Nobody sets it. It appears when
    # the board is read again, and goes when the form is filled again.
    @theirs = Hibiki::Derived.new do
      base = @base.value
      row = rows.value.find { it.id == @editing_id.value }
      row if base && row && row.lock_version != base.lock_version
    end

    # The fields this visitor has changed. Names only, so typing changes
    # it once per field and not once per letter.
    @mine = Hibiki::Derived.new do
      base = @base.value
      base ? fields.select { @form.public_send(it) != base.public_send(it) } : []
    end

    # The whole board is one fragment. The open row's fields are read
    # without subscribing to them, so typing renders nothing; `@mine` is
    # followed only while there is a conflict to explain.
    Hibiki::Effect.new do
      theirs = @theirs.value
      locals = { rows: rows.value, editors: editors.value, editing_id: @editing_id.value, draft: @draft.value,
                 form: @form, base: @base.value, theirs:, mine: theirs ? @mine.value : [] }
      @form.errors
      Hibiki.untrack do
        broadcast_morph target: "presence_rows", partial: "#{VIEWS}/rows", locals:
      end
    end

    Hibiki::Effect.new do
      broadcast_morph target: "presence_who", partial: "#{VIEWS}/who", locals: { me: @me, others: others.value }
    end
  end

  def edit(data)
    bounty = Bounty.find_by(id: data["id"])
    return unless bounty

    fill(bounty)
    @editing_id.value = bounty.id
    Visitor.open(cid, bounty.id)
  end

  def cancel(_data) = close

  # Keeps the form up to date with the fields, so a render caused by
  # someone else puts back what was typed.
  def set_field(data)
    return unless @editing_id.value

    assign(data)
  end

  # While there is a conflict the row offers Keep mine and Take theirs, and
  # a plain save waits for one of them.
  def save(data)
    return unless @editing_id.value

    assign(data)
    commit unless @theirs.value
  end

  # Start again from the bounty as it is now.
  def take_theirs(_data)
    bounty = open_bounty
    fill(bounty) if bounty
  end

  # Their save, with this visitor's changes on top: the form is filled from
  # the bounty as it is now, and only the fields that were changed here are
  # put back before it is saved.
  def keep_mine(_data)
    bounty = open_bounty
    return unless bounty

    kept = @mine.value.index_with { @form.public_send(it) }
    fill(bounty)
    kept.each { |name, value| @form.public_send(:"#{name}=", value) }
    commit
  end

  private

  def subscribed
    @me = Visitor.join(cid) if cid.present?
    super
    return if subscription_rejected?

    # A bounty saved in another tab, or a row opened or closed there. The
    # block runs on a cable thread, so it hops onto the graph thread before
    # touching a signal.
    stream_from Bounty::CHANGED, coder: ActiveSupport::JSON do |ping|
      graph_actor&.post { hear(ping) }
    end
  end

  def unsubscribed
    Visitor.leave(cid) if @me
    super
  end

  def beat = Visitor.beat(cid)

  # A ping this tab caused is about something it already knows.
  def hear(ping)
    reread unless ping["from"] == cid
  end

  def reread = @version.value += 1

  def fields = BountyForm.hibiki_attributes

  def open_bounty = Bounty.find_by(id: @editing_id.value)

  def fill(bounty)
    bounty.saved_by = cid
    @form.hydrate(bounty)
    @base.value = bounty.row
    @draft.value += 1
  end

  def close(ping: true)
    @editing_id.value = nil
    @base.value = nil
    Visitor.open(cid, nil, ping:)
  end

  # A save the model turns down leaves its messages on the form, and the
  # row stays open. A save from a copy that is no longer the newest is the
  # same conflict caught late: the ping has not been heard yet, so the
  # board is read now.
  #
  # The save and the closed row are one transaction, so the bounty's ping
  # goes out after both and is the only one the others need.
  def commit
    Bounty.transaction do
      next unless @form.commit

      close(ping: false)
      reread
    end
  rescue ActiveRecord::StaleObjectError
    reread
  end

  # Only the form's own attributes are taken from what the browser sent.
  def assign(data)
    fields.each do |name|
      value = data[name.to_s]
      @form.public_send(:"#{name}=", value.first(60)) if value.is_a?(String)
    end
  end
end

The panel above is a bounty board: each bounty with a target, a reward and where it was last seen. A shared form like this has a natural problem: two people can edit the same row, and whoever saves last overwrites a change made by others. Try opening this page in a second window and put the two side by side: each window is an adventurer with a number. Try editing the same row in both, followed by a save in one of them. The other window is told at once what changed, and its Save button becomes two: Keep mine and Take theirs. Knowing who is here usually takes a channel and JavaScript of its own, and telling a visitor what changed under their form takes even more JavaScript. Here presence is a table the channel already writes to, and the conflict is a value worked out from two copies of the row.

The board is shared by every visitor: anyone can edit it, and it is reset every night.

How it fits together

The board lives in a channel: the server side of an Action Cable connection, an object that stays alive while the page is open, one for each tab. A state holds a value and remembers who read it. Here @editing_id is a state holding the id of the row this tab has open, and @base holds a copy of that bounty as it was when the form was filled. The open row's fields are a reactive form, BountyForm, with a state for each column, and the channel's public methods are its actions, the methods the page may call.

The bounties, and who has which one open, belong to everyone, so both are tables. A derived is a value worked out from other values, and worked out again when one of them changes. Here rows is a derived that reads the bounties, and editors one that reads who else has which row open, both as plain values. Each reads a state first, @version, a number that goes up whenever another tab saves a bounty or opens or closes a row, so each asks the database again.

The conflict is a derived too. @theirs is the bounty this tab has open, as the board has it now, when its version is no longer the one in @base; the rest of the time it is nil. An effect is a block of code that runs once, and runs again whenever a value it read has changed. One renders the whole board, with the badges, the open row and the notice drawn from those values, and sends it to the page, where it is morphed into place. This is what happens when another visitor saves the bounty you have open:

  1. Their save writes the bounty, and its lock_version goes up by one. After the commit the model pings every tab.
  2. Your channel hears the ping and adds one to @version. rows read it, so the bounties are read again, and your open row now carries the new version.
  3. @theirs read rows, so it is worked out again, and it stops being nil. The effect read @theirs, so it renders the board again: the notice sits in your open row, and Keep mine and Take theirs stand where Save was. Your fields are left as you have them.

The channel never says that the notice depends on a save in another tab. Whatever the effect read on its last run is what it depends on.

Who has a row open

Who has which row open is the one fact here that no single tab knows. Each tab has its own channel and its own states, so the fact has to live where every tab can read it. A table does that, and it works the same with several server processes:

# The channel. A page is a visitor from the moment it subscribes until its
# socket closes, however that happens.
def subscribed
  @me = Visitor.join(cid) if cid.present?
  super
  return if subscription_rejected?

  stream_from Bounty::CHANGED, coder: ActiveSupport::JSON do |ping|
    graph_actor&.post { hear(ping) }
  end
end

# A ping says only which tab caused it. That tab already knows.
def hear(ping)
  reread unless ping["from"] == cid
end

def unsubscribed
  Visitor.leave(cid) if @me
  super
end

# Opening and closing a row write one column.
def edit(data)
  # ...
  Visitor.open(cid, bounty.id)
end

# Who else has which row open, read again on every ping. It is a plain
# hash, so a ping that changes nobody's row renders nothing.
editors = Hibiki::Derived.new do
  @version.value
  Visitor.editing(except: cid)
end

A page is a visitor from the moment it subscribes until its socket closes, and opening a row writes the bounty's id to its visitor. A change to that table pings the same stream a saved bounty does, naming the tab that caused it, so that tab does not read again what it already knows.

The badge is a warning and not a lock. A lock that a visitor forgets shuts everyone else out of the row, and a lock with a timer closes the form of a visitor who stopped to think. Here an open form stays open as long as its page does, and a closed tab takes its row away with it.

A badge that appears in a row is a render of the whole board, into forms other visitors are typing in. They keep what was typed, because each keystroke is sent to the form's states and the effect draws the fields from the form. (The open row itself, with its cursor, Enter and Escape, is the in-place form of the Inline Edit recipe.)

A change under an open form

Presence makes a conflict rare, and it does not prevent one. The save that overwrites is often made when nobody else is editing: you open a bounty, someone else opens it, saves and leaves, and then you save. So the check has to be on the record. Active Record has one: a lock_version column that every save counts up, so that a save from a copy whose number is behind is refused. The channel keeps the copy the form was filled from, and compares its number with the board's:

# The line of the board the open form was filled from.
@base = Hibiki::State.new(nil)

def fill(bounty)
  bounty.saved_by = cid
  @form.hydrate(bounty)
  @base.value = bounty.row
  @draft.value += 1
end

# The conflict is derived, not set. `rows` is read again on every ping, so
# this turns from nil to their copy when someone else's save arrives.
@theirs = Hibiki::Derived.new do
  base = @base.value
  row = rows.value.find { it.id == @editing_id.value }
  row if base && row && row.lock_version != base.lock_version
end

# A save made before the ping was heard. Active Record refuses it, and the
# answer is to read the board now: `@theirs` does the rest.
def commit
  Bounty.transaction do
    next unless @form.commit

    close(ping: false)
    reread
  end
rescue ActiveRecord::StaleObjectError
  reread
end

fill runs whenever the form is filled from a bounty, and keeps a plain copy of the row in @base for @theirs to compare with. A save can still leave before the ping arrives. Then Active Record refuses it, and the channel answers by reading the board, which is all the ping would have done. Found early or late, the conflict is the same derived and the same notice.

Keep mine and Take theirs

A notice that says only "someone else saved this" leaves you to find out what. This one lists each field the other save changed, and says which of them you changed too. It can, because the channel holds three copies of the bounty: the line the form was filled from, the line as the board has it now, and the form. The fields you changed are a derived of names, @mine, read only while the notice is up, so typing draws the board once when a field joins the list and not once for each letter:

# The fields this visitor has changed, by name.
@mine = Hibiki::Derived.new do
  base = @base.value
  base ? fields.select { @form.public_send(it) != base.public_send(it) } : []
end

# Start again from the bounty as it is now.
def take_theirs(_data)
  bounty = open_bounty
  fill(bounty) if bounty
end

# Fill the form from the bounty as it is now, put back only the fields
# that were changed here, and save.
def keep_mine(_data)
  bounty = open_bounty
  return unless bounty

  kept = @mine.value.index_with { @form.public_send(it) }
  fill(bounty)
  kept.each { |name, value| @form.public_send(:"#{name}=", value) }
  commit
end

Take theirs fills the form from the bounty as it is now, and what you typed is gone. A morph leaves a field you have typed in alone, so the form's ids carry a number that fill counts up, and the next render puts in new fields.

Keep mine is not "save anyway". Saving the whole form would write your old copy of the fields you never touched over what they saved. So it fills the form from the newest bounty, puts back only the fields you changed, and saves that. Change the target in one window and the reward in the other, and both changes survive.

The same feature in stock Rails

Stock Rails has the staleness check: it is the same lock_version column. The rest is built by hand. Here is a sketch of it. It is not run here.

# A sketch of the stock Rails version. It is not part of this app.
class BountiesController < ApplicationController
  before_action :set_bounty, only: %i[show edit update]

  def index
    @bounties = Bounty.order(:id)
  end

  # Cancel. The row's frame asks for the closed row again.
  def show
    render partial: "bounties/bounty", locals: { bounty: @bounty }
  end

  # Opening a row, and Take theirs: the frame asks for a fresh form.
  def edit
  end

  # The form carries the `lock_version` it was rendered with, so a save
  # from an older copy raises.
  def update
    keep_mine if params[:keep_mine]

    if @bounty.update(bounty_params)
      render partial: "bounties/bounty", locals: { bounty: @bounty }
    else
      render :edit, status: :unprocessable_entity
    end
  rescue ActiveRecord::StaleObjectError
    # Their values for the notice, the visitor's own for the fields.
    @theirs = Bounty.find(@bounty.id)
    render :edit, status: :conflict
  end

  private

  def set_bounty = @bounty = Bounty.find(params[:id])

  def bounty_params = params.expect(bounty: %i[target reward last_seen lock_version])

  # The form also sends each field as it was first rendered. Only a field
  # that differs from that was changed by this visitor; the rest are
  # dropped, so the reloaded record keeps what the other save wrote.
  def keep_mine
    was = params.expect(was: %i[target reward last_seen])
    params[:bounty].delete_if { |name, value| was[name] == value }
    params[:bounty].delete(:lock_version)
  end
end

The difficulty is the same in every part of it. A broadcast is one piece of HTML for everyone, and here every visitor needs something different: the badges without their own name, and the notice measured against what their own form holds. The server does not know either, so the page sends them up in hidden fields or works them out in JavaScript. The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
A stale save is refused A lock_version column and a hidden field carrying it The same column; the channel holds the record
Who is here A table, a channel of your own and a Stimulus controller that subscribes to it A table, written from the channel the page already has
Which row a visitor has open The Stimulus controller watches the frames load and tells the channel Nothing; edit and cancel already run on the server
The badge on a row A callback that broadcasts a partial for the row left and the row entered A derived the effect reads
Not showing a visitor their own badge The Stimulus controller hides it; the broadcast is the same for everyone except: cid in the query; each tab draws its own board
Told when the record changes under your form A second broadcast, aimed at an element only an open form has A derived comparing two versions
What they changed, and what you changed too Each field's first value in a hidden input, compared in a Stimulus controller Compared in the partial; the channel has all three copies
A save that left before the news arrived Rescue the error and render the form again with their values Rescue the error and read the board; the same derived shows it
Keep mine Send the first values back and drop the params that match them One action: fill the form again, put back the changed fields, save
Take theirs A link that fetches the form again One action: fill the form again
Swapping Save for the two buttons The Stimulus controller, when the notice arrives An if in the form's partial
JavaScript you write Two Stimulus controllers None
What a visitor arriving costs One insert, and a broadcast to every page One insert, and three small queries in every other open tab

The last row is the honest cost. Every ping makes every other open tab read the bounties and the visitors again, where a broadcast renders once for all. A save is one ping, since the bounty and the closed row are written in one transaction. For a board of this size the reads are inexpensive; for a long list, consider reducing queries on each ping.