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
68 changes: 66 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,71 @@
# Changelog

## Unreleased

## 0.16.0 - 2026-09-23

- Find a message whose reference a caller lost.
`SolidObjects.client.find_by(request_id:)` answers a request id, which is
unique across the table, and `reference.find_by(idempotency_key:)` answers a
key, which is unique per instance, so the receiver supplies the scope the key
needs. Naming neither key, naming both, or naming an idempotency key without a
reference raises `ArgumentError`.
- Authorize every lookup with the hook the original call ran, against the stored
operation and arguments, because a request id is not a capability. An absent
row, an actor this process no longer registers, and a caller the policy
refuses all return `nil`, so a lookup cannot be used to ask whether a request
id exists.
- Add `MessageReference#outcome`, which reports the status, the result, the
persisted error, the rejection, and the attempt count, so a terminal failure
answers as well as a success. A result is stored for `sync` delivery only, so
an asynchronous message reports its status and error and no result.
- Tell a pruned message from one that never existed. An actor remembers the
idempotency keys of its own finished turns, the way an Orleans grain keeps
its deduplication history in grain state, so the memory needs no second
store and no second write. `reference.find_by(idempotency_key:)` raises
`SolidObjects::MessagePruned` for a key the actor remembers and whose message
retention removed, and still answers `nil` for a key no caller ever sent.
An actor remembers the operation beside each key, so the pruned answer runs
the same hook against the same operation a lookup of the surviving row would,
and a caller the policy refuses reads `nil` for both. Gating it on `snapshot`
would have told a caller who may read state, but not the operation, that the
operation had run.
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for
each actor. A lookup by request id cannot make the distinction, because the
runtime, not the caller, generates a request id and no actor remembers one.
`retained_idempotency_keys_bytes` bounds the serialized memory as well,
because an idempotency key has no length limit on every adapter and the memory
outlives the message row. An actor drops its oldest keys until the list fits,
so a key long enough to fill the limit by itself is never remembered.
- Add `db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb`,
which adds `instances.completed_idempotency_keys` as `jsonb` on PostgreSQL and
`json` elsewhere. An application installs it with
`bin/rails solid_objects:install:migrations` and runs it before it upgrades a
worker, because the executor writes the column on every finished turn. The
doctor now reports the column as missing when it is not installed.
- Apply migrations through `SolidObjects::SchemaBootstrap`, which reads
`db/migrate`. Seven scripts each carried a hand-copied migration list, and
three of them applied an incomplete schema. A test fails if any script names a
migration class again.
- Report a half-applied migration in `solid_objects doctor`. The column list
omitted `instances.state_revision`, `messages.operation`,
`effects.success_operation`, `effects.failure_operation`, and
`dead_letters.operation`, so an application that skipped a migration read as
healthy and found out from a worker crash. A test fails when the list does not
name a column that a migration after the first adds.

- List a dead effect or broadcast as a `SolidObjects::DeadRow` rather than as
an Active Record row. `all` returned rows whose `id` was the primary key while
`retry` reads `effect_id` or `broadcast_id`, so the obvious
`scope.retry(scope.all.first.id)` raised `ActiveRecord::RecordNotFound`.
`DeadRow#id` is now the value `retry` accepts, which is what the TypeScript
runtime has always returned. `dead` still answers the relation for a caller
that wants to scope it further.
- Raise a load error rather than report an unreachable database. Wake-up
selection rescued every exception, so a `NameError` from an unloaded model
read as "the database could not be reached" and downgraded the process to
in-process signalling. It now rescues database, system call, and IO errors
only.
- Note that `json` 3.0.2 breaks `ActiveSupport::JSON.decode`, and therefore
every JSON column, in [docs/operations.md](docs/operations.md).
- Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its
message meaning and answers `effects` and `broadcasts`, so the kind rides on
the receiver. `retry` returns a dead row to pending with a zero attempt count
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solid_objects (0.15.2)
solid_objects (0.16.0)
actioncable (>= 7.1)
actionpack (>= 7.1)
actionview (>= 7.1)
Expand Down Expand Up @@ -384,7 +384,7 @@ CHECKSUMS
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
solid_objects (0.15.2)
solid_objects (0.16.0)
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Solid Object Rails Actors elegantly fit anything where one identifiable thing mu
- Ticket holds and reservations
- Multiplayer games and shared rooms
- Shopping carts and checkout recovery
- Rate limits and account quotas
- Low-rate quotas and account limits
- Session expiration
- Job leases and workflows
- Connected devices
Expand Down
10 changes: 2 additions & 8 deletions benchmark/support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -425,14 +425,8 @@ def establish_connection

