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 nil — storage.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
Full detail: Durable objects and Background jobs and scheduled work.