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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ release commit directly to `main`.
snapshot from the tag. The `check:release` step of the site refuses a
snapshot that is not the latest published tag.

Before 1.0, a feature or a behavior change gets a minor version, and a fix gets
a patch version.
From 1.0, versions follow `docs/versioning.md`. A fix gets a patch version. An
addition gets a minor version. A change that breaks the public API gets a
major version.

The Context7 refresh workflow refreshes the index after each push to `main` that
changes the documentation. Confirm that its run succeeded.
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## 1.0.0 - 2026-10-13

- Version 1.0 freezes the public API under semantic versioning.
[docs/versioning.md](docs/versioning.md) gives the promise and its limits.
Version 1.0 is not a claim of measured scale: the benchmarks are
development measurements, and longer soak tests are open work.
- **Breaking:** a turn that calls `schedule` and then calls no operation on the
result now fails with `SolidObjects::ScheduleWithoutOperation`. The runtime
does not retry that turn, and the message becomes a dead letter. Before this
release, such a call stored no reminder and gave no error. Correct code,
such as `schedule(at: time).expire`, does not change. The turn can keep the
result and call the operation later. A `schedule` call with no operation in
an observable fails the turn with `SolidObjects::QueryMutatedState`.
JavaScript has the same behavior.
- `bin/rails solid_objects:doctor` has a new `json` check, and the boot prints
a warning, when Active Support cannot decode JSON with the installed `json`
gem. This is the case for `json` 3.x on Rails older than 8.1.4. The check
decodes one document and does not compare version numbers.
- The gem summary and description now start with the problems that the gem
solves. The README has a status statement and names the projects that
Solid Objects is not.

## 0.17.5 - 2026-10-12