# @rbs () -> void
def migrate
require_relative "../db/migrate/20260805000000_create_solid_objects_tables"
require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns"
require_relative "../db/migrate/20260915000000_add_solid_objects_effect_recoveries"
CreateSolidObjectsTables.new.migrate(:up)
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
RenameMessageDispatchColumns.new.migrate(:up)
AddSolidObjectsEffectRecoveries.new.migrate(:up)
require "solid_objects/schema_bootstrap"
SolidObjects::SchemaBootstrap.install
end

# @rbs () -> void
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# rbs_inline: enabled

class AddSolidObjectsCompletedIdempotencyKeys < ActiveRecord::Migration[7.1]
# @rbs () -> void
def change
add_column SolidObjects.table_name(:instances),
:completed_idempotency_keys,
connection.adapter_name.match?(/postgres/i) ? :jsonb : :json
end
end
46 changes: 41 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ through the client.

### Client and mailbox

The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented.
The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented and are not planned here.

Message execution state is table membership, not a status column. The durable message remains for results, retention, and diagnostics. Only live work occupies `ready_messages` or `claimed_messages`, so completed history cannot inflate the polling index.

Expand Down Expand Up @@ -424,6 +424,42 @@ outer commit, and callers timing out on work they indirectly block.
waiting and immediately returns a `MessageReference`. Runtime workers process
it normally.

A caller that loses that reference rebuilds one. `SolidObjects.client.find_by`
answers a request id, which is unique across the table, and
`reference.find_by` answers an idempotency key, which is unique per instance.
Each lookup runs the authorization hook the original call ran, against the
stored operation and arguments, and answers `nil` for an absent row, an
unregistered actor, and a refused caller alike, so it cannot be used to ask
whether a request id exists. `MessageReference#outcome` reports the status, the
result, the persisted error, the rejection, and the attempt count. A result is
stored for `sync` delivery only.

An actor remembers the idempotency keys of its own finished turns. The executor
already writes the instance row in the transaction that completes, rejects, or
kills a turn, so the memory rides on a write that happens anyway. This is the
Orleans answer: a grain keeps its deduplication history in grain state rather
than in a separate tombstone table, which needs no second store, no second
write, and no separate retention. `reference.find_by(idempotency_key:)` raises
`MessagePruned` for a key the actor remembers and whose message retention
removed, and answers `nil` for a key no caller ever sent, so a client can tell
a lost result from a request that never arrived. An actor remembers the operation and original arguments beside each key, so
the pruned answer runs the same hook against the same operation and arguments that a lookup
of the surviving row would, and a caller the policy refuses reads `nil` whether
the message is pruned or never existed. Gating it on `snapshot` instead would
tell a caller who may read state, but not the operation, that the operation had
run.
Remembered arguments count toward the serialized memory limit and remain until
the entry is evicted or the instance is removed. Entries from older versions
that lack arguments return absence after pruning because their original
authorization cannot be reproduced.

`retained_idempotency_keys` bounds the memory and defaults to 64 keys for each
actor, and `retained_idempotency_keys_bytes` bounds its serialized size at 16 KB,
because an idempotency key has no length limit on every adapter and the memory
outlives the message row. An actor drops its oldest keys until the list fits. Only a lookup by idempotency key can make the distinction. A request id
is generated by the runtime rather than by the caller, so no actor remembers
one, and `client.find_by(request_id:)` answers `nil` in both cases.

An executing caller receives an inline after-commit callback error even though
the turn committed. An independently waiting caller observes the durable
result and may return before that callback raises in the worker. Completed
Expand Down Expand Up @@ -750,11 +786,11 @@ Enqueue counts unfinished rows under the locked actor instance and rejects with

### Per-actor rate limits

The initial implementation supplies the mailbox cap. Distributed token buckets or time-window counters are a hardening milestone.
This runtime supplies the mailbox cap. Distributed token buckets and time-window counters are not planned here, because a request-path limiter is hot and loss-tolerant while every invocation writes one permanent message row. Solid Objects Pro answers that shape with grouped and ephemeral operations, which [fit](fit.md) describes.

### Global enqueue limits

Global admission hooks are not implemented. A future hook can reject based on database health or application policy without introducing a strict global counter as a contention hotspot.
Global admission hooks are not implemented and are not planned here, for the same reason as per-actor rate limits. A strict global counter would also be a contention hotspot. Reject on database health or application policy in front of the actor instead.

### Payload size

Expand Down Expand Up @@ -867,9 +903,9 @@ All backends use unique identity and sequence constraints, short transactions, a
12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
14. **How does synchronous invocation work across processes?** The caller first tries to claim and execute the actor locally. If another process owns it, a wake-up adapter prompts a durable result query and bounded polling remains the fallback.
15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference. An enqueue timeout leaves no message. Running Ruby code is not preempted.
15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference, or with `find_by` from the request id or the idempotency key when that reference is gone. An enqueue timeout leaves no message. Running Ruby code is not preempted.
16. **How are results cleaned up?** `prune_messages` deletes eligible terminal history in bounded batches after global or per-actor retention. It previews by default and preserves live work, dead letters, retry links, and unfinished outboxes.
17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields; rate and global admission controls remain roadmap work.
17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields. Rate and global admission controls are not planned here; Solid Objects Pro answers that shape.
18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically.
19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
Expand Down
6 changes: 6 additions & 0 deletions docs/fit.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ a presence signal, or a view count. Reactive projections materialize a read
model from the durable broadcast outbox, so request-path reads stop competing
with mailbox work.

Distributed per-actor rate limits, global admission control, and cache-capacity
eviction are answered there rather than in this gem. Each one is hot and
request-critical, and this gem writes one permanent message row for every
invocation, so the cost model above rules out a limiter that checks on the
request path. They are not open roadmap items here.

## Cost model

Every synchronous or asynchronous invocation:
Expand Down
43 changes: 43 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ reports a failed or warned check rather than raising out of the command.

## Installing and upgrading

Solid Objects keeps actor state, message arguments, results, and the remembered
idempotency keys in JSON columns. Active Support decodes every one of them, and
`ActiveSupport::JSON.decode` raises with the `json` gem at 3.0.2:

```
ArgumentError: wrong number of arguments (given 2, expected 1)
```

The failure is in Active Support rather than in Solid Objects, and it reaches
every JSON column in a Rails application. A new Rails 8.1 application resolves
`json` 3.0.2 today, so pin the 2.x series until Rails ships a fix:

```ruby
gem "json", "~> 2"
```

Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
notes, then update the gem:

Expand Down Expand Up @@ -215,6 +231,8 @@ end
| `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
| `process_retention` | 7 days |
| `prune_batch_size` | 1,000 |
| `retained_idempotency_keys` | 64 |
| `retained_idempotency_keys_bytes` | 16 KB |
| `worker_count` | 1 |
| `effect_worker_count` | 1 |
| `broadcast_worker_count` | 1 |
Expand Down Expand Up @@ -517,6 +535,8 @@ SolidObjects.configure do |configuration|
}
configuration.process_retention = 7.days
configuration.prune_batch_size = 1_000
configuration.retained_idempotency_keys = 64
configuration.retained_idempotency_keys_bytes = 16.kilobytes
end
```

Expand All @@ -543,6 +563,29 @@ broadcasts, and other message-owned rows. Choose a cutoff longer than every
`sync` timeout because a caller whose result row disappears can no longer
observe it.

`find_by` reads the same rows, so a lookup answers only while the message it
names survives retention. A lookup by idempotency key still tells the two cases
apart after pruning, because the actor remembers the keys of its own last
`retained_idempotency_keys` finished turns: it raises `MessagePruned` for a key
the actor remembers and answers `nil` for a key no caller ever sent. The actor
remembers the operation and original arguments beside each key, so the pruned answer runs the same
authorization the surviving row would. Raise
`retained_idempotency_keys` above the default of 64 when an actor finishes more
keyed turns than that inside the window in which a caller may retry. A lookup
by request id answers `nil` in both cases, so a caller that must tell them apart
sends its own idempotency key.

Remembered arguments count toward the serialized memory limit and remain until
the entry is evicted or the instance is removed. Entries from older versions
that lack arguments return absence after pruning because their original
authorization cannot be reproduced.

`retained_idempotency_keys_bytes` bounds the serialized memory as well, because
an idempotency key has no length limit on every adapter and the memory outlives
the message row. An actor drops its oldest keys until the list fits, so a key
long enough to fill the limit by itself is never remembered and its lookup
answers `nil` rather than raising.

Actor expiration is disabled by default. `prune_instances` considers only
actor types listed in `instance_retention_by_actor_type`, excludes active or
paused actors, and preserves ready/claimed mailbox work, scheduled reminders,
Expand Down
Loading
Loading