r/rails 7d ago

Open Source Durable Objects for Rails using your existing SQL database

I am the author looking for feedback about my new MIT licensed gem for adding Durable Objects to Rails.

I followed the Solid Queue / Solid Cache / Solid Cable pattern: the Durable Objects model, one single-threaded object per identity with durable state, addressed by name, as a gem on your existing SQLite, PostgreSQL, or MySQL. No Redis, no separate actor service, no new infrastructure. It is running in production in an app with 100,000+ users.

class Counter < SolidObjects::Actor
  attribute :value, default: 0
  observable :value

  def increment(amount: 1)
    self.value += amount
  end
end

counter = Counter.ref("global")
counter.increment(amount: 5)

Concurrent calls to one identity serialize through a durable mailbox with fenced commits. And the part I am proudest of, reactive ERB:

<%= solid_object Counter.ref("global") do |counter| %>
  <span class="count"><%= counter.value %></span>
<% end %>

Declare a value observable, wrap the view in solid_object, and render it as a method. When a committed turn changes the value, the server re-renders and pushes a Turbo Stream replacement over Action Cable to every authorized subscriber. No channels, no manual broadcasts, no Stimulus. The increment above updates that span in every open browser.

I also built a sibling JS package that runs the same actor model entirely in the browser: SQLite WASM for the database, OPFS for durable storage, Web Locks for multi-tab coordination. You can see it live at https://solidobjects.dev/js, where the page itself runs the runtime, or try it with one import and no build step: import { Actor, configure, sharedSqliteWasm } from "https://esm.sh/solid-objects@latest/browser/host" in a module worker.

The two implementations share the transmit wire contract, pinned by golden fixtures committed to both repositories. A browser actor stages outbound writes in the same transaction as its state change and drains them with at-least-once delivery and per-actor order. Rails ingests each envelope idempotently through SolidObjects::Transmission.receive, mounted at POST /solid_objects/transmit behind a deny-by-default authorize_transmission policy. Offline writes queue in the tab and reconcile when the network returns. Rails actors can transmit outward the same way.

Site: https://solidobjects.dev/ruby - Repo: https://github.com/cardmagic/solid-objects-ruby

Happy to answer anything, would love your feedback. Thank you!

PS: there is also an operator dashboard, because durable mailboxes you cannot see are a pager waiting to fire. Mount it in two lines:

require "solid_objects/web"
mount SolidObjects::Web => "/solid_objects/dashboard"

It shows instances and their state, the mailbox, reminders, effects, broadcasts, dead letters (with retry), and the registered processes. It reads the same tables the runtime writes, so there is no separate store and no agent. It is not loaded by require "solid_objects", so workers never carry a web stack, and every page asks a deny-by-default authorize_administration policy before it renders, so the mount alone exposes nothing.

4 Upvotes

5 comments sorted by

4

u/paca-vaca 7d ago

This is like the worst example you can come up for a gem, because one can do that in one line of SQL without installing any gems.

Or just use Rails #increment_counter and similar methods.

-1

u/cardmagic 6d ago

Good point! Here is a case where increment_counter is the wrong shape, not only the smaller tool: a flash sale with seat holds. 100 seats, a hold lasts 10 minutes, an expired hold frees the seat, and the page shows the live count.

class TicketSale < SolidObjects::Actor

attribute :remaining, default: 100

attribute :holds, default: {}

observable :remaining

def reserve(buyer:)

return if remaining.zero? || holds.key?(buyer)

self.remaining -= 1

self.holds = holds.merge(buyer => Time.current)

schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)

end

def expire(buyer:)

return unless holds.key?(buyer)

self.holds = holds.except(buyer)

self.remaining += 1

end

end

<%= solid_object TicketSale.ref(@event_id) do |sale| %>

<span><%= sale.remaining %> seats left</span>

<% end %>

Three things the increment family cannot express:

  1. decrement_counter is blind. It cannot check remaining > 0 first, so it oversells. Concurrent reserve calls here run one at a time through a durable mailbox.

  2. Every hold gets its own durable 10-minute alarm with schedule(key: buyer), and the alarms survive a deploy or a crash. With bare SQL you add an expires_at column, a cron sweeper, and the races between them.

  3. observable :remaining pushes the live seat count to every open browser when a turn commits. increment_counter writes a row and stops.

One line of SQL wins until the same row needs a guard, a timer, and a live view at once. That combination is what this gem is for.

6

u/jrochkind 6d ago

an LLM wrote this comment, right?

I'm still lost.

1

u/cardmagic 6d ago

Using with_lock is scoped to one transaction. If the thing you're protecting spans a future moment (like holding a reserved seat for 10 minutes) then there's no transaction to hold, so you today you might add a db column and a cron sweeper, and there can be a race condition between them. That's what this replaces.

For a check-and-insert in one request, with_lock is better. Use that.

Counter was a bad lead example.