- Back off repeated interrupted-message recovery using `retry_delay`.
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.17.5)
solid_objects (1.0.0)
actioncable (>= 7.1)
actionpack (>= 7.1)
actionview (>= 7.1)
Expand Down Expand Up @@ -392,7 +392,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.17.5)
solid_objects (1.0.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
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ stable identity, and its state lives in the SQL database that your app already
uses. [Virtual actors in Ruby on Rails](docs/virtual-actors.md) explains the
model and when to use it.

**Status.** Version 1.0: the public API follows semantic versioning and does
not break before 2.0. [Versioning](docs/versioning.md) gives the limits of the
promise. First public release: August 6, 2026. One maintainer.

**Production use.** One first-party application with more than 100,000 users
runs it in production. That is a count of users, not a measure of load. No
production use by other people is known. The [benchmarks](docs/benchmarks.md)
are development measurements, and longer soak tests are open work.

**It is not** Solid Queue, SolidJS, or the `service_actor` gem, and it is not
affiliated with Rails or Cloudflare.

In a shopping cart, paying twice at the same time is a big problem. The payment provider might time out, and your Rails site could be restarting before recovery finishes.

To deal with this safely, you often need logic scattered between 7-10 files like database row locks, Redis locks, delayed jobs, retries, and cleanup code to keep that process straight. They are not all large, but they must agree about the same payment state and failure rules. That coordination is the difficult part.
Expand Down Expand Up @@ -193,9 +205,11 @@ explanations remain in `docs/`. The README stops here.

## Status and license

Solid Objects Ruby is a pre-1.0 early release. Its correctness core is tested
against SQLite, PostgreSQL, and MySQL, but the project makes no production-ready
claim. That requires more hardening and operational soak evidence. Expect breaking changes.
Version 1.0 promises a stable public API. Its correctness core is tested
against SQLite, PostgreSQL, and MySQL. It makes no claim of measured scale:
the [benchmarks](docs/benchmarks.md) are development measurements, and longer
soak tests are open work. [Versioning](docs/versioning.md) says what a release
can change.

Solid Objects is released under the [MIT License](MIT-LICENSE). It is an
independent project and is not affiliated with, sponsored by, or endorsed by
Expand Down
19 changes: 12 additions & 7 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,22 @@ bin/rails solid_objects:doctor
```

The generator adds an initializer and copies migrations into the application.
The doctor checks the configuration, the tables, and one real actor round trip.
The doctor checks the `json` gem, the configuration, the tables, and one real
actor round trip.

Install the current release. `bundle add solid_objects` selects it. Do not pin
a version that you remember from earlier work; the API changed between
releases. The current version is on <https://rubygems.org/gems/solid_objects>.
Install the current release. `bundle add solid_objects` selects it. Do not use
an API that you remember from a 0.x release; the API changed between 0.x
releases. From version 1.0, the public API follows
[semantic versioning](versioning.md). The current version is on
<https://rubygems.org/gems/solid_objects>.

The generated policies deny every call. Do step 5 before you call an actor.

The `json` gem 3.x works only with Active Support 8.1.4 or newer. On Rails
7.1, 7.2, 8.0, or 8.1 before 8.1.4, pin `gem "json", "~> 2"` in the
`Gemfile`. Without the pin, Active Support raises an `ArgumentError`, such as
`unknown keyword: quirks_mode`, for every JSON column.
`unknown keyword: quirks_mode`, for every JSON column. The doctor reports this
problem as a failure of its `json` check, and the boot prints a warning.

[Installing and upgrading](operations.md#installing-and-upgrading) has the
details.
Expand Down Expand Up @@ -184,7 +188,7 @@ Avoid these mistakes:

| Mistake | Correct form |
| --- | --- |
| `schedule(at: deadline)` with no operation after it | `schedule(at: deadline, key: buyer).expire(buyer:)`. `schedule` stages a reminder only when you call an operation on its result |
| `schedule(at: deadline)` with no operation after it | `schedule(at: deadline, key: buyer).expire(buyer:)`. A `schedule` call with no operation after it fails the turn with `SolidObjects::ScheduleWithoutOperation` |
| `reject "room full"` | `reject(:room_full, "The room is full")` |
| `id` inside an actor | `actor_id`. An actor has no `id` method |
| `register_effect(:name) { \|context, arguments\| ... }` | `register_effect(:name) { \|arguments, context\| ... }`. The arguments come first |
Expand Down Expand Up @@ -278,6 +282,7 @@ When you explain Solid Objects to a user, state these limits:
- There are no transactions across actor identities.
- Fencing stops a stale activation from a commit, but its code can continue to
run.
- The gem is pre-1.0 and makes no production-ready claim.
- Version 1.0 promises a stable public API. It makes no claim of measured scale,
and no production use by other people is known.

The [correctness contract](correctness.md) is the source for each guarantee.
2 changes: 1 addition & 1 deletion docs/guides/expiring-reservations.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ See [the transactional outbox guide](transactional-outbox.md).
- Delivery is at least once. Each operation must be safe to run again.
- There are no transactions across actors. A reservation that spans two shows needs its own design.
- One busy show runs its calls one at a time. Many shows run at the same time.
- The gem is pre-1.0 and makes no production-ready claim.
- Version 1.0 promises a stable public API. It makes no claim of measured scale.

## More information

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/ordered-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ You do not assign sequence numbers. The actor mailbox gives the order.
- Delivery is at least once. Each operation must be safe to run again.
- The gem provides no transactions across actors. A transfer between two accounts needs its own design, for example one SQL transaction on normal tables.
- One busy account runs its entries one at a time.
- The gem is pre-1.0 and makes no production-ready claim.
- Version 1.0 promises a stable public API. It makes no claim of measured scale.

## More information

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/race-conditions.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ Delivery is at least once, so an effect can run more than once. See [effect idem
- Delivery is at least once, not exactly once. Write each operation so that it can run again.
- There are no transactions across actors. A change that must touch two events atomically needs one SQL transaction on normal tables.
- One busy event runs its calls one at a time. Many events can run at the same time.
- The gem is pre-1.0 and makes no production-ready claim.
- Version 1.0 promises a stable public API. It makes no claim of measured scale.

## More information

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/transactional-outbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ One successful turn commits the actor state, the staged effects, the same-databa
- Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
- Delivery is at least once. Each effect handler must deduplicate with `context.id`.
- Solid Objects provides no transactions across actors.
- The gem is pre-1.0 and makes no production-ready claim.
- Version 1.0 promises a stable public API. It makes no claim of measured scale.

## More information

Expand Down
5 changes: 5 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ an older Rails release, pin the 2.x series of `json`:
gem "json", "~> 2"
```

`bin/rails solid_objects:doctor` reports this problem as a failure of its
`json` check, and the boot prints a warning that starts with
`[solid_objects]`. Both decode one JSON document with Active Support. They do
not compare version numbers.

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

Expand Down
20 changes: 20 additions & 0 deletions docs/reminders.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ end
Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
enqueue each one.

## A schedule call needs an operation

`schedule` returns an object, and the reminder exists only after you call an
operation on that object. A turn that calls `schedule` and then calls no
operation on the result fails with `SolidObjects::ScheduleWithoutOperation`.
The runtime does not retry that turn, and the message becomes a dead letter.

```ruby
# Wrong. The turn fails.
schedule(at: 10.minutes.from_now, key: buyer)

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

You can keep the result and call the operation later in the same turn.

An observable cannot call `schedule`, with or without an operation. The turn
that reads the observable fails with `SolidObjects::QueryMutatedState`.

## A reminder is one named alarm per actor

The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
Expand Down
16 changes: 14 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
[observability](observability.md).

- Rails engine, install generator, migration, and CLI
- A stable public API from version 1.0, with the limits in
[versioning](versioning.md)
- A turn that calls `schedule` and then calls no operation on the result fails
with `ScheduleWithoutOperation`, which is not retried, matching JavaScript.
Before 1.0 such a call stored no reminder and gave no error. The projection
guard counts such a call in an observable as staged work, so that turn fails
with `QueryMutatedState`.
- A `json` check in `solid_objects:doctor` and a warning during the boot, for
the `json` gem 3.x on an Active Support release that cannot decode with it.
The check decodes one document and does not compare version numbers. This
surface is Rails-only.
- Explicit actor registry, references, JSON state, and state migrations
- Queries, observable projections, and personalized payloads reject state mutation
and staged durable work with `QueryMutatedState`, matching JavaScript. Query
Expand Down Expand Up @@ -245,5 +256,6 @@
latency, query counts, and the three reactive delivery paths are measured on
SQLite; adapter-specific and end-to-end browser measurements are not.

No production-ready claim should be made until these hardening milestones have
operational soak evidence.
Version 1.0 freezes the public API under [semantic versioning](versioning.md).
It does not complete these hardening milestones. The project makes no claim of
measured scale until they have operational soak evidence.
41 changes: 41 additions & 0 deletions docs/versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Versioning

From version 1.0.0, Solid Objects follows
[Semantic Versioning](https://semver.org/).

## The promise

The public API is the set of classes, methods, generators, commands, and
configuration options that `README.md` and the files in `docs/` describe.

| Release | What it can change |
| --- | --- |
| Patch, for example 1.0.1 | Corrections of defects. No new behavior |
| Minor, for example 1.1.0 | New behavior. Code that uses the public API continues to work |
| Major, for example 2.0.0 | A change that breaks code that uses the public API |

## Outside the promise

- Classes, methods, and constants that the documentation does not describe.
- The database schema. A minor release can add tables, columns, and indexes,
and it supplies a migration for each change.
[Installing and upgrading](operations.md#installing-and-upgrading) gives the
steps.
- The text of error messages and log lines. The error classes are part of the
public API.
- The timing of background work, where no configuration option sets it.
- A correction of behavior that is different from the
[correctness contract](correctness.md). A patch release can make such a
correction.

## What version 1.0 does not claim

Version 1.0 is a promise about the API. It is not a claim of measured scale.
The [benchmarks](benchmarks.md) are development measurements and do not
guarantee capacity. Longer soak tests are open work. The project has one
maintainer, and no production use by other people is known.

## Releases before 1.0

The 0.x releases changed the API between minor versions. `CHANGELOG.md` lists
each change.
9 changes: 5 additions & 4 deletions docs/virtual-actors.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ State and mailboxes live in the SQLite, PostgreSQL, or MySQL database that the
Rails application already uses. Redis and a separate actor service are not
necessary.

Solid Objects is a pre-1.0 release. It makes no production-ready claim. Read
Solid Objects 1.0 promises a stable public API. It makes no claim of measured
scale. Read
[Compatibility and maturity](#compatibility-and-maturity) before you choose it.

## What a virtual actor is
Expand Down Expand Up @@ -198,9 +199,9 @@ matrix.
InnoDB. MySQL works through `mysql2` or `trilogy`.
- SQLite is correct for the same contract, but it is best for development and
modest single-host workloads.
- Pre-1.0. Expect changes that break compatibility. The correctness core has tests against all
three databases. The project has no production-ready claim and no measured
scale claim.
- Version 1.0: the public API follows [semantic versioning](versioning.md). The
correctness core has tests against all three databases. The project makes no
claim of measured scale, and no production use by other people is known.

The [roadmap](roadmap.md) records what is tested, what is partial, and what is
next. For TypeScript and Node.js, use the
Expand Down
1 change: 1 addition & 0 deletions lib/solid_objects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
require "solid_objects/diagnostics"
require "solid_objects/log_subscriber"
require "solid_objects/serialization"
require "solid_objects/json_compatibility"
require "solid_objects/context"
require "solid_objects/actor_registry"
require "solid_objects/state"
Expand Down
30 changes: 28 additions & 2 deletions lib/solid_objects/actor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Actor
REMINDER_HANDLE_KEY = "reminder_name"

ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy)
ScheduleCall = Data.define(:at, :key)
UnscheduleIntent = Data.define(:name)
UnscheduleAllIntent = Data.define(:operation)
ReminderStatus = Data.define(:name, :operation, :key, :next_run_at, :interval_seconds,
Expand Down Expand Up @@ -154,6 +155,7 @@ def default_actor_type
# @rbs @effect_recovery_intents: Array[EffectRecoveryIntent]
# @rbs @commit_action_intents: Array[CommitActionIntent]
# @rbs @reminder_intents: Array[ReminderIntent]
# @rbs @open_schedule_calls: Array[ScheduleCall]
# @rbs @outbound_message_intents: Array[OutboundMessageIntent]

attr_reader :actor_id, :state
Expand All @@ -167,6 +169,7 @@ def initialize(actor_id:, state:, instance_id: nil)
@effect_recovery_intents = []
@commit_action_intents = []
@reminder_intents = []
@open_schedule_calls = []
@outbound_message_intents = []
end

Expand Down Expand Up @@ -257,11 +260,14 @@ def schedule(at:, every: nil, missed: :latest, key: nil)
raise ArgumentError, "missed reminder policy must be all or latest"
end
reminder_key = validated_reminder_key(key)
call = ScheduleCall.new(at:, key: reminder_key)
open_schedule_calls << call

OperationDispatcher.new(
actor_type: self.class.actor_type,
handlers: self.class.definition.messages
) do |operation, arguments|
open_schedule_calls.delete_if { |open_call| open_call.equal?(call) }
name = reminder_name(operation:, key: reminder_key)
reminder_intents << ReminderIntent.new(
name:,
Expand Down Expand Up @@ -537,6 +543,18 @@ def discard_intents
commit_action_intents.clear
reminder_intents.clear
outbound_message_intents.clear
open_schedule_calls.clear
end

# @rbs () -> void
def ensure_schedule_calls_have_operations!
call = open_schedule_calls.first
return unless call

arguments = [ "at: #{call.at.inspect}", ("key: #{call.key.inspect}" if call.key) ].compact.join(", ")
raise ScheduleWithoutOperation,
"schedule(#{arguments}) has no operation after it, so it stores no reminder. " \
"Call an operation on the result, for example schedule(at: time).expire"
end

# @rbs () -> Integer
Expand All @@ -563,11 +581,19 @@ def read_projection(operation)
:effect_recovery_intents,
:commit_action_intents,
:reminder_intents,
:outbound_message_intents
:outbound_message_intents,
:open_schedule_calls

# @rbs () -> Array[Array[Hash[Symbol, Object]]]
def intent_snapshot
[ effect_intents, effect_recovery_intents, commit_action_intents, reminder_intents, outbound_message_intents ].map do |intents|
[
effect_intents,
effect_recovery_intents,
commit_action_intents,
reminder_intents,
outbound_message_intents,
open_schedule_calls
].map do |intents|
intents.map { |intent| intent.to_h.deep_dup }
end
end
Expand Down
Loading
Loading