solid_objects 0.16.1 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -0
- data/README.md +9 -0
- data/Rakefile +5 -0
- data/app/models/solid_objects/message.rb +15 -0
- data/docs/agents.md +253 -0
- data/docs/architecture.md +16 -6
- data/docs/authorization.md +10 -3
- data/docs/observability.md +186 -0
- data/docs/operations.md +10 -8
- data/docs/realtime.md +4 -3
- data/docs/reminders.md +3 -1
- data/docs/roadmap.md +17 -1
- data/docs/security.md +2 -2
- data/docs/transmission.md +17 -6
- data/docs/virtual-actors.md +226 -0
- data/examples/quickstart/README.md +246 -0
- data/examples/quickstart/app/actors/ticket_sale.rb +22 -0
- data/examples/quickstart/smoke.rb +421 -0
- data/lib/solid_objects/activation.rb +6 -3
- data/lib/solid_objects/actor.rb +29 -2
- data/lib/solid_objects/actor_channel.rb +9 -1
- data/lib/solid_objects/actor_snapshot.rb +14 -8
- data/lib/solid_objects/broadcast_executor.rb +1 -0
- data/lib/solid_objects/client.rb +15 -1
- data/lib/solid_objects/configuration.rb +4 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
- data/lib/solid_objects/diagnostics.rb +83 -0
- data/lib/solid_objects/effect_executor.rb +4 -0
- data/lib/solid_objects/errors.rb +3 -0
- data/lib/solid_objects/executor.rb +34 -16
- data/lib/solid_objects/instrumentation.rb +22 -1
- data/lib/solid_objects/log_subscriber.rb +1 -1
- data/lib/solid_objects/mailbox.rb +5 -1
- data/lib/solid_objects/message_reference.rb +9 -9
- data/lib/solid_objects/observer_registry.rb +47 -0
- data/lib/solid_objects/payload_broadcast.rb +9 -8
- data/lib/solid_objects/reference.rb +19 -0
- data/lib/solid_objects/reminder_scheduler.rb +5 -0
- data/lib/solid_objects/state_snapshot.rb +1 -0
- data/lib/solid_objects/supervisor.rb +3 -6
- data/lib/solid_objects/synchronous_invocation.rb +1 -21
- data/lib/solid_objects/telemetry.rb +137 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +1 -2
- data/lib/solid_objects/wake_up_adapters/redis.rb +1 -2
- data/lib/solid_objects/worker.rb +1 -2
- data/lib/solid_objects.rb +10 -0
- data/sig/generated/lib/solid_objects/actor.rbs +9 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +7 -2
- data/sig/generated/lib/solid_objects/client.rbs +3 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +7 -3
- data/sig/generated/lib/solid_objects/diagnostics.rbs +25 -0
- data/sig/generated/lib/solid_objects/errors.rbs +3 -0
- data/sig/generated/lib/solid_objects/executor.rbs +10 -2
- data/sig/generated/lib/solid_objects/message_reference.rbs +6 -6
- data/sig/generated/lib/solid_objects/observer_registry.rbs +29 -0
- data/sig/generated/lib/solid_objects/payload_broadcast.rbs +2 -4
- data/sig/generated/lib/solid_objects/reference.rbs +9 -0
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +0 -3
- data/sig/generated/lib/solid_objects/telemetry.rbs +30 -0
- data/sig/generated/lib/solid_objects.rbs +3 -0
- data/sig/generated/models/solid_objects/message.rbs +3 -0
- data/sig/public/json_value.rbs +3 -0
- data/sig/public/telemetry.rbs +49 -0
- data/sig/support/framework.rbs +5 -0
- metadata +24 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b918d2d78f5336b1eeda97b9f59560b6ef22cb4703524fd17c368c3fcb90b8d
|
|
4
|
+
data.tar.gz: 26483ce973457d40dceb52a14bb311417e476a9bd84ce8dc5f85e814a16db9af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ac2d7b510bd69aad30b8b32c8330725c08e52625ceca66a0cfde34e61f316b904aa2f21705473034096560479ff94dbc8c8a072b68e0a4e852de89c182f2bcc
|
|
7
|
+
data.tar.gz: 7061098cc192a52606d2957380d8a6a6d842e8a67c2c13aad0873efcfdad691bb0783fbb5252554a896d5a2424a88d24cfb5454bdb80990c5b20a6b9c65f8847
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,105 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.1 - 2026-10-08
|
|
4
|
+
|
|
5
|
+
- Name the category in the gem metadata and the README: Solid Objects is a
|
|
6
|
+
SQL-backed virtual actor library for Ruby on Rails. The gem homepage now
|
|
7
|
+
links to `https://solidobjects.dev/ruby` instead of the site root, which
|
|
8
|
+
redirects to the Node page.
|
|
9
|
+
- Add `docs/virtual-actors.md`, a category guide with the definition, a small
|
|
10
|
+
example, fit and poor-fit criteria, comparisons, and an Orleans concept map.
|
|
11
|
+
- Add `docs/agents.md`, a consumer guide for coding agents with setup,
|
|
12
|
+
authorization, effect idempotency, verification, and troubleshooting steps.
|
|
13
|
+
Both guides ship in the gem.
|
|
14
|
+
- Add `context7.json` so that Context7 indexes the consumer documentation and
|
|
15
|
+
skips maintainer files.
|
|
16
|
+
- Add a Rails quickstart in `examples/quickstart/` and a `rake quickstart`
|
|
17
|
+
check that runs it against the built gem. The check builds the gem, creates
|
|
18
|
+
a new SQLite Rails application, installs the gem from `vendor/cache` with
|
|
19
|
+
`bundle install --local`, and confirms by checksum and load path that the
|
|
20
|
+
application loads the built gem. It runs the install generator, the
|
|
21
|
+
migrations, and the doctor, and grants only the message and query policies.
|
|
22
|
+
It sends eight concurrent holds from separate processes to the README's
|
|
23
|
+
`TicketSale` actor and confirms that exactly one hold commits. It stops the
|
|
24
|
+
runtime, waits until a reminder is past due, confirms that the reminder did
|
|
25
|
+
not run, restarts the runtime, and confirms that the reminder released the
|
|
26
|
+
hold once. The check also fails when a `TicketSale` sample in the README or
|
|
27
|
+
in `docs/` differs from the actor that it runs. A new `quickstart` CI job runs
|
|
28
|
+
the check, and the release job waits for it.
|
|
29
|
+
- Correct the `json` 3.x note in `docs/operations.md`. The `json` gem 3.x works
|
|
30
|
+
only with Active Support 8.1.4 or newer. Active Support 7.1, 7.2, and 8.0
|
|
31
|
+
raise `unknown keyword: quirks_mode`, and Active Support 8.1.3.1 and earlier
|
|
32
|
+
8.1 releases fail to decode. Upgrade Rails to 8.1.4 or newer, or pin `json`
|
|
33
|
+
to 2.x. The compatibility CI matrix now pins `json` 2.x for Rails 7.1, 7.2,
|
|
34
|
+
and 8.0, the configuration that the guide prescribes.
|
|
35
|
+
|
|
36
|
+
## 0.17.0 - 2026-10-03
|
|
37
|
+
|
|
38
|
+
- Publish RBS types for portable events, metric samples, actor diagnostics, and
|
|
39
|
+
event observers in `sig/public/telemetry.rbs`, and a `json_value` type for
|
|
40
|
+
message results and actor state. Observer blocks, diagnostics, and results now
|
|
41
|
+
type-check against these contracts instead of `untyped`.
|
|
42
|
+
- **Breaking:** `solid_objects.activation.started` now fires before the actor's
|
|
43
|
+
`activate` hook. Before, it fired after a successful hook. The new
|
|
44
|
+
`solid_objects.activation.completed` event takes that meaning, and
|
|
45
|
+
`solid_objects.activation.failed` reports a failed hook. JavaScript changes
|
|
46
|
+
the same events. Move a subscriber that reads `activation.started` as a
|
|
47
|
+
finished activation to `activation.completed`.
|
|
48
|
+
- **Breaking:** Active Support payloads no longer carry `error_message`. This
|
|
49
|
+
applies to `commit_action.failed`, `activation.deactivation_failed`,
|
|
50
|
+
`supervisor.monitor_failed`, `supervisor.retention_failed`,
|
|
51
|
+
`supervisor.redrive_failed`, and `wake_up.failed`. The
|
|
52
|
+
`solid_objects.worker.error` log entry also omits it. Each keeps
|
|
53
|
+
`error_class`. Exception text can contain actor state, so JavaScript already
|
|
54
|
+
reports only the error name.
|
|
55
|
+
|
|
56
|
+
- **Breaking:** rename `solid_objects.payload_broadcast_failed` to
|
|
57
|
+
`solid_objects.payload_broadcast.failed`, the dotted form that every other
|
|
58
|
+
event uses. Update Active Support subscribers to the new name. The portable
|
|
59
|
+
event names the payload `payload`.
|
|
60
|
+
- Match portable event attributes to JavaScript through the shared
|
|
61
|
+
`compatibility/telemetry-events.json` contract. Message events carry
|
|
62
|
+
`operation` and `deliveryMode`, `message.failed` carries `retryable` and
|
|
63
|
+
`outcome`, commit action events carry the message fields and `commitAction`,
|
|
64
|
+
and `reminder.enqueued` carries `operation`. `outbox.age` carries the effect or
|
|
65
|
+
broadcast identity, `sync.enqueue_timeout` carries `timeoutMilliseconds`, and
|
|
66
|
+
polling intervals are integers. `realtime.connected` carries only actor fields.
|
|
67
|
+
- Log `solid_objects.instrumentation.failed` when an exporter or observer raises.
|
|
68
|
+
Observers require a block, a process accepts at most 1,000 observers, and
|
|
69
|
+
`SolidObjects.reset!` removes them. Pin reserved JSON keys through actor
|
|
70
|
+
arguments, state, and retained results.
|
|
71
|
+
|
|
72
|
+
- Guard personalized payload projections against state changes, staged work,
|
|
73
|
+
and application database writes. Each payload gets an isolated actor from
|
|
74
|
+
the committed snapshot and honors `max_payload_bytes`, matching JavaScript.
|
|
75
|
+
- Preserve timeout wait reasons, activation owner IDs, and activation generations
|
|
76
|
+
in portable telemetry using the shared camelCase fields and reason values.
|
|
77
|
+
- Use a yielding SQLite busy handler for background transactions so concurrent
|
|
78
|
+
writers can finish on Rails 7.1 and 7.2. Preserve configured wait limits and
|
|
79
|
+
synchronous deadlines; cover contention with a coordinated lock regression.
|
|
80
|
+
|
|
81
|
+
- **Breaking:** reject query and observable state mutation and staged durable
|
|
82
|
+
work with terminal `QueryMutatedState` errors. Cover individual snapshot
|
|
83
|
+
projections and preserve ordinary operations' already-staged work while
|
|
84
|
+
reading projections, including replacements that leave the intent count
|
|
85
|
+
unchanged.
|
|
86
|
+
- Pin reserved JSON property names with shared Ruby/JS fixtures. Document the
|
|
87
|
+
reminder-name limit difference and the authorized dead-transmit retry API.
|
|
88
|
+
|
|
89
|
+
- **Breaking:** reauthorize every message-reference status, result, and outcome
|
|
90
|
+
read against the original invocation. Pass `authorization_context:` on every
|
|
91
|
+
read.
|
|
92
|
+
- **Breaking:** retain immutable JSON results for background and internal
|
|
93
|
+
messages as well as synchronous calls. All operations now enforce result
|
|
94
|
+
serialization and size limits; return `nil` explicitly when an operation does
|
|
95
|
+
not need a result. `result` raises terminal rejection/failure errors;
|
|
96
|
+
`outcome` exposes them as data.
|
|
97
|
+
- Preserve polling transition intervals in milliseconds and string reasons in
|
|
98
|
+
portable telemetry. Pin transmit staging order and null-argument validation
|
|
99
|
+
against the shared JavaScript contract.
|
|
100
|
+
|
|
101
|
+
- Add portable telemetry, isolated observer hooks, metric definitions, and bounded authorized actor diagnostics matching JavaScript.
|
|
102
|
+
|
|
3
103
|
## 0.16.1 - 2026-10-02
|
|
4
104
|
|
|
5
105
|
- Fix the SQLite join order of the claimed-message scan. The query in
|
data/README.md
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
**Open Source Durable Objects in your Rails app.**
|
|
7
7
|
|
|
8
|
+
Solid Objects is a SQL-backed virtual actor library for Ruby on Rails, with
|
|
9
|
+
durable state, ordered operations, and automatic activation. Each actor has a
|
|
10
|
+
stable identity, and its state lives in the SQL database that your app already
|
|
11
|
+
uses. [Virtual actors in Ruby on Rails](docs/virtual-actors.md) explains the
|
|
12
|
+
model and when to use it.
|
|
13
|
+
|
|
8
14
|
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.
|
|
9
15
|
|
|
10
16
|
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.
|
|
@@ -160,8 +166,11 @@ Exactly once is not hiding in a more advanced configuration. Read the
|
|
|
160
166
|
## Read more
|
|
161
167
|
|
|
162
168
|
- [Five-minute Rails guide](https://solidobjects.dev/5min/rails)
|
|
169
|
+
- [Virtual actors in Ruby on Rails](docs/virtual-actors.md)
|
|
170
|
+
- [Guide for coding agents](docs/agents.md)
|
|
163
171
|
- [Choosing Solid Objects](docs/fit.md)
|
|
164
172
|
- [Operations and recovery](docs/operations.md)
|
|
173
|
+
- [Observability and diagnostics](docs/observability.md)
|
|
165
174
|
- [Reminders](docs/reminders.md)
|
|
166
175
|
- [Reactive ERB](docs/realtime.md)
|
|
167
176
|
- [Detailed architecture](docs/architecture.md)
|
data/Rakefile
CHANGED
|
@@ -37,6 +37,11 @@ task :at_least_once do
|
|
|
37
37
|
sh "bundle exec ruby examples/at_least_once/demo.rb"
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
desc "Install the built gem into a new Rails app and prove ordering and restart recovery"
|
|
41
|
+
task :quickstart do
|
|
42
|
+
sh "bundle exec ruby examples/quickstart/smoke.rb"
|
|
43
|
+
end
|
|
44
|
+
|
|
40
45
|
desc "Scan the Rails engine for security warnings"
|
|
41
46
|
task :security do
|
|
42
47
|
sh "bundle exec brakeman --force --no-pager -q ."
|
|
@@ -47,6 +47,21 @@ module SolidObjects
|
|
|
47
47
|
dead_letter.present?
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# @rbs () -> json_value
|
|
51
|
+
def result!
|
|
52
|
+
if rejected?
|
|
53
|
+
raise Rejected.new(
|
|
54
|
+
code: rejection.fetch("code"),
|
|
55
|
+
message: rejection.fetch("message"),
|
|
56
|
+
details: rejection.fetch("details"),
|
|
57
|
+
message_id: id
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
raise MessageFailed.new("actor message failed permanently", message_id: id, details: error || {}) if dead?
|
|
61
|
+
|
|
62
|
+
Serialization.readonly_copy(result)
|
|
63
|
+
end
|
|
64
|
+
|
|
50
65
|
private
|
|
51
66
|
|
|
52
67
|
# @rbs () -> void
|
data/docs/agents.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# Solid Objects for coding agents (Rails)
|
|
2
|
+
|
|
3
|
+
This guide is for a coding agent that evaluates or adds Solid Objects in a
|
|
4
|
+
Ruby on Rails application. It gives the facts and steps in a fixed order. Each
|
|
5
|
+
step links to the maintained documentation for more detail.
|
|
6
|
+
|
|
7
|
+
## 1. Check the fit first
|
|
8
|
+
|
|
9
|
+
Use Solid Objects only if you can answer yes to most of these questions:
|
|
10
|
+
|
|
11
|
+
1. Does one identity, such as a cart, room, booking, account, or device, own
|
|
12
|
+
the state?
|
|
13
|
+
2. Must writes for that identity happen one at a time across requests, jobs,
|
|
14
|
+
and processes?
|
|
15
|
+
3. Must some work happen later or continue after a process restart?
|
|
16
|
+
4. Is the state a bounded JSON document, not a large relational dataset?
|
|
17
|
+
5. Can the application run one more process, `bundle exec solid_objects start`,
|
|
18
|
+
for reminders and background work?
|
|
19
|
+
|
|
20
|
+
Select a simpler tool in these cases:
|
|
21
|
+
|
|
22
|
+
| Requirement | Use instead |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| The invariant fits in one request | `with_lock`, a unique index, or a short transaction |
|
|
25
|
+
| CPU-intensive or parallel work | Background jobs or Ractors. An actor serializes work |
|
|
26
|
+
| Thousands of writes for each second to one key | A counter column, a cache, or Solid Objects Pro |
|
|
27
|
+
| One atomic change across two actor identities | One SQL transaction on normal tables |
|
|
28
|
+
| Exactly-once calls to an external API | Not available from any actor library. Use provider idempotency keys |
|
|
29
|
+
|
|
30
|
+
The full checklist is in [Choosing Solid Objects](fit.md). The category guide
|
|
31
|
+
is [Virtual actors in Ruby on Rails](virtual-actors.md).
|
|
32
|
+
|
|
33
|
+
## 2. Package identity
|
|
34
|
+
|
|
35
|
+
| Item | Value |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| Gem | `solid_objects` |
|
|
38
|
+
| Repository | <https://github.com/cardmagic/solid-objects-ruby> |
|
|
39
|
+
| Website | <https://solidobjects.dev/ruby> |
|
|
40
|
+
| Node.js package | `solid-objects` on npm, a separate port with the same model |
|
|
41
|
+
| Commercial extension | `solid_objects_pro`, a separate paid gem. The open-source gem does not need it |
|
|
42
|
+
|
|
43
|
+
Solid Objects is not part of Rails. It is not Solid Queue, Solid Cache, or
|
|
44
|
+
Solid Cable. It is not affiliated with Cloudflare.
|
|
45
|
+
|
|
46
|
+
## 3. Requirements
|
|
47
|
+
|
|
48
|
+
- Ruby 3.3 or newer.
|
|
49
|
+
- Rails 7.1 or newer. The gem is a Rails engine. It does not run without
|
|
50
|
+
Rails.
|
|
51
|
+
- SQLite 3.35 or newer, PostgreSQL 14 or newer, or MySQL 8.0 or newer with
|
|
52
|
+
InnoDB.
|
|
53
|
+
- No Redis and no separate actor service.
|
|
54
|
+
|
|
55
|
+
## 4. Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bundle add solid_objects
|
|
59
|
+
bin/rails generate solid_objects:install
|
|
60
|
+
bin/rails db:migrate
|
|
61
|
+
bin/rails solid_objects:doctor
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The generator adds an initializer and copies migrations into the application.
|
|
65
|
+
The doctor checks the configuration, the tables, and one real actor round trip.
|
|
66
|
+
|
|
67
|
+
The `json` gem 3.x works only with Active Support 8.1.4 or newer. On Rails
|
|
68
|
+
7.1, 7.2, 8.0, or 8.1 before 8.1.4, pin `gem "json", "~> 2"` in the
|
|
69
|
+
`Gemfile`. Without the pin, Active Support raises an `ArgumentError`, such as
|
|
70
|
+
`unknown keyword: quirks_mode`, for every JSON column.
|
|
71
|
+
|
|
72
|
+
[Installing and upgrading](operations.md#installing-and-upgrading) has the
|
|
73
|
+
details.
|
|
74
|
+
|
|
75
|
+
## 5. Authorize
|
|
76
|
+
|
|
77
|
+
Every policy in the generated initializer denies by default. A new
|
|
78
|
+
installation answers no actor call until you write a policy. Do not remove
|
|
79
|
+
this behavior.
|
|
80
|
+
|
|
81
|
+
For a local demonstration only, grant messages and queries:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
SolidObjects.configure do |configuration|
|
|
85
|
+
configuration.authorize_message = ->(**) { true }
|
|
86
|
+
configuration.authorize_query = ->(**) { true }
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Keep `authorize_destroy`, `authorize_subscription`,
|
|
91
|
+
`authorize_administration`, and `authorize_transmission` denied in a
|
|
92
|
+
demonstration.
|
|
93
|
+
|
|
94
|
+
A production policy must bind the actor type and ID to the authenticated user
|
|
95
|
+
or tenant. An actor ID is not a permission:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
SolidObjects.configure do |configuration|
|
|
99
|
+
owns_cart = lambda do |actor_type:, actor_id:, authorization_context:, **|
|
|
100
|
+
user = authorization_context
|
|
101
|
+
|
|
102
|
+
actor_type == "ShoppingCart" &&
|
|
103
|
+
user.present? &&
|
|
104
|
+
actor_id == user.id.to_s
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
configuration.authorize_message = owns_cart
|
|
108
|
+
configuration.authorize_query = owns_cart
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Pass the context on each call:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
ShoppingCart.ref(Current.user.id.to_s).add_item(
|
|
116
|
+
product_id: "shirt-123",
|
|
117
|
+
authorization_context: Current.user
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
[Authorization policies](authorization.md) lists each policy and its risk.
|
|
122
|
+
|
|
123
|
+
## 6. Define an actor
|
|
124
|
+
|
|
125
|
+
Put actors in `app/actors/`. This actor is the example from the README:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
class TicketSale < SolidObjects::Actor
|
|
129
|
+
attribute :available, default: 1
|
|
130
|
+
attribute :holds, default: -> { {} }
|
|
131
|
+
|
|
132
|
+
def hold(buyer:)
|
|
133
|
+
return { held: false, available: } if available.zero? || holds.key?(buyer)
|
|
134
|
+
|
|
135
|
+
self.available -= 1
|
|
136
|
+
self.holds = holds.merge(buyer => Time.current.to_i)
|
|
137
|
+
schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
|
|
138
|
+
{ held: true, available: }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def expire(buyer:)
|
|
142
|
+
return available unless holds.key?(buyer)
|
|
143
|
+
|
|
144
|
+
self.holds = holds.except(buyer)
|
|
145
|
+
self.available += 1
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Obey these rules in actor code:
|
|
151
|
+
|
|
152
|
+
- Keep state in `attribute` values. State must be JSON-compatible.
|
|
153
|
+
- Use `schedule(at:, key:)` for delayed work. A reminder is one named alarm
|
|
154
|
+
for each actor and key. A new `schedule` with the same key moves the alarm.
|
|
155
|
+
- Use `reject(code, message)` for a business rule failure that must not retry.
|
|
156
|
+
- Do not write Active Record models directly in a handler. The runtime raises
|
|
157
|
+
`SolidObjects::ApplicationWriteForbidden`. Use `commit_action` for a short
|
|
158
|
+
write in the same database.
|
|
159
|
+
- Do not call an external API in a handler. Use `emit` and an effect handler.
|
|
160
|
+
- Write each handler so that it can run again. Delivery is at least once.
|
|
161
|
+
|
|
162
|
+
[Reminders](reminders.md) and the [architecture guide](architecture.md) give
|
|
163
|
+
the full actor API.
|
|
164
|
+
|
|
165
|
+
## 7. Run the runtime process
|
|
166
|
+
|
|
167
|
+
A direct call, such as `TicketSale.ref("event-42").hold(buyer: "ada")`, runs in
|
|
168
|
+
the caller. It needs no worker. These features need the runtime process:
|
|
169
|
+
|
|
170
|
+
- Reminders from `schedule`.
|
|
171
|
+
- `async` calls.
|
|
172
|
+
- Effects from `emit` and their callbacks.
|
|
173
|
+
- Broadcasts to Action Cable.
|
|
174
|
+
|
|
175
|
+
Start it beside the web process:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
bundle exec solid_objects start
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Add it to the `Procfile`, the process manager, or the deployment
|
|
182
|
+
configuration. When it stops, pending work stays in SQL and runs after it
|
|
183
|
+
starts again. [Operations](operations.md#runtime) covers roles and shutdown.
|
|
184
|
+
|
|
185
|
+
## 8. Make external effects idempotent
|
|
186
|
+
|
|
187
|
+
Register an effect handler at boot. Use `context.id` as the provider
|
|
188
|
+
idempotency key:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
SolidObjects.register_effect(:charge_payment) do |arguments, context|
|
|
192
|
+
Payments.charge(
|
|
193
|
+
idempotency_key: context.id,
|
|
194
|
+
payment_id: arguments.fetch("payment_id"),
|
|
195
|
+
amount_cents: arguments.fetch("amount_cents")
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Stage it from the actor:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
emit :charge_payment, payment_id:, amount_cents:, on_success: :charged
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The effect can run more than once after a crash. The `context.id` value is the
|
|
207
|
+
same each time. [Effect recovery](effect-recovery.md) explains how to retire
|
|
208
|
+
abandoned work.
|
|
209
|
+
|
|
210
|
+
## 9. Verify the implementation
|
|
211
|
+
|
|
212
|
+
Do these checks before you report that the work is complete:
|
|
213
|
+
|
|
214
|
+
1. Run `bin/rails solid_objects:doctor`. It must report no failures.
|
|
215
|
+
2. Write a test that includes `SolidObjects::TestHelper`. Send concurrent
|
|
216
|
+
calls to one identity from several threads. Assert the final state, for
|
|
217
|
+
example that only one hold succeeded.
|
|
218
|
+
3. Use `run_due_reminders(now:)` and `drain_solid_objects` to test delayed
|
|
219
|
+
work without sleeps.
|
|
220
|
+
4. Start `bundle exec solid_objects start`, schedule a short reminder, and stop
|
|
221
|
+
the process. Start it again after the deadline and confirm that the reminder
|
|
222
|
+
ran.
|
|
223
|
+
5. Confirm that each effect handler deduplicates with `context.id`.
|
|
224
|
+
6. Confirm that production policies do not grant access to every caller.
|
|
225
|
+
|
|
226
|
+
[Host application tests](development.md#host-application-tests) describes the
|
|
227
|
+
test helper. The [clean-install quickstart](../examples/quickstart/README.md)
|
|
228
|
+
runs checks 2 and 4 against a new Rails application.
|
|
229
|
+
|
|
230
|
+
## 10. Troubleshooting
|
|
231
|
+
|
|
232
|
+
| Symptom | Cause and fix |
|
|
233
|
+
| --- | --- |
|
|
234
|
+
| `SolidObjects::Unauthorized` | A policy denied the call. Write the policy, and pass `authorization_context:` |
|
|
235
|
+
| A reminder or `async` call does not run | The runtime process is not running. Start `bundle exec solid_objects start` |
|
|
236
|
+
| `SolidObjects::SyncInsideTransaction` | The call ran inside an open transaction. Call the actor outside the transaction. In tests, include `SolidObjects::TestHelper` |
|
|
237
|
+
| `SolidObjects::SyncTimeout` | The call did not finish in time. The message is still durable. Use its `message_reference` to wait for the result |
|
|
238
|
+
| `SolidObjects::ApplicationWriteForbidden` | A handler wrote a model directly. Use `commit_action` or `emit` |
|
|
239
|
+
| `SolidObjects::Rejected` | The actor called `reject`. This is a business result, not a retry |
|
|
240
|
+
| `ArgumentError` from `ActiveSupport::JSON`, such as `unknown keyword: quirks_mode` | `json` 3.x with Active Support before 8.1.4. Upgrade Rails to 8.1.4 or newer, or pin `gem "json", "~> 2"` |
|
|
241
|
+
|
|
242
|
+
## 11. Guarantees to state correctly
|
|
243
|
+
|
|
244
|
+
When you explain Solid Objects to a user, state these limits:
|
|
245
|
+
|
|
246
|
+
- Delivery is at least once, not exactly once.
|
|
247
|
+
- Calls for one identity are ordered. Different identities run concurrently.
|
|
248
|
+
- There are no transactions across actor identities.
|
|
249
|
+
- Fencing stops a stale activation from a commit, but its code can continue to
|
|
250
|
+
run.
|
|
251
|
+
- The gem is pre-1.0 and makes no production-ready claim.
|
|
252
|
+
|
|
253
|
+
The [correctness contract](correctness.md) is the source for each guarantee.
|
data/docs/architecture.md
CHANGED
|
@@ -167,10 +167,12 @@ creating a message or activation, applies required state migrations in memory,
|
|
|
167
167
|
and returns deeply frozen declared attributes. It can race with an in-flight
|
|
168
168
|
turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
|
|
169
169
|
|
|
170
|
-
`message` and `query` both execute as durable mailbox turns.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
170
|
+
`message` and `query` both execute as durable mailbox turns. Queries and
|
|
171
|
+
observables must not mutate state or stage effects, recovery checks, commit
|
|
172
|
+
actions, reminders, or outbound messages. Violations raise `QueryMutatedState`
|
|
173
|
+
and fail the message without retrying or committing its work. Individual snapshot
|
|
174
|
+
projections enforce the same rule. An observable is a named projection used by
|
|
175
|
+
server rendering and realtime updates. Its durable broadcast row stores only an empty invalidation marker by
|
|
174
176
|
default. `broadcast: :value` explicitly opts into storing and sharing the
|
|
175
177
|
projected value.
|
|
176
178
|
|
|
@@ -431,8 +433,16 @@ Each lookup runs the authorization hook the original call ran, against the
|
|
|
431
433
|
stored operation and arguments, and answers `nil` for an absent row, an
|
|
432
434
|
unregistered actor, and a refused caller alike, so it cannot be used to ask
|
|
433
435
|
whether a request id exists. `MessageReference#outcome` reports the status, the
|
|
434
|
-
result, the persisted error, the rejection, and the attempt count.
|
|
435
|
-
|
|
436
|
+
result, the persisted error, the rejection, and the attempt count. Every delivery
|
|
437
|
+
mode stores its JSON result, including background messages. Result serialization
|
|
438
|
+
and `max_result_bytes` apply before commit; a result that cannot be stored fails
|
|
439
|
+
the turn. Return `nil` explicitly from operations that need no result.
|
|
440
|
+
|
|
441
|
+
`status`, `result`, and `outcome` reauthorize the stored operation on every read.
|
|
442
|
+
Pass `authorization_context:` each time; references retain identity rather than
|
|
443
|
+
caller permissions. `result` raises `Rejected` or `MessageFailed` for terminal
|
|
444
|
+
errors and returns a deeply frozen successful value. `outcome` reports terminal
|
|
445
|
+
errors as data.
|
|
436
446
|
|
|
437
447
|
An actor remembers the idempotency keys of its own finished turns. The executor
|
|
438
448
|
already writes the instance row in the transaction that completes, rejects, or
|
data/docs/authorization.md
CHANGED
|
@@ -13,13 +13,20 @@ answers nothing until the host application defines its trust boundary.
|
|
|
13
13
|
| `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants |
|
|
14
14
|
| `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
|
|
15
15
|
| `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
|
|
16
|
-
| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning,
|
|
16
|
+
| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, dead-letter inspection/retry, and actor diagnostics and observers | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Every `MessageReference#status`, `#result`, `#outcome`, and `#wait` call
|
|
19
|
+
reauthorizes the stored operation and arguments as a message or query. Supply
|
|
20
|
+
`authorization_context:` on each call, including after `find_by`; the reference
|
|
21
|
+
does not retain the original caller's context. Internal reminder, effect-callback, and
|
|
20
22
|
actor-to-actor deliveries come from
|
|
21
23
|
already committed runtime rows and do not re-enter the public client policy.
|
|
22
24
|
|
|
25
|
+
Actor diagnostics and actor observers call `authorize_administration` with the
|
|
26
|
+
resource `actor_diagnostics` and the resource ID `[actor_type, actor_id].to_json`.
|
|
27
|
+
`diagnostics` uses the action `:inspect`. `observe` and `on` use the action
|
|
28
|
+
`:observe`. The check runs before any queue read or observer registration.
|
|
29
|
+
|
|
23
30
|
## Realtime authorization contexts
|
|
24
31
|
|
|
25
32
|
Reactive components cross three Rails execution contexts and authorize at all
|