Aggregate Stats
A smithy's order book that every visitor writes to. The counts, the coin and the queue updates with each order whether by a visitor or a background job.
The smithy's order book
- Work in hand
- 5,533 gp
- Ready to be paid for
- 465 gp
- Dearest order open
- Silvered Rapier, 2,100 gp
Next in line
-
Chain Hauberk at the forge 1,100 gp
-
Black Iron Axe at the forge 880 gp
-
Hatchet waiting 25 gp
-
Hunter's Spear waiting 520 gp
-
Steel Kiteshield waiting 700 gp
# frozen_string_literal: true
# One visitor's reading of the smithy's order book. The states are a number
# that goes up when anyone writes to the book, and the kind this tab looks
# at. Every figure is a derived that reads them and asks the database
# again. The actions that write an order say nothing about the page.
class Recipes::Stats::StatsChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Order = Recipes::Stats::Order
Book = Recipes::Stats::Book
CaravanJob = Recipes::Stats::CaravanJob
VIEWS = "app/recipes/stats"
def build_graph
@version = Hibiki::State.new(0)
@kind = Hibiki::State.new(Book::ALL)
@caravan = Hibiki::State.new("")
@book = Hibiki::Derived.new { Book.new(kind: @kind.value) }
# Each figure reads the version, so each broadcast makes it ask again, and
# answers with plain values, so an answer equal to the last one stops
# there. The kinds do not read the filter; the other three do.
kinds = Hibiki::Derived.new { @version.value; Book.new.kinds }
statuses = Hibiki::Derived.new { @version.value; @book.value.statuses }
coin = Hibiki::Derived.new { @version.value; @book.value.coin }
queue = Hibiki::Derived.new { @version.value; @book.value.queue }
Hibiki::Effect.new do
broadcast_morph target: "stats_kinds", partial: "#{VIEWS}/kinds",
locals: { kinds: kinds.value, kind: @book.value.kind }
end
Hibiki::Effect.new do
broadcast_morph target: "stats_statuses", partial: "#{VIEWS}/statuses", locals: { statuses: statuses.value }
end
Hibiki::Effect.new do
broadcast_morph target: "stats_coin", partial: "#{VIEWS}/coin", locals: { coin: coin.value }
end
Hibiki::Effect.new do
broadcast_morph target: "stats_queue", partial: "#{VIEWS}/queue", locals: { queue: queue.value }
end
transmit_value(:stats_caravan) { @caravan.value }
end
def filter(data) = @kind.value = data["kind"].to_s
def place(data) = Order.place(data["kind"])
# Moves on the next order at a step, among the kind this tab looks at.
def advance(data)
@book.value.orders.advance!(data["from"]) if Order::STATUSES.first(2).include?(data["from"])
end
def collect = @book.value.orders.collect!
def caravan
@caravan.value = CaravanJob.dispatch ? "A caravan is on its way." : "A caravan is already on the road."
end
private
def subscribed
super
return if subscription_rejected?
# A write by anyone: this tab, another tab, a job. The block runs on a
# cable thread, so it hops onto the graph thread before touching a
# signal.
stream_from Order::CHANGED do
graph_actor&.post { @version.value += 1 }
end
end
end
# frozen_string_literal: true
# The smithy's order book as one visitor reads it: every order, or those of
# one kind. Each figure on the page is a method here that asks the database
# and answers with plain values. The page renders its first figures from
# this, and the channel every figure after.
class Recipes::Stats::Book < Data.define(:kind)
Order = Recipes::Stats::Order
Coin = Data.define(:in_hand, :ready, :dearest)
ALL = Order::ALL
# How many open orders the queue shows.
QUEUE = 5
# What each step is called on the page.
STEPS = { "waiting" => "Waiting", "forging" => "At the forge", "done" => "Done" }.freeze
def self.gp(amount) = Recipes::Totals::Order.gp(amount)
# A kind the smith does not make falls back to everything.
def initialize(kind: ALL) = super(kind: Order::KINDS.include?(kind) ? kind : ALL)
# How many orders of each kind the book holds. The filter is not asked.
def kinds = Order::KINDS.index_with(0).merge(Order.group(:kind).count)
# How many orders stand at each step.
def statuses = Order::STATUSES.index_with(0).merge(orders.group(:status).count)
# What the work not yet finished is worth, what is ready to be paid for,
# and the dearest order still open, as a name and a price.
def coin
sums = orders.group(:status).sum(:price)
Coin.new(in_hand: sums.except("done").values.sum, ready: sums.fetch("done", 0),
dearest: orders.unfinished.order(price: :desc, id: :asc).pick(:item, :price))
end
# The open orders next in line, the oldest first.
def queue = orders.unfinished.order(:id).limit(QUEUE).map(&:entry)
# The write actions go through the filter too: "the next" is the next of
# the kind being looked at.
def orders = Order.of(kind)
end
# frozen_string_literal: true
# An order at the smithy in the Aggregate Stats recipe. There is one book,
# and every visitor writes to it: an order is placed, moved on a step, or
# handed over. No page is mentioned here. A commit broadcasts an empty message,
# and whoever is counting counts again.
#
# Recipes::Reset puts the starting book back each night, and `place` caps
# the table in between.
class Recipes::Stats::Order < ApplicationRecord
self.table_name = "recipes_stats_orders"
# A line of the queue: a plain copy of a row, compared by what it says.
Entry = Data.define(:id, :item, :kind, :price, :status)
# The steps an order goes through, in order, and what the smith makes.
STATUSES = %w[waiting forging done].freeze
KINDS = %w[weapon armour tool].freeze
ALL = "all"
# How many rows `place` leaves.
KEEP = 60
SEED_FILE = Rails.root.join("db/seeds/stats_orders.yml")
# Every open book listens here for a write by anyone.
CHANGED = "recipes:stats:orders"
after_commit { self.class.announce }
scope :unfinished, -> { where.not(status: "done") }
# The orders of one kind, or all of them.
def self.of(kind) = KINDS.include?(kind) ? where(kind:) : all
# An empty broadcast. Whoever receives it counts again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# A new order for something off the shelves of the Live Search recipe.
# The oldest rows go in the same transaction, so there is one broadcast.
def self.place(kind)
name, price = Recipes::Search::Item.where(kind: KINDS.intersection([ kind ])).order(Arel.sql("RANDOM()")).pick(:name, :price)
return unless name
transaction do
create!(item: name, kind:, price:).tap { prune! }
end
end
# Moves the oldest order at `from` one step on. The row is locked, so two
# visitors who strike together move two orders.
def self.advance!(from)
step = STATUSES[STATUSES.index(from) + 1]
transaction { oldest(from)&.tap { it.update!(status: step) } }
end
# Hands over the oldest finished order, which leaves the book.
def self.collect! = transaction { oldest("done")&.destroy! }
def self.oldest(status) = where(status:).order(:id).lock("FOR UPDATE SKIP LOCKED").first
# Loads the starting book from db/seeds/stats_orders.yml into an empty
# table. Safe to run again.
def self.seed!
insert_all(YAML.load_file(SEED_FILE)) if none?
end
# `delete_all` and `insert_all` run no callback, so `announce` is called
# by hand.
def self.reset!
transaction do
delete_all
seed!
end
announce
end
# Drops all but the newest rows.
def self.prune! = unscoped.where.not(id: unscoped.order(id: :desc).limit(KEEP).select(:id)).delete_all
def entry = Entry.new(id:, item:, kind:, price:, status:)
end
# frozen_string_literal: true
# A caravan comes to the smithy and its orders are placed one at a time.
# The job knows nothing of who is looking at the book. Each order it places
# is a commit, and the model's broadcast does the rest.
class Recipes::Stats::CaravanJob < ApplicationJob
# How many orders a caravan brings, and how long the road is closed to
# the next one.
LOAD = 5
COOLDOWN = 10.seconds
ON_THE_ROAD = "recipes:stats:caravan"
# Sends a caravan unless one set out a moment ago. Answers whether it did.
def self.dispatch(cache: Rails.cache)
return false unless cache.write(ON_THE_ROAD, true, unless_exist: true, expires_in: COOLDOWN)
perform_later
true
end
def perform(pause: 1)
LOAD.times do |n|
sleep pause if n.positive?
Recipes::Stats::Order.place(Recipes::Stats::Order::KINDS.sample)
end
end
end
<%# The page asks the same Book the channel does, so the first figures and
every later one agree. The buttons at the foot are rendered here and
never again. %>
<% book = Recipes::Stats::Book.new %>
<%= island Recipes::Stats::StatsChannel, class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">The smithy's order book</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</header>
<%= render "app/recipes/stats/kinds", kinds: book.kinds, kind: book.kind %>
<%= render "app/recipes/stats/statuses", statuses: book.statuses %>
<%= render "app/recipes/stats/coin", coin: book.coin %>
<%= render "app/recipes/stats/queue", queue: book.queue %>
<footer class="mt-4 border-t-2 border-base-300 pt-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold">Order</span>
<% { "weapon" => "A weapon", "armour" => "Armour", "tool" => "A tool" }.each do |kind, label| %>
<%= tag.button label, id: "stats_place_#{kind}", type: "button", class: "btn btn-sm", **on(:place, with: { kind: }) %>
<% end %>
<%= tag.button "Send a caravan", id: "stats_caravan", type: "button", class: "btn btn-primary btn-sm ml-auto", **on(:caravan) %>
</div>
<p class="mt-2 min-h-5 text-right text-sm opacity-70"><%= reactive :stats_caravan, "" %></p>
</footer>
<% end %>
<%# locals: (kinds:, kind:) -%>
<%# Morphed by the channel when a count changes or this tab picks a kind.
The counts are of the whole book, whatever the tab is looking at. %>
<% names = { "weapon" => "Weapons", "armour" => "Armour", "tool" => "Tools" } %>
<div id="stats_kinds" class="mt-3 flex flex-wrap gap-1.5" role="group" aria-label="Count a kind">
<% { Recipes::Stats::Book::ALL => kinds.values.sum, **kinds }.each do |name, count| %>
<%= tag.button id: "stats_kind_#{name}", type: "button", "aria-pressed": kind == name,
class: [ "btn btn-xs", { "btn-primary": kind == name } ], **on(:filter, with: { kind: name }) do %>
<%= names.fetch(name, "All") %> <b class="tabular-nums"><%= count %></b>
<% end %>
<% end %>
</div>
<%# locals: (statuses:) -%>
<%# Morphed by the channel when a count changes. A morph keeps the bar's
segments, so they slide to their new widths. %>
<% total = statuses.values.sum
colours = { "waiting" => "bg-warning", "forging" => "bg-error", "done" => "bg-success" }
moves = { "waiting" => "Fire one up", "forging" => "Finish one", "done" => "Hand one over" } %>
<div id="stats_statuses" class="mt-4">
<div class="grid grid-cols-3 gap-2">
<% statuses.each do |status, count| %>
<div id="stats_<%= status %>" class="flex flex-col items-center gap-1 rounded-box border-2 border-base-300 p-2 text-center">
<span class="flex items-center gap-1.5 text-xs font-semibold">
<span class="size-2.5 rounded-full <%= colours[status] %>" aria-hidden="true"></span>
<%= Recipes::Stats::Book::STEPS[status] %>
</span>
<b id="stats_<%= status %>_count" class="text-3xl leading-none tabular-nums"><%= count %></b>
<%= tag.button moves[status], id: "stats_move_#{status}", type: "button", class: "btn btn-xs", disabled: count.zero?,
**(status == "done" ? on(:collect) : on(:advance, with: { from: status })) %>
</div>
<% end %>
</div>
<div id="stats_bar" class="mt-2 flex h-3 overflow-hidden rounded-full border-2 border-base-300 bg-base-200" aria-hidden="true">
<% statuses.each do |status, count| %>
<div id="stats_bar_<%= status %>" class="h-full <%= colours[status] %> transition-[width] duration-500 ease-out motion-reduce:transition-none"
style="width: <%= total.zero? ? 0 : count * 100.0 / total %>%"></div>
<% end %>
</div>
</div>
<%# locals: (coin:) -%>
<%# Morphed by the channel when a sum changes. An order that moves from
waiting to the forge changes none of them, and nothing is sent. %>
<% gp = Recipes::Stats::Book.method(:gp) %>
<dl id="stats_coin" class="mt-4 grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-sm tabular-nums">
<dt>Work in hand</dt>
<dd id="stats_in_hand" class="text-right font-bold"><%= gp.(coin.in_hand) %></dd>
<dt>Ready to be paid for</dt>
<dd id="stats_ready" class="text-right font-bold"><%= gp.(coin.ready) %></dd>
<dt>Dearest order open</dt>
<dd id="stats_dearest" class="text-right">
<% if coin.dearest %>
<%= coin.dearest.first %>, <b><%= gp.(coin.dearest.last) %></b>
<% else %>
<span class="opacity-70">None</span>
<% end %>
</dd>
</dl>
<%# locals: (queue:) -%>
<%# Morphed by the channel when the next few open orders change. Rows keep
their ids, so the ones that stay are left where they are and the ones
that go slide shut. %>
<div id="stats_queue" class="mt-4">
<h3 id="stats_queue_title" class="text-sm font-semibold">Next in line</h3>
<ul id="stats_queue_list" class="mt-1 border-t-2 border-base-300">
<% queue.each do |order| %>
<li id="stats_order_<%= order.id %>" data-motion
class="grid grid-rows-[1fr] transition-[grid-template-rows,opacity] duration-300 ease-out
data-motion-leaving:grid-rows-[0fr] data-motion-leaving:opacity-0 motion-reduce:transition-none">
<div class="min-h-0 overflow-hidden">
<div class="flex items-center gap-3 border-b-2 border-base-300 px-2 py-1.5 text-sm">
<span class="min-w-0 flex-1 font-semibold"><%= order.item %></span>
<span class="<%= class_names("badge badge-sm", "badge-error": order.status == "forging") %>"><%= Recipes::Stats::Book::STEPS[order.status].downcase %></span>
<b class="w-20 shrink-0 text-right whitespace-nowrap tabular-nums"><%= Recipes::Stats::Book.gp(order.price) %></b>
</div>
</div>
</li>
<% end %>
</ul>
<% if queue.empty? %>
<p id="stats_queue_empty" class="px-2 py-3 text-sm opacity-70">Nothing is waiting. The anvil is quiet.</p>
<% end %>
</div>
The panel above is a smithy's order book. Place an order, such as "A weapon", and you can see a new order land in the book and every figure it touches update. Try opening this page in a second window and put the two side by side: a write in either window moves the figures in both. "Send a caravan" starts a background job that places five orders a second apart, and both windows count each one as it lands. Here the model says only that something changed, and each open page works its own figures out again.
The book is shared by every visitor, so the numbers may move while you watch. The orders are reset every night.
How it fits together
The book 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
@version is a state holding a number that goes up whenever
anyone writes to the book, and @kind is a state holding the
kind this tab is looking at. The channel's public methods are its
actions, the methods the page may call. place,
advance and collect write an order to the
table and return; filter writes the chosen kind to
@kind.
A derived is a value worked out from other values, and worked
out again when one of them changes. Here each figure on the page is a
derived. It reads @version first and then asks
Book, a plain value object with a method for each figure.
The book asks the database and answers with plain values: a hash of
counts, a sum, a list of rows. The page renders its first figures from
the same book, so the first render and every later one agree.
An effect is a block of code that runs once, and runs again whenever a value it read has changed. There are four, one for each figure. Each renders its part of the panel and sends it to the page, where it is morphed into place. This is what happens when you press "Fire one up":
-
The button calls
advance, which moves the oldest waiting order to the forge. After the commit the model broadcasts an empty message on one stream (announce). -
Every open page receives that broadcast and adds one to its
@version. -
The four deriveds read
@version, so each asks the book again. The step counts come back different, and so does the queue, where the order's badge now says "at the forge". - The effects that read a changed answer run again, and the counts and the queue are morphed in.
We never wrote in the channel which write moves which figure: every figure works itself out through reactivity.
The announcement
The model's work is minimal. After each commit it broadcasts an empty message, which says only that something changed, and leaves each page to work out what that means for the figures it shows:
# The model. A commit of any kind broadcasts an empty message.
CHANGED = "recipes:stats:orders"
after_commit { self.class.announce }
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# The channel. Every open book listens, and each broadcast adds one to a number.
def subscribed
super
return if subscription_rejected?
stream_from Order::CHANGED do
graph_actor&.post { @version.value += 1 }
end
end
Placing an order, moving one on and handing one over are a create, an
update and a destroy; one after_commit covers all three.
Only changed figures are sent
After each broadcast, every derived asks the database again and compares the
new answer with its last one, using ==. If the two are
equal, the effect that reads it does not run, and nothing is sent for
that figure. This works only when == compares what the
figure shows. Hashes, numbers and Data rows do: two of
them are equal when their contents are. A relation does not. Two
relations are equal when their queries are, and the query is the same
every time, so a derived holding one would never see a change.
@version = Hibiki::State.new(0)
@kind = Hibiki::State.new(Book::ALL)
@book = Hibiki::Derived.new { Book.new(kind: @kind.value) }
# Reading the version is what makes a figure ask again after each broadcast.
kinds = Hibiki::Derived.new { @version.value; Book.new.kinds }
statuses = Hibiki::Derived.new { @version.value; @book.value.statuses }
coin = Hibiki::Derived.new { @version.value; @book.value.coin }
queue = Hibiki::Derived.new { @version.value; @book.value.queue }
# One of the four effects. It runs when `coin` gives a different answer,
# and at no other time.
Hibiki::Effect.new do
broadcast_morph target: "stats_coin", partial: "#{VIEWS}/coin", locals: { coin: coin.value }
end
Each tab counts through its own filter
A Turbo broadcast sends the same HTML to everyone, so a visitor who wants to
see one kind at a time needs figures of their own. Here the kind is a
state in the tab's own channel, and three of the figures read it
through @book. Choosing Weapons asks those three again,
for weapons only, and leaves the counts on the kind buttons alone,
since they count the whole book. Every tab receives the same
announcement, and each works out its own figures from it. The buttons under the counts go
through the filter too: with Weapons chosen, "Fire one up" takes the
next weapon and is disabled when no weapon is waiting.
Writing from outside a channel
The channel's actions write to the table and return without telling the page anything. The caravan is a background job that knows nothing about the channel, and its orders still show up on every open page. This works because the model sends the broadcast after each commit, whoever made it. A console, a controller or a webhook could write orders too, and the page would follow:
# The channel's actions. Each writes to the table and returns.
def place(data) = Order.place(data["kind"])
def collect = @book.value.orders.collect!
# The job. It has never heard of the channel.
def perform(pause: 1)
LOAD.times do |n|
sleep pause if n.positive?
Order.place(Order::KINDS.sample)
end
end
The broadcast is sent from an after_commit callback, so a
write that skips callbacks sends none. The nightly reset clears the
table with delete_all, which runs no callback, so it calls
announce itself. Placing an order also deletes the oldest
rows with delete_all, in the same transaction. Only the new
order runs the callback, so the page hears one broadcast and the
figures move once:
# `delete_all` and `insert_all` run no callback, so `announce` is called
# by hand.
def self.reset!
transaction do
delete_all
seed!
end
announce
end
# A new order and the rows it pushes out are one transaction, so they are
# one commit and one broadcast.
def self.place(kind)
# ...
transaction do
create!(item: name, kind:, price:).tap { prune! }
end
end
A job usually runs in another process. For its broadcast to reach the page, the app needs a cable adapter that works across processes, such as Solid Cable or Redis.
One more figure
Suppose the smith wants the average price of an open order on the page. That is a method on the book, a derived in the channel and a line on the page. The model, the actions and the job stay as they are:
# Recipes::Stats::Book: the question.
def average = orders.unfinished.average(:price).to_i
# The channel, in build_graph: who asks it, and when.
average = Hibiki::Derived.new { @version.value; @book.value.average }
transmit_value(:stats_average) { Book.gp(average.value) }
# The page: where the answer goes.
# <%= reactive :stats_average, Book.gp(book.average) %>
The same feature in stock Rails
broadcasts_to sends one record's partial, and a count is
not in any record. So the usual stock Rails build has a job that works
every figure out and broadcasts each one. Here is a sketch of it. It is
not run here, and it leaves out Book, which would be the
same in both versions, the partials and the caravan's job.
class Order < ApplicationRecord
STATUSES = %w[waiting forging done].freeze
KINDS = %w[weapon armour tool].freeze
# `broadcasts_to` would send this row's partial. No row holds a count,
# so the counting is a job of our own, enqueued after every write.
after_commit { StatsBroadcastJob.perform_later }
scope :unfinished, -> { where.not(status: "done") }
def self.of(kind) = KINDS.include?(kind) ? where(kind:) : all
def self.place(kind)
# ...as in the Hibiki version
end
# No callback runs after `delete_all` or `insert_all`, so the job is
# enqueued by hand.
def self.reset!
transaction do
delete_all
seed!
end
StatsBroadcastJob.perform_later
end
end
class StatsBroadcastJob < ApplicationJob
LENSES = [ Book::ALL, *Order::KINDS ].freeze
# A broadcast is one piece of HTML for everyone on a stream. A visitor
# who looks at one kind needs figures of their own, so there is a stream
# for each kind, and every write renders every figure for each of them:
# sixteen renders, whether the write changed the figure or not, and
# whether or not anyone is looking.
def perform
kinds = Book.new.kinds
LENSES.each do |kind|
book = Book.new(kind:)
stream = [ :orders, kind ]
Turbo::StreamsChannel.broadcast_replace_to stream, target: "stats_kinds",
partial: "orders/kinds", locals: { kinds:, kind: }
Turbo::StreamsChannel.broadcast_replace_to stream, target: "stats_statuses",
partial: "orders/statuses", locals: { statuses: book.statuses }
Turbo::StreamsChannel.broadcast_replace_to stream, target: "stats_coin",
partial: "orders/coin", locals: { coin: book.coin }
Turbo::StreamsChannel.broadcast_replace_to stream, target: "stats_queue",
partial: "orders/queue", locals: { queue: book.queue }
end
end
end
class OrdersController < ApplicationController
before_action :set_book
# The first render. The same four figures the job works out, asked for a
# second time here, with the same locals under the same names.
def index
end
# The page changes when the broadcast arrives, so each action answers
# with nothing.
def create
Order.place(params[:order_kind])
head :no_content
end
def advance
@book.orders.advance!(params[:from]) if Order::STATUSES.first(2).include?(params[:from])
head :no_content
end
def collect
@book.orders.collect!
head :no_content
end
def caravan
CaravanJob.dispatch
head :no_content
end
private
# The filter is in the address, and every form sends it back.
def set_book
@book = Book.new(kind: params[:kind])
end
end
<%# The stream this visitor's figures arrive on. Choosing a kind is a visit
to another address, which subscribes to another stream. %>
<%= turbo_stream_from :orders, @book.kind %>
<%= render "orders/kinds", kinds: @book.kinds, kind: @book.kind %>
<%= render "orders/statuses", statuses: @book.statuses %>
<%= render "orders/coin", coin: @book.coin %>
<%= render "orders/queue", queue: @book.queue %>
<%# Every button is a form, and each carries the filter. The buttons under
the counts are forms too, inside a partial the job renders with no
request, so that partial is handed the kind as one more local. %>
<% Order::KINDS.each do |kind| %>
<%= button_to "Order #{kind}", orders_path(kind: @book.kind, order_kind: kind) %>
<% end %>
<%= button_to "Send a caravan", caravan_orders_path(kind: @book.kind) %>
resources :orders, only: %i[index create] do
collection do
post :advance
post :collect
post :caravan
end
end
The counting is as short as before. What is new is a list, kept by hand in the job, of every figure on the page and the locals each one takes, and a second copy of it in the view. The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| A count follows a write by anyone | A callback, a job, and a broadcast line in the job for each figure | A callback that broadcasts an empty message, and one state that counts them |
| Which figures a write moves | All of them are rendered and sent every time | Each derived compares its answer; an equal one sends nothing |
| The first render and the later ones agree | The locals are listed twice, in the view and in the job | The page and the channel ask the same four methods |
| A filter for one visitor | A stream for each filter, every figure rendered for each on every write, and the filter carried in the address and in each form | One state in the tab's graph |
| A filter with free input, such as a price range | Cannot be a stream; the page has to be refreshed instead | The same one state |
| One more figure | A method, a partial, a line in the view and a line in the job | A method, a derived and a line in the view |
| A write that runs no callback | Enqueue the job by hand | Call announce by hand |
| A caravan of five orders, nobody looking | Five jobs and eighty renders | Five empty broadcasts that nobody receives |
| A write with a hundred tabs open | Rendered once for each stream and shared | Each tab asks the database its four questions |
The last row is the honest cost. A broadcast renders once and everyone gets the copy; here every open tab counts for itself, which is what lets each one count something different. In your real-world app, you'd want to keep the queries cheap and indexed, and if the writes come in hundreds, announce once after the batch instead of once a row.
Stock Rails has a second answer with
broadcasts_refreshes. The model says the page is stale, and
every open page requests itself again and morphs in the difference. That
is one line, it needs no list of figures, and the filter works because
each page asks with its own address. For a small page it is a good
answer. Its cost is in what a write sets off: a full request from every
open tab, through the router, the controller and the layout, to render
the whole page for the sake of three numbers, and whatever the visitor
has chosen has to be in the address for the page to come back the same.
Here a write sets off the four questions and sends the parts whose
answers changed.