Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 89 additions & 88 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,17 @@ sale = TicketSale.ref("event-42")
sale.reserve(buyer: current_user.id)
```

That example wants three things from the same number. It must never go below
zero. It must give the seat back if the buyer does not pay within ten minutes.
It must show the current count to everyone watching the page.
The example needs three behaviors from the same number:

The first is one line of SQL. The second is an `expires_at` column plus a cron
job that sweeps it. The third is a broadcast on every code path that changes
the number. The combination is what costs, not any one of them. Here the guard,
the ten-minute alarm, and the live count are one class, and they commit
together.
- the count must never go below zero;
- the seat must return if the buyer does not pay within ten minutes; and
- every open page must show the current count.

Written by hand, the first needs one line of SQL, the second needs an
`expires_at` column and a cron job that sweeps it, and the third needs a
broadcast on every code path that changes the number. These three must agree
with each other. In the example above the guard, the alarm, and the live count
are one class, and they commit in one transaction.

`TicketSale / event-42` is a logical identity. Like a Durable Object named with
`idFromName`, it can be addressed from anywhere without first creating or
Expand All @@ -65,11 +67,10 @@ its ordered turns one at a time, persists its state, and deactivates it when
idle. Different identities run concurrently, so two events never wait on each
other.

The invocation model is the first adoption decision. A direct call or `sync`
needs no worker fleet, because the Rails caller helps execute the actor through
the same mailbox, lease, and fencing path a worker would use. `async` only
enqueues and returns a `MessageReference`, so a runtime process handles it
later.
There are two ways to invoke an actor. A direct call or `sync` needs no worker
fleet, because the Rails caller helps execute the actor through the same
mailbox, lease, and fencing path a worker would use. `async` only enqueues and
returns a `MessageReference`, so a runtime process handles it later.

| Call | Returns | Worker fleet required? |
| --- | --- | --- |
Expand Down Expand Up @@ -110,54 +111,54 @@ are rejected so they cannot escape a later actor failure. Use a same-database

## Why not just use transactions?

Often you should. If the whole job is read a row, decide, write it back, and
answer the user, then `with_lock` does that and you need nothing else
installed. Reach for it first.
Often you should. If the whole job is to read a row, decide, write it back, and
answer the user, then `with_lock` does that, and you install nothing else.

The argument for an actor is scope, not discipline. A lock is scoped to one
transaction, on one connection, in one process. The ticket sale above leaves
that scope on one line: the hold expires in ten minutes, and no transaction
stays open for ten minutes.
An actor helps when the work goes outside the scope of a lock. A lock holds for
one transaction, on one connection, in one process. The ticket sale above goes
outside that scope on one line: the hold expires in ten minutes, and no
transaction stays open for ten minutes.

Any column named `expires_at`, `scheduled_at`, or `next_run_at` is evidence
that the critical section already outlived the lock that was supposed to cover
it. What follows such a column is a sweeper that looks for due rows, and then a
race between that sweeper and the next writer of the same row. The column, the
sweeper, and the race are what a Solid Objects actor replaces.
A column named `expires_at`, `scheduled_at`, or `next_run_at` shows that the
critical section already outlived the lock that was supposed to cover it. Such
a column usually comes with a sweeper that looks for due rows, and the sweeper
can race the next writer of the same row. A Solid Objects actor replaces the
column, the sweeper, and the race.

Three things a lock cannot reach:
A lock cannot reach three cases:

- work that fires at a future moment, when no transaction of yours is open;
- work that must survive a process restart, which rules out an in-process
timer; and
- a fan-in whose critical section spans many jobs over minutes, such as an
import that counts its own chunks as each one finishes.

If it all happens inside one request, use a lock.
If all the work happens inside one request, use a lock.

## Is it worth installing here?

Worth it when several requests, jobs, or processes act on the same cart, chat
Install it when several requests, jobs, or processes act on the same cart, chat
room, device twin, game room, long-lived workflow, or refillable quota, and
each next action needs the last committed state. Worth it when that same thing
also owns work that fires later, or a number a live page must show.

Two of those have a limit. A workflow fits when one entity owns the mutable
state and its mailbox holds the step order. A durable execution engine that
replays named steps from a step log is a different tool, because Solid Objects
redelivers an ordered message and retries it. A quota fits when one identity
checks it a few times per minute, because each check is one durable ordered
message with a retained history row. A limiter that every request to that
identity touches does not fit here.

Not worth it for a plain counter, a single-row update inside one transaction, a
stateless job, bulk ingestion or a data-parallel pipeline, CPU-heavy work, a
large JSON document that belongs in normalized rows, high-QPS request reads, or
a rate-limit counter that every request touches. One hot identity is serialized
on purpose, so making everything one identity makes a queue.

High-QPS reads and hot identities are where this runtime stops being the right
tool on its own. [Solid Objects Pro](https://solidobjects.pro/) is a commercial
each next action needs the last committed state. It also helps when that same
thing owns work that fires later, or a number that a live page must show.

Two of those cases have a limit. A workflow fits when one entity owns the
mutable state and its mailbox holds the step order. A durable execution engine
that replays named steps from a step log is a different tool, because Solid
Objects redelivers an ordered message and retries it. A quota fits when one
identity checks it a few times per minute, because each check is one durable
ordered message with a retained history row. A limiter that every request to
that identity touches does not fit here.

Do not install it for a plain counter, a single-row update inside one
transaction, a stateless job, bulk ingestion or a data-parallel pipeline,
CPU-heavy work, a large JSON document that belongs in normalized rows, high-QPS
request reads, or a rate-limit counter that every request touches. Solid
Objects serializes one identity on purpose. If you put all the work in one
identity, you get a queue.

This runtime alone does not handle high-QPS reads or hot identities well.
[Solid Objects Pro](https://solidobjects.pro/) is a commercial
performance layer for this gem that adds grouped commits, which coalesce
concurrent writes into fewer database commits; optional ephemeral operations,
which take loss-tolerant calls out of the durable journal; and materialized
Expand All @@ -167,10 +168,10 @@ mailbox work.
Before moving an existing surface, read the
[fit and anti-pattern guide](docs/fit.md), the
[measured costs](docs/benchmarks.md), and the
[migration cookbook](docs/migrating-existing-state.md). This ports the
programming model, not Cloudflare's edge runtime; the exact Rails guarantees
are in [correctness](docs/correctness.md), and this is an early release with no
production-readiness claim.
[migration cookbook](docs/migrating-existing-state.md). This gem ports the
programming model. It does not port Cloudflare's edge runtime. The
[correctness guide](docs/correctness.md) gives the exact Rails guarantees. This
is an early release, and it makes no production-readiness claim.

## Cloudflare Durable Objects for Rails

Expand Down Expand Up @@ -202,20 +203,20 @@ worker can finish running Ruby but cannot commit.
## Reactive ERB

For a comment count or a dashboard number, lock the row, update it, and call
`broadcast_replace_to`. That is less code than this gem and it works.
`broadcast_replace_to`. That needs less code than this gem, and it is
sufficient for that case.

It gets harder when several people write to the same record at once. Each
request renders the fragment in its own process and pushes it. The lock decided
who wrote first, but it has no say over which push arrives last, so a viewer
can be left looking at the older number. The second gap is that the push is not
part of the save: if the process dies after the database commits and before the
push goes out, the browser keeps a wrong number and nothing corrects it.
Two problems occur when several people write to the same record at once. First,
each request renders the fragment in its own process and pushes it. The lock
sets the write order, but it does not set the order in which the pushes arrive,
so a viewer can keep the older number. Second, the push is not part of the save.
If the process stops after the database commits and before the push goes out,
the browser keeps a wrong number, and nothing corrects it.

An observable is the alternative. The state change and the broadcast row commit
together, a worker delivers that row and retries until it succeeds, and Cable
ignores an older `(instance_id, state_revision)` pair after a newer one. A
viewer cannot end up on an older number, though delivery itself is still at
least once.
An observable prevents both problems. The state change and the broadcast row
commit together, a worker delivers that row and retries until it succeeds, and
Cable ignores an older `(instance_id, state_revision)` pair after a newer one. A
viewer cannot end up on an older number. Delivery is still at least once.

```erb
<%= solid_object @sale, authorization_context: current_user do |sale| %>
Expand All @@ -224,15 +225,15 @@ least once.
<% end %>
```

The two observables in the ticket sale are what make that template live: a
committed turn that changes `remaining` replaces the span, and one that changes
`holds` re-renders the component from `actors/ticket_sale/_buyers`. Observables
are invalidation-only unless declared `broadcast: :value`, which is why
`remaining` carries it and `holds` does not: only an opted-in scalar sends its
value to every authorized subscriber, and rendering an invalidation-only
observable as a span raises. Per-viewer state belongs in `broadcast_payload`.
Signed tokens protect integrity, not access: rendering, Cable, and every
refresh each authorize again.
The two observables in the ticket sale make that template live. A committed
turn that changes `remaining` replaces the span, and a turn that changes `holds`
re-renders the component from `actors/ticket_sale/_buyers`. An observable only
invalidates unless you declare `broadcast: :value`, so `remaining` carries that
option and `holds` does not. Only an opted-in scalar sends its value to every
authorized subscriber, and a template that renders an invalidation-only
observable as a span raises an error. Put per-viewer state in
`broadcast_payload`. Signed tokens protect integrity. They do not grant access:
rendering, Cable, and every refresh authorize again.

Reactive views require `turbo-rails`, an Action Cable adapter, and
`mount SolidObjects::Engine => "/solid_objects"`. They are optional; the actor
Expand All @@ -256,18 +257,18 @@ The doctor validates configuration and schema shape, reports authorization
posture and live roles, and completes a real synchronous actor round-trip
without a worker.

The generated initializer is intentionally inert: all five policies deny by
default. Replace them before sending messages, querying state, destroying
actors, subscribing to streams, or mounting administration routes. Knowledge of
an actor ID or a signed stream token is never authorization. Read the
In the generated initializer, all five policies deny by default. Replace them
before you send messages, query state, destroy actors, subscribe to streams, or
mount administration routes. An actor ID or a signed stream token is not
authorization. Read the
[policy reference](docs/authorization.md) first. Upgrades, the RuboCop
exclusion for engine migrations, and Sorbet RBI generation are in the
[operations guide](docs/operations.md#installing-and-upgrading).

## Worker requirements

Synchronous actors can be adopted without adding a long-running process. Start
the runtime when the feature introduces asynchronous delivery or outboxes:
You can adopt synchronous actors without a long-running process. Start the
runtime when the feature adds asynchronous delivery or outboxes:

| Feature | Runtime roles required |
| --- | --- |
Expand All @@ -286,12 +287,12 @@ run beside the built-in roles; see the

## Defining an actor

`TicketSale` above is the whole shape. Class-level `attribute` declarations are
the per-object durable storage schema. Public instance methods are durable
message handlers, so declare helpers private. Attributes also become ordered
read queries on a reference: `sale.remaining` goes through the mailbox, while
`sale.snapshot.remaining` reads the most recently committed state without one
and does not activate a missing actor.
The `TicketSale` class above shows the full structure. Class-level `attribute`
declarations are the per-object durable storage schema. Public instance methods
are durable message handlers, so declare helpers private. Attributes also
become ordered read queries on a reference: `sale.remaining` goes through the
mailbox, while `sale.snapshot.remaining` reads the most recently committed
state without one and does not activate a missing actor.

State, arguments, results, effects, and reminder arguments accept
JSON-compatible values only, and Solid Objects never deserializes Ruby
Expand Down Expand Up @@ -356,9 +357,9 @@ durable for audit, actor state rolls back, and no later turn is blocked. A code
must match `\A[A-Za-z_][A-Za-z0-9_]*\z`, and an invalid one raises
`SolidObjects::InvalidRejectionCode`.

Sequential does not mean once. A handler can run again after a crash or lease
loss, so guard logical transitions in durable actor state and deduplicate
external effects on the stable effect ID. See
Ordered execution does not prevent repeated execution. A handler can run again
after a crash or lease loss, so guard logical transitions in durable actor state
and deduplicate external effects on the stable effect ID. See
[handler idempotency](docs/correctness.md#handler-idempotency).

## Application database writes
Expand Down Expand Up @@ -538,7 +539,7 @@ Solid Objects does not promise:
- cancellation when a synchronous caller times out; or
- that a lease stops stale Ruby code from running.

The fencing generation is what stops stale code from committing. Read
The fencing generation stops stale code from committing. Read
[correctness](docs/correctness.md) for the full contract.

## Comparisons
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0006-at-least-once-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Mailbox delivery is at least once. State mutation, message completion, result pe

Actor code receives message ID, request ID, attempt, enqueue time, and idempotency key. Documentation requires idempotency for effects outside the actor commit.

Message handlers themselves can run more than once. Sequential execution means one valid activation runs one turn at a time; it does not mean a handler runs once. Handlers for transitions such as `launch`, `checkout`, or `submit` must inspect durable actor state and return safely when the transition already happened. External calls belong in an outbox and still require downstream idempotency.
Message handlers themselves can run more than once. Sequential execution means that one valid activation runs one turn at a time. It does not guarantee that a handler runs only once. Handlers for transitions such as `launch`, `checkout`, or `submit` must inspect durable actor state and return safely when the transition already happened. External calls belong in an outbox and still require downstream idempotency.

## Consequences

Expand Down
13 changes: 7 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ It is a database-backed virtual actor runtime for MySQL, PostgreSQL, and
SQLite. A virtual actor is a logical object addressed by type and ID whose
in-memory activation is created on demand, processes one mailbox turn at a
time, persists JSON state, and can disappear when idle without losing its
identity or state. This ports the programming model, not Cloudflare's
serverless runtime, global placement, storage API, or platform guarantees.
identity or state. This gem ports the programming model. It does not port
Cloudflare's serverless runtime, global placement, storage API, or platform
guarantees.

The runtime contract is:

Expand Down Expand Up @@ -128,8 +129,8 @@ instance first prevents a claimed reminder from recreating a destroyed actor.
The broadcast worker claims committed observable-change rows, renders
idempotent scalar Turbo replacements with component invalidation metadata,
broadcasts to a signed actor stream, and records delivery. It never renders
personalized component HTML. Current actor state remains the reconnect and
request-time component source of truth.
personalized component HTML. On reconnect, and on a request-time component
render, Solid Objects reads the current actor state.

### Process registry

Expand Down Expand Up @@ -482,7 +483,7 @@ A reminder record contains actor identity, a reminder name, target message, JSON
schedule(at: 30.minutes.from_now).expire
```

Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](reminders.md#one-alarm-for-a-whole-queue) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, so re-arming is safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](reminders.md#one-alarm-for-a-whole-queue) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.

When due, the scheduler locks the source instance and creates a normal mailbox
row with an idempotency key derived from reminder ID and occurrence. The
Expand Down Expand Up @@ -698,7 +699,7 @@ Backoff and a retry limit prevent tight loops. The poison message blocks its act

### Handler redelivery

Sequential processing does not mean single execution. A handler can run, lose its lease before commit, and run again. Logical transitions must guard on durable state:
Ordered processing does not prevent repeated execution. A handler can run, lose its lease before commit, and run again. Logical transitions must guard on durable state:

```ruby
def launch
Expand Down
8 changes: 4 additions & 4 deletions docs/authorization.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Authorization policies

Solid Objects treats actor identities as identifiers, never capabilities.
Knowing an actor ID, message ID, or signed stream token grants no permission.
All five policies deny by default, so a generated installation is
intentionally inert until the host application defines its trust boundary.
Solid Objects treats actor identities as identifiers. They are not
capabilities. An actor ID, a message ID, or a signed stream token grants no
permission. All five policies deny by default, so a generated installation
answers nothing until the host application defines its trust boundary.

## Policy reference

Expand Down
Loading
Loading