Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

5. Work that happens on its own

A durable object: state that outlives a request, and a timer that fires when nobody is visiting.

Count how many people looked at the board today, and roll the count over at midnight.

Neither half of that is possible in a request child. A counter in a variable disappears when the process exits, which is at the end of the request. And “at midnight” is work with no request behind it at all, in a platform with no threads and no job queue.

Both are what a durable object is for: a named, long-lived object that lives in a second process — the app’s worker — with its own SQLite database and one timer.

The class

# lib/stats.rb
# frozen_string_literal: true
class Stats < Durable
  def setup = timer(at: midnight)

  def hit
    storage.run "INSERT INTO days (on_date, views) VALUES (?, 1) " \
                "ON CONFLICT (on_date) DO UPDATE SET views = views + 1", today_date
    nil
  end

  def today = storage.first("SELECT views FROM days WHERE on_date = ?", today_date)&.fetch("views").to_i

  def on_timer
    storage.run "DELETE FROM days WHERE on_date < ?", (Time.now - 30 * 86_400).strftime("%Y-%m-%d")
    timer at: midnight
  end

  private

  def today_date = Time.now.strftime("%Y-%m-%d")

  def midnight
    now = Time.now
    Time.local(now.year, now.month, now.day).to_i + 86_400
  end
end

And its schema, in db/objects/stats/001_days.sql — the directory name is the class name underscored:

CREATE TABLE days (
  on_date TEXT PRIMARY KEY,
  views INTEGER NOT NULL DEFAULT 0
);

Every Stats object gets its own copy of that table in its own file.

Calling it

require_relative "lib/stats"

get "/" do
  Stats.get("visits").hit
  render :index, notes: open_notes, views: Stats.get("visits").today
end

Stats.get("visits") returns a handle to the object with that id. There is no separate “create”: the object exists from the first call, and setup runs inside it. Ids are Strings; Stats.list gives you all of them and Stats.count how many there are.

The handle is generated at build time by reading your class: it gets one forwarding method per public method you defined, and nothing else. Note that this does not mean a misspelling is caught at build time — Stats.get("visits").hitt compiles and raises NoMethodError when the line runs, the same as any other undefined method with an explicit receiver. What the generated handle does buy you is that the set of callable methods is fixed and readable in .melee/objects.rb.

The four rules

storage persists, instance variables do not. An ivar is a cache that survives while the object happens to be loaded and vanishes when the worker unloads it — on idle, on deploy, on restart. Anything that must survive goes through storage, which is the same query/first/run/transaction API as db, plus a key/value shortcut:

storage.put("last_seen", Time.now.to_i)
storage.get("last_seen")

Note storage, never db: the object has its own database, and the app’s is not for it.

Arguments and return values must be JSON-shaped. They cross a process boundary as JSON, so String, Integer, Float, true, false, nil, and Arrays and String-keyed Hashes of those. A Time or a Symbol raises ArgumentError at run time. That is why today returns .to_i of a column rather than the row, and why hit ends in an explicit nilstorage.run returns a row count, which would be fine, but being deliberate about what crosses is a habit worth having.

Parameters are positional only. No keyword arguments, no splat, no block. The build step rejects anything else with a file and line.

One call at a time, per app. The worker is single-threaded, and it hosts every durable object in the app. A slow method on one object delays calls to every other one. Keep them quick — and note that the two calls in the route above (hit and today) are two round trips.

The timer

def setup = timer(at: midnight)

def on_timer
  # ... do the work ...
  timer at: midnight
end

One pending timer per object. timer sets it, replacing whatever was there; cancel_timer clears it; and when it fires the worker calls on_timer. There is no repeating timer — re-arming inside on_timer is how you get one, and it means a run that fails does not silently schedule itself forever.

What makes this more than a cron line is the last piece: the worker tells the server when its next timer is due, so the server starts the app at that moment even though nobody has visited. An app that is asleep still wakes up for its own timer.

Under melee dev there is no separate worker process — the dev server runs the objects in-process and checks for due timers about once a second, printing a line when one fires:

timer Stats/visits -> on_timer

Same code path, no socket in between.

The rest of the app

views/index.erb gains the count:

<%# locals: (notes:, views:) %>
<ul class="notes">
  <% notes.each do |note| %>
    <%== partial :note, note: note %>
  <% end %>
</ul>
<p class="count"><%= notes.size %> open, <%= views %> views today. <a href="/admin">Admin</a></p>

Restart, load the page a few times, and the count climbs and stays climbed across restarts.

Next

Deploying it.

Full detail: Durable objects and Background jobs and scheduled work.