solid_objects 0.2.0 → 0.2.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 +9 -0
- data/README.md +95 -8
- data/benchmark/adoption_latency.rb +5 -0
- data/benchmark/support.rb +35 -0
- data/docs/authorization.md +85 -0
- data/docs/benchmarks.md +71 -1
- data/docs/development.md +5 -4
- data/docs/fit.md +90 -0
- data/docs/migrating-existing-state.md +133 -0
- data/docs/operations.md +55 -3
- data/docs/roadmap.md +4 -1
- data/docs/security.md +3 -0
- data/docs/state-migrations.md +4 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +17 -0
- data/lib/solid_objects/doctor.rb +311 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/tasks/solid_objects_tasks.rake +10 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 654720ae19caa970f403b5cded61287e1f6a9eb9d731ff90fd9f4f103d55fa53
|
|
4
|
+
data.tar.gz: 256712423dd88685643a0345de44eb774efbca12188c78c9e89571ef884b59fc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f1e43da5f13c33558bdb187b52c55b55093786d3ec8af9f49d4ffc123ade274d2f5483dd8975c6a638152dfc9ae1c4cf416d8b1ea94cb1383ac30b33ef7548aa
|
|
7
|
+
data.tar.gz: f8cb10850b5b3748450ffaf741417866ed360437657c5d21836312018f0d6b27109cd69c4035f770f6cf643227ab086ae7eedc1b0a14245103cfe517a008f49c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 - 2026-08-06
|
|
4
|
+
|
|
5
|
+
- Add `solid_objects:doctor` for configuration, schema, policy, runtime, and
|
|
6
|
+
workerless synchronous round-trip verification.
|
|
7
|
+
- Add onboarding guidance for fit decisions, worker requirements,
|
|
8
|
+
authorization, performance and row growth, retention, Sorbet, RuboCop, and
|
|
9
|
+
migrations from existing state stores.
|
|
10
|
+
- Make the early-Action View engine boot regression explicit.
|
|
11
|
+
|
|
3
12
|
## 0.2.0 - 2026-08-06
|
|
4
13
|
|
|
5
14
|
- Make direct actor methods synchronous Durable Object-style RPC.
|
data/README.md
CHANGED
|
@@ -20,8 +20,13 @@ class Counter < SolidObjects::Actor
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
#
|
|
24
|
-
Counter.ref("global")
|
|
23
|
+
# Synchronous caller-assisted RPC. No worker fleet is required.
|
|
24
|
+
counter = Counter.ref("global")
|
|
25
|
+
count = counter.increment(amount: 5)
|
|
26
|
+
current_count = counter.value
|
|
27
|
+
|
|
28
|
+
# Durable fire-and-forget delivery. A worker processes it later.
|
|
29
|
+
message = counter.async(:increment, amount: 5)
|
|
25
30
|
```
|
|
26
31
|
|
|
27
32
|
`Counter / global` is a logical identity. Like a Durable Object named with
|
|
@@ -30,6 +35,23 @@ locating a Ruby object. Solid Objects activates it when work arrives, commits
|
|
|
30
35
|
its ordered turns one at a time, persists its state, and deactivates it when
|
|
31
36
|
idle. Different identities can run concurrently.
|
|
32
37
|
|
|
38
|
+
The invocation model is the first adoption decision:
|
|
39
|
+
|
|
40
|
+
| Call | Returns | Worker fleet required? |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `counter.increment(amount: 5)` | Committed handler result | No |
|
|
43
|
+
| `counter.sync(:increment, amount: 5)` | Committed handler result | No |
|
|
44
|
+
| `counter.value` | Deeply frozen state snapshot | No |
|
|
45
|
+
| `counter.async(:increment, amount: 5)` | `MessageReference` immediately | Yes |
|
|
46
|
+
|
|
47
|
+
Direct methods and `sync` durably enqueue the call, then the Rails caller helps
|
|
48
|
+
execute the actor through the same mailbox, lease, and fencing path as a
|
|
49
|
+
worker. `async` only enqueues; a runtime process handles it later.
|
|
50
|
+
|
|
51
|
+
Before adopting a latency-sensitive or high-volume surface, read
|
|
52
|
+
[Is Solid Objects a good fit?](docs/fit.md) and the
|
|
53
|
+
[measured performance and row-growth costs](docs/benchmarks.md).
|
|
54
|
+
|
|
33
55
|
This is a port of the programming model, not Cloudflare's edge runtime or
|
|
34
56
|
platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
|
|
35
57
|
and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
|
|
@@ -43,6 +65,7 @@ but the project does not yet claim production readiness. See
|
|
|
43
65
|
- [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
|
|
44
66
|
- [Reactive ERB](#reactive-erb)
|
|
45
67
|
- [Installation](#installation)
|
|
68
|
+
- [Worker requirements](#worker-requirements)
|
|
46
69
|
- [Defining an actor](#defining-an-actor)
|
|
47
70
|
- [Actor identity](#actor-identity)
|
|
48
71
|
- [Invoking an object](#invoking-an-object)
|
|
@@ -187,10 +210,17 @@ Add the gem, install its initializer and migration, then migrate:
|
|
|
187
210
|
bundle add solid_objects
|
|
188
211
|
bin/rails generate solid_objects:install
|
|
189
212
|
bin/rails db:migrate
|
|
213
|
+
bin/rails solid_objects:doctor
|
|
190
214
|
```
|
|
191
215
|
|
|
192
|
-
The
|
|
193
|
-
|
|
216
|
+
The doctor validates configuration and required schema shape, reports
|
|
217
|
+
authorization posture and live runtime roles, and completes a real synchronous
|
|
218
|
+
actor round-trip without a worker. It checks required tables and columns instead
|
|
219
|
+
of a copied migration timestamp, which the host application rewrites. It exits
|
|
220
|
+
unsuccessfully when configuration, schema, or the round-trip is broken.
|
|
221
|
+
|
|
222
|
+
The generated initializer is intentionally inert: all five policies deny by
|
|
223
|
+
default. Replace them with application-specific authorization before sending
|
|
194
224
|
messages, querying state, destroying actors, subscribing to streams, or
|
|
195
225
|
mounting administration routes:
|
|
196
226
|
|
|
@@ -205,16 +235,62 @@ end
|
|
|
205
235
|
```
|
|
206
236
|
|
|
207
237
|
Knowledge of an actor ID or signed stream token is never authorization.
|
|
238
|
+
Read the [policy reference and tenant-aware example](docs/authorization.md)
|
|
239
|
+
before opening a policy. Unconditionally allowing message and query calls is
|
|
240
|
+
reasonable only for a controlled server-side pilot. Keep destroy,
|
|
241
|
+
subscription, and administration denied until each has an authenticated
|
|
242
|
+
caller.
|
|
208
243
|
|
|
209
|
-
|
|
210
|
-
|
|
244
|
+
The engine uses the application's primary Active Record connection by default.
|
|
245
|
+
See [Database support](#database-support) for a separate database configuration.
|
|
246
|
+
|
|
247
|
+
### Host application tooling
|
|
248
|
+
|
|
249
|
+
Installed engine migrations are copied as
|
|
250
|
+
`db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
|
|
251
|
+
`Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than
|
|
252
|
+
editing their intentionally specialized hot tables:
|
|
253
|
+
|
|
254
|
+
```yaml
|
|
255
|
+
Rails/CreateTableWithTimestamps:
|
|
256
|
+
Exclude:
|
|
257
|
+
- "db/migrate/*.solid_objects.rb"
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Solid Objects ships inline RBS signatures, not RBI files. Sorbet applications
|
|
261
|
+
can generate the gem RBI with:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
bundle exec tapioca gem solid_objects
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Worker requirements
|
|
268
|
+
|
|
269
|
+
Synchronous actors can be adopted without adding a long-running process. Start
|
|
270
|
+
the runtime when the feature introduces asynchronous delivery or outboxes:
|
|
271
|
+
|
|
272
|
+
| Feature | Runtime roles required |
|
|
273
|
+
| --- | --- |
|
|
274
|
+
| Direct actor method or explicit `sync` | None; the caller executes it |
|
|
275
|
+
| Attribute or declared query read | None; the caller executes it |
|
|
276
|
+
| `destroy` | None |
|
|
277
|
+
| `async` including delayed delivery | Actor worker |
|
|
278
|
+
| One-shot or recurring `schedule` | Reminder scheduler and actor worker |
|
|
279
|
+
| `emit` without an actor callback | Effect worker |
|
|
280
|
+
| `emit` with success or failure callback | Effect worker and actor worker |
|
|
281
|
+
| Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
|
|
282
|
+
| Observable Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
|
|
283
|
+
| Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
|
|
284
|
+
|
|
285
|
+
One command starts every Solid Objects role:
|
|
211
286
|
|
|
212
287
|
```bash
|
|
213
288
|
bundle exec solid_objects start
|
|
214
289
|
```
|
|
215
290
|
|
|
216
|
-
|
|
217
|
-
|
|
291
|
+
Deploy and monitor that process before enabling any feature marked as requiring
|
|
292
|
+
a runtime role. A missing worker never makes a durable `async` message
|
|
293
|
+
disappear, but it leaves the message pending indefinitely.
|
|
218
294
|
|
|
219
295
|
## Defining an actor
|
|
220
296
|
|
|
@@ -396,6 +472,10 @@ The caller receives `SolidObjects::Rejected` with a stable code, message, and
|
|
|
396
472
|
JSON-compatible details. The rejected message remains durable for audit, actor
|
|
397
473
|
state is rolled back, and no later mailbox turn is blocked.
|
|
398
474
|
|
|
475
|
+
`Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
|
|
476
|
+
match `\A[a-z][a-z0-9_]*\z`; invalid codes raise `ArgumentError` when the
|
|
477
|
+
handler calls `reject`.
|
|
478
|
+
|
|
399
479
|
### Redelivery
|
|
400
480
|
|
|
401
481
|
Sequential does not mean once. A handler can run again after a process crash or
|
|
@@ -686,6 +766,13 @@ Do not use it for stateless work, bulk pipelines, CPU-heavy computation,
|
|
|
686
766
|
cross-actor transactions, slow network calls inside handlers, or domains that
|
|
687
767
|
are clearer as normalized Active Record models and direct service objects.
|
|
688
768
|
|
|
769
|
+
High-QPS request reads, rate-limit counters, impression pipelines, large JSON
|
|
770
|
+
documents, and latency budgets that cannot tolerate several coordination
|
|
771
|
+
transactions are explicit anti-patterns. Read the full
|
|
772
|
+
[fit and anti-pattern guide](docs/fit.md) before migrating an existing
|
|
773
|
+
surface, and use the [legacy-state migration cookbook](docs/migrating-existing-state.md)
|
|
774
|
+
for staged cutovers.
|
|
775
|
+
|
|
689
776
|
## Comparisons
|
|
690
777
|
|
|
691
778
|
| Tool | What Solid Objects adds or changes |
|
data/benchmark/support.rb
CHANGED
|
@@ -145,6 +145,32 @@ module SolidObjectsBenchmark
|
|
|
145
145
|
"p99=#{milliseconds(percentile(sorted, 0.99))}ms"
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
# @rbs () -> void
|
|
149
|
+
def adoption_latency
|
|
150
|
+
instance_count = SolidObjects::Instance.count
|
|
151
|
+
message_count = SolidObjects::Message.count
|
|
152
|
+
|
|
153
|
+
cold_elapsed = Benchmark.realtime do
|
|
154
|
+
CounterActor.ref("adoption-cold").increment
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
reference = CounterActor.ref("adoption-warm")
|
|
158
|
+
reference.increment
|
|
159
|
+
write_samples = Array.new(count) do
|
|
160
|
+
Benchmark.realtime { reference.increment }
|
|
161
|
+
end
|
|
162
|
+
read_samples = Array.new(count) do
|
|
163
|
+
Benchmark.realtime { reference.count }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
puts "first cold call: #{milliseconds(cold_elapsed)}ms"
|
|
167
|
+
puts latency_summary("warm writes", write_samples)
|
|
168
|
+
puts latency_summary("ordered reads", read_samples)
|
|
169
|
+
puts "durable row growth: " \
|
|
170
|
+
"instances=+#{SolidObjects::Instance.count - instance_count}, " \
|
|
171
|
+
"messages=+#{SolidObjects::Message.count - message_count}"
|
|
172
|
+
end
|
|
173
|
+
|
|
148
174
|
# @rbs () -> void
|
|
149
175
|
def activation_cache
|
|
150
176
|
reference = CounterActor.ref("cache")
|
|
@@ -255,6 +281,15 @@ module SolidObjectsBenchmark
|
|
|
255
281
|
samples.fetch(((samples.length - 1) * fraction).ceil)
|
|
256
282
|
end
|
|
257
283
|
|
|
284
|
+
# @rbs (String, Array[Float]) -> String
|
|
285
|
+
def latency_summary(name, samples)
|
|
286
|
+
sorted = samples.sort
|
|
287
|
+
"#{name} #{samples.length} calls: " \
|
|
288
|
+
"median=#{milliseconds(percentile(sorted, 0.50))}ms " \
|
|
289
|
+
"min=#{milliseconds(sorted.first)}ms " \
|
|
290
|
+
"max=#{milliseconds(sorted.last)}ms"
|
|
291
|
+
end
|
|
292
|
+
|
|
258
293
|
# @rbs (Float) -> String
|
|
259
294
|
def milliseconds(seconds)
|
|
260
295
|
format("%.1f", seconds * 1_000)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Authorization policies
|
|
2
|
+
|
|
3
|
+
Solid Objects treats actor identities as identifiers, never capabilities.
|
|
4
|
+
Knowing an actor ID, message ID, or signed stream token grants no permission.
|
|
5
|
+
All five policies deny by default, so a generated installation is
|
|
6
|
+
intentionally inert until the host application defines its trust boundary.
|
|
7
|
+
|
|
8
|
+
## Policy reference
|
|
9
|
+
|
|
10
|
+
| Policy | Gates | Caller context | Risk if opened globally |
|
|
11
|
+
| --- | --- | --- | --- |
|
|
12
|
+
| `authorize_message` | Direct actor methods, explicit `sync` messages, and public `async` enqueue | Value passed as `authorization_context:`; often a user, service principal, or trusted internal marker | Anyone reaching the call site can mutate any known actor identity |
|
|
13
|
+
| `authorize_query` | Attribute reads, declared queries, observable reads, and component reads | Explicit call context or the Rails view context supplied by `solid_object` | Actor state can leak across users or tenants |
|
|
14
|
+
| `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
|
|
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, process inspection/cleanup, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, and retries become exposed or mutable |
|
|
17
|
+
|
|
18
|
+
Internal reminder, effect-callback, and actor-to-actor deliveries come from
|
|
19
|
+
already committed runtime rows and do not re-enter the public client policy.
|
|
20
|
+
|
|
21
|
+
## A tenant-aware policy
|
|
22
|
+
|
|
23
|
+
Pass the authenticated user as the call context:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
cart = ShoppingCart.ref(Current.user.id)
|
|
27
|
+
cart.add_item(
|
|
28
|
+
product_id: "shirt-123",
|
|
29
|
+
authorization_context: Current.user
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Authorize only the matching user and actor type:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
SolidObjects.configure do |configuration|
|
|
37
|
+
owns_actor = lambda do |actor_type:, actor_id:, authorization_context:, **|
|
|
38
|
+
user = authorization_context
|
|
39
|
+
|
|
40
|
+
actor_type == "ShoppingCart" &&
|
|
41
|
+
user.present? &&
|
|
42
|
+
actor_id == user.id.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
configuration.authorize_message = owns_actor
|
|
46
|
+
configuration.authorize_query = owns_actor
|
|
47
|
+
configuration.authorize_destroy = owns_actor
|
|
48
|
+
|
|
49
|
+
configuration.authorize_subscription = lambda do |actor_type:, actor_id:, authorization_context:|
|
|
50
|
+
connection = authorization_context
|
|
51
|
+
|
|
52
|
+
actor_type == "ShoppingCart" &&
|
|
53
|
+
connection.current_user.present? &&
|
|
54
|
+
actor_id == connection.current_user.id.to_s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
configuration.authorize_administration = lambda do |authorization_context:, **|
|
|
58
|
+
context = authorization_context
|
|
59
|
+
user = context.respond_to?(:current_user) ? context.current_user : nil
|
|
60
|
+
|
|
61
|
+
user&.administrator?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The policy receives normalized actor type and ID strings, message name and
|
|
67
|
+
arguments where relevant, and the context supplied by the caller. Avoid
|
|
68
|
+
authorizing from arguments alone; bind the actor identity to the authenticated
|
|
69
|
+
principal and tenant.
|
|
70
|
+
|
|
71
|
+
## Server-side-only pilots
|
|
72
|
+
|
|
73
|
+
Allowing `authorize_message` and `authorize_query` unconditionally can be a
|
|
74
|
+
reasonable short-lived pilot only when every call site is trusted server code,
|
|
75
|
+
actor IDs cannot come from an unauthorized request, and the feature is not
|
|
76
|
+
exposed through Action Cable or administration routes.
|
|
77
|
+
|
|
78
|
+
Keep `authorize_destroy`, `authorize_subscription`, and
|
|
79
|
+
`authorize_administration` denied until each feature has an explicit policy.
|
|
80
|
+
Replace unconditional policies before exposing actor IDs to controllers, API
|
|
81
|
+
clients, MCP tools, jobs carrying user input, or browser subscriptions.
|
|
82
|
+
|
|
83
|
+
Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
|
|
84
|
+
probe is deliberately conservative: a context-aware policy may correctly warn
|
|
85
|
+
because it denies a `nil` context.
|
data/docs/benchmarks.md
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Performance and storage costs
|
|
2
2
|
|
|
3
3
|
These numbers are development measurements, not universal capacity guarantees.
|
|
4
4
|
They include the runtime's Active Record and database query overhead and will
|
|
5
5
|
vary with hardware, schema size, connection pools, durability settings, and
|
|
6
6
|
contention.
|
|
7
7
|
|
|
8
|
+
## Production-shaped adoption measurement
|
|
9
|
+
|
|
10
|
+
An adoption evaluation measured Solid Objects 0.2.0 from a macOS Rails process
|
|
11
|
+
against Docker MySQL 8 over a published TCP port. The host used Rails 8.1, Ruby
|
|
12
|
+
4.0.5, roughly 165 gems, and an approximately 2,200-line schema.
|
|
13
|
+
|
|
14
|
+
| Operation | Existing key-value row | Solid Objects |
|
|
15
|
+
| --- | ---: | ---: |
|
|
16
|
+
| Write | median 4.7 ms | median 60 ms, minimum 33 ms, maximum 163 ms |
|
|
17
|
+
| Read | median 0.2 ms | median 28 ms |
|
|
18
|
+
| First call for a cold identity | approximately 5 ms | 315 ms |
|
|
19
|
+
|
|
20
|
+
This is not a controlled cross-database benchmark and no sample count was
|
|
21
|
+
recorded. It is still useful adoption evidence: a synchronous actor call is not
|
|
22
|
+
a substitute for a direct indexed row read when single-digit-millisecond
|
|
23
|
+
latency is the requirement. The first call includes actor-instance creation,
|
|
24
|
+
caller-process registration, message enqueue, activation claim, handler
|
|
25
|
+
execution, fenced commit, and activation release.
|
|
26
|
+
|
|
27
|
+
## Project development benchmark
|
|
28
|
+
|
|
8
29
|
Measured 2026-08-06 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1,
|
|
9
30
|
and SQLite 3.51.0. Each throughput scenario used 200 operations; the concurrent
|
|
10
31
|
scenario used four worker threads.
|
|
@@ -21,6 +42,55 @@ scenario used four worker threads.
|
|
|
21
42
|
| Activation reuse | 98.0%, four activations for 200 messages |
|
|
22
43
|
| Queries for one message turn | 29 |
|
|
23
44
|
|
|
45
|
+
The difference between the SQLite development result and the MySQL adoption
|
|
46
|
+
result is why Solid Objects does not publish one latency promise. Network
|
|
47
|
+
topology, adapter behavior, host schema, logging, callbacks, and contention all
|
|
48
|
+
matter.
|
|
49
|
+
|
|
50
|
+
## Durable row growth
|
|
51
|
+
|
|
52
|
+
The storage cost is deterministic even when latency is not:
|
|
53
|
+
|
|
54
|
+
- the first call for one actor identity inserts one
|
|
55
|
+
`solid_objects_instances` row;
|
|
56
|
+
- every direct, `sync`, query, attribute read, or `async` call inserts one
|
|
57
|
+
permanent `solid_objects_messages` row;
|
|
58
|
+
- ready and claimed membership rows exist only while the call is pending or
|
|
59
|
+
executing;
|
|
60
|
+
- one caller process row is registered per application process that performs
|
|
61
|
+
synchronous calls;
|
|
62
|
+
- effects and observable changes add outbox rows; and
|
|
63
|
+
- reminders add one row per named actor reminder.
|
|
64
|
+
|
|
65
|
+
Attribute reads are therefore not free snapshots from the instance row. They
|
|
66
|
+
are ordered durable query messages and grow message history exactly like
|
|
67
|
+
writes.
|
|
68
|
+
|
|
69
|
+
Version 0.2 has cleanup indexes but no built-in pruning command. Budget message
|
|
70
|
+
growth as:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
daily durable messages = daily actor writes + daily actor reads + daily callbacks
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Review the [retention requirements](operations.md#retention-and-backups) before
|
|
77
|
+
adopting a high-volume surface.
|
|
78
|
+
|
|
79
|
+
## Measure the host application
|
|
80
|
+
|
|
81
|
+
Run the adoption benchmark against a dedicated empty database with the same
|
|
82
|
+
adapter and topology as production:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
COUNT=25 \
|
|
86
|
+
SOLID_OBJECTS_DATABASE_URL=mysql2://localhost/solid_objects_benchmark \
|
|
87
|
+
bundle exec ruby -Ilib benchmark/adoption_latency.rb
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
It reports the first cold call, warm synchronous writes, ordered reads, and
|
|
91
|
+
durable instance/message growth. Run it near the application process, with
|
|
92
|
+
production-like TLS and network boundaries where applicable.
|
|
93
|
+
|
|
24
94
|
The scripts and invocation examples are in the
|
|
25
95
|
[development guide](development.md#benchmarks). PostgreSQL and MySQL should be
|
|
26
96
|
benchmarked independently before selecting production capacity.
|
data/docs/development.md
CHANGED
|
@@ -66,12 +66,13 @@ correct change, rerun the focused test, then the complete database matrix.
|
|
|
66
66
|
|
|
67
67
|
## Benchmarks
|
|
68
68
|
|
|
69
|
-
Scripts in `benchmark/` cover
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
capacity guarantees.
|
|
69
|
+
Scripts in `benchmark/` cover adoption latency and durable row growth, enqueue,
|
|
70
|
+
claim, processing, cold actors, a hot actor, concurrent actors, synchronous
|
|
71
|
+
latency, cache reuse, and query counts. Results describe one machine and
|
|
72
|
+
database configuration; they are not universal capacity guarantees.
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
+
COUNT=25 bundle exec ruby -Ilib benchmark/adoption_latency.rb
|
|
75
76
|
COUNT=500 bundle exec ruby -Ilib benchmark/enqueue.rb
|
|
76
77
|
COUNT=500 bundle exec ruby -Ilib benchmark/claim.rb
|
|
77
78
|
COUNT=500 bundle exec ruby -Ilib benchmark/processing.rb
|
data/docs/fit.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Is Solid Objects a good fit?
|
|
2
|
+
|
|
3
|
+
Solid Objects trades database work and retained message history for one strong
|
|
4
|
+
property: all committed turns for one durable identity execute in order behind
|
|
5
|
+
a fenced activation. Adopt it when that coordination property removes
|
|
6
|
+
application-level locking, recovery, and scheduling code that would otherwise
|
|
7
|
+
be difficult to make correct.
|
|
8
|
+
|
|
9
|
+
## Strong fit signals
|
|
10
|
+
|
|
11
|
+
Solid Objects is a good candidate when most of these are true:
|
|
12
|
+
|
|
13
|
+
- State belongs to one durable identity such as a cart, room, device, session,
|
|
14
|
+
workflow, or user-specific schedule.
|
|
15
|
+
- Writes for that identity must be serialized.
|
|
16
|
+
- The state is naturally a bounded JSON document.
|
|
17
|
+
- The object needs per-identity reminders, transactional external effects, or
|
|
18
|
+
reactive Rails views.
|
|
19
|
+
- Different identities should run concurrently while one hot identity remains
|
|
20
|
+
deliberately sequential.
|
|
21
|
+
- A durable mailbox and at-least-once retry are more valuable than minimum
|
|
22
|
+
request latency.
|
|
23
|
+
- The application can operate and monitor additional database tables and, for
|
|
24
|
+
asynchronous features, a Solid Objects runtime process.
|
|
25
|
+
|
|
26
|
+
Typical fits include checkout state machines, collaborative rooms, device
|
|
27
|
+
twins, durable assessments, approval workflows, and user-specific scheduling.
|
|
28
|
+
|
|
29
|
+
## Poor fit and anti-patterns
|
|
30
|
+
|
|
31
|
+
Prefer ordinary Active Record, cache storage, Active Job, or an event pipeline
|
|
32
|
+
when any of these dominate:
|
|
33
|
+
|
|
34
|
+
- High-QPS request-path reads. Every actor attribute read is an ordered durable
|
|
35
|
+
message, not a direct `SELECT`, and retains a message-history row.
|
|
36
|
+
- Hot counters such as abuse limits, impressions, page views, or metrics. One
|
|
37
|
+
identity is a serialization point and cannot gain throughput by adding
|
|
38
|
+
workers.
|
|
39
|
+
- High-volume append workloads. Actor state rewrites a JSON document and the
|
|
40
|
+
mailbox retains one durable message per call.
|
|
41
|
+
- Latency budgets where tens of milliseconds are already unacceptable.
|
|
42
|
+
- Large, relational, or query-heavy state. Keep that data normalized in
|
|
43
|
+
application tables.
|
|
44
|
+
- CPU-heavy work or slow network I/O inside a handler.
|
|
45
|
+
- Cross-actor transactions or synchronous actor-to-actor call graphs.
|
|
46
|
+
- State that is clearer as a normal record with database constraints and direct
|
|
47
|
+
service methods.
|
|
48
|
+
|
|
49
|
+
A rate limiter is usually a poor actor: it is hot, request-critical, and often
|
|
50
|
+
expires rather than requiring permanent message history. An impressions
|
|
51
|
+
pipeline is also a poor actor: its value is high-throughput append and
|
|
52
|
+
aggregation, not serialized mutable state.
|
|
53
|
+
|
|
54
|
+
## Cost model
|
|
55
|
+
|
|
56
|
+
Every synchronous or asynchronous invocation:
|
|
57
|
+
|
|
58
|
+
- inserts one permanent `solid_objects_messages` row;
|
|
59
|
+
- briefly occupies one ready or claimed membership row;
|
|
60
|
+
- performs several short coordination transactions; and
|
|
61
|
+
- may add effect, broadcast, or reminder records.
|
|
62
|
+
|
|
63
|
+
The first call for an identity also inserts one `solid_objects_instances` row.
|
|
64
|
+
Each application process that performs synchronous calls registers one caller
|
|
65
|
+
process row. Actor state is rewritten as a JSON value on each successful
|
|
66
|
+
mutation.
|
|
67
|
+
|
|
68
|
+
There is no built-in retention command in 0.2. Completed message history grows
|
|
69
|
+
until the host application implements a reviewed retention policy. See
|
|
70
|
+
[performance measurements](benchmarks.md) and
|
|
71
|
+
[retention guidance](operations.md#retention-and-backups).
|
|
72
|
+
|
|
73
|
+
## Decision checklist
|
|
74
|
+
|
|
75
|
+
Before adopting an actor, answer:
|
|
76
|
+
|
|
77
|
+
1. What exact race or lifecycle problem requires serialized per-identity turns?
|
|
78
|
+
2. What is the canonical actor identity?
|
|
79
|
+
3. How hot can one identity become?
|
|
80
|
+
4. Can the request path tolerate the measured cold and warm latency?
|
|
81
|
+
5. How many calls and durable rows will this surface create per day?
|
|
82
|
+
6. Which calls can be asynchronous?
|
|
83
|
+
7. Which effects need downstream idempotency?
|
|
84
|
+
8. Which runtime roles and operational alerts will the feature require?
|
|
85
|
+
9. How will completed messages and outbox history be retained?
|
|
86
|
+
10. How will existing state be cut over and rolled back?
|
|
87
|
+
|
|
88
|
+
Benchmark the actual host database and deployment topology before committing a
|
|
89
|
+
latency-sensitive surface. Local benchmark results are evidence about query
|
|
90
|
+
shape, not universal capacity guarantees.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Migrating existing state
|
|
2
|
+
|
|
3
|
+
Moving an existing Redis, cache, or key-value state machine into Solid Objects
|
|
4
|
+
is a data migration and a coordination cutover. Treat it as a staged production
|
|
5
|
+
change, not a rewrite that switches storage in one deploy.
|
|
6
|
+
|
|
7
|
+
## 1. Write down the existing contract
|
|
8
|
+
|
|
9
|
+
Inventory:
|
|
10
|
+
|
|
11
|
+
- every read and write path;
|
|
12
|
+
- the current canonical and secondary keys;
|
|
13
|
+
- expiration and cleanup behavior;
|
|
14
|
+
- concurrency guards and idempotency keys;
|
|
15
|
+
- external effects;
|
|
16
|
+
- expected request latency and volume; and
|
|
17
|
+
- rollback requirements.
|
|
18
|
+
|
|
19
|
+
Run the [fit checklist](fit.md#decision-checklist) before migrating. A hot
|
|
20
|
+
counter or append pipeline may be better left in its existing store.
|
|
21
|
+
|
|
22
|
+
## 2. Choose one canonical identity
|
|
23
|
+
|
|
24
|
+
Solid Objects addresses an actor with one `(actor_type, actor_id)` pair. Do not
|
|
25
|
+
hide two competing identities inside actor code or reintroduce a scan.
|
|
26
|
+
|
|
27
|
+
When existing state is written by `(user_id, assessment_short)` but read by
|
|
28
|
+
`session_id`, create a normalized lookup record:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
class AssessmentSession < ApplicationRecord
|
|
32
|
+
validates :session_id, uniqueness: true
|
|
33
|
+
validates :assessment_short, uniqueness: { scope: :user_id }
|
|
34
|
+
|
|
35
|
+
def actor
|
|
36
|
+
Assessment.ref(id)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The lookup row gives both old keys one stable primary key. The actor ID is the
|
|
42
|
+
lookup record ID, and ordinary indexed Active Record queries resolve either
|
|
43
|
+
external key. This is clearer and safer than delimiter-joining composite values
|
|
44
|
+
or preserving a `LIKE` scan.
|
|
45
|
+
|
|
46
|
+
Create and backfill the lookup table before actor traffic begins. Enforce every
|
|
47
|
+
identity invariant with unique database indexes.
|
|
48
|
+
|
|
49
|
+
## 3. Add an idempotent bootstrap message
|
|
50
|
+
|
|
51
|
+
Never bulk-update `solid_objects_instances.state`. Direct writes bypass actor
|
|
52
|
+
ordering, state migrations, observables, activation ownership, and fencing.
|
|
53
|
+
|
|
54
|
+
Import through a normal actor message:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
class Assessment < SolidObjects::Actor
|
|
58
|
+
attribute :imported, default: false
|
|
59
|
+
attribute :answers, default: -> { [] }
|
|
60
|
+
|
|
61
|
+
def bootstrap(answers:)
|
|
62
|
+
return if imported
|
|
63
|
+
|
|
64
|
+
self.answers = answers
|
|
65
|
+
self.imported = true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Give every bootstrap call an idempotency key derived from the legacy record:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
session.actor.async(
|
|
74
|
+
:bootstrap,
|
|
75
|
+
answers: legacy.answers,
|
|
76
|
+
idempotency_key: "legacy-assessment:#{legacy.id}",
|
|
77
|
+
available_at: jittered_time
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Spread large backfills over a dispatch window and monitor mailbox age,
|
|
82
|
+
failures, and dead letters. Asynchronous backfill requires the worker runtime.
|
|
83
|
+
|
|
84
|
+
## 4. Prefer shadow comparison over blind dual writes
|
|
85
|
+
|
|
86
|
+
Two independent stores cannot be updated atomically without a shared
|
|
87
|
+
transaction or outbox. A controller that writes Redis and an actor in sequence
|
|
88
|
+
can leave them divergent after a timeout or crash.
|
|
89
|
+
|
|
90
|
+
A safer rollout:
|
|
91
|
+
|
|
92
|
+
1. Keep the legacy store authoritative.
|
|
93
|
+
2. Bootstrap the actor from a consistent legacy snapshot.
|
|
94
|
+
3. Mirror new changes to the actor with stable idempotency keys.
|
|
95
|
+
4. Read both stores in a background comparison path.
|
|
96
|
+
5. Record divergence counts without changing the user response.
|
|
97
|
+
6. Repair through actor messages, never direct actor-state SQL.
|
|
98
|
+
7. Cut reads over only after divergence remains acceptably low.
|
|
99
|
+
|
|
100
|
+
If the actor becomes authoritative before the legacy system is retired, emit a
|
|
101
|
+
transactional effect that updates the legacy store. The effect is at least once,
|
|
102
|
+
so the legacy write still needs idempotency.
|
|
103
|
+
|
|
104
|
+
## 5. Cut over in reversible stages
|
|
105
|
+
|
|
106
|
+
A typical zero-downtime sequence is:
|
|
107
|
+
|
|
108
|
+
1. Deploy the lookup table and dual-key resolution.
|
|
109
|
+
2. Deploy actor code and policies with reads still on the legacy store.
|
|
110
|
+
3. Start the required runtime roles.
|
|
111
|
+
4. Backfill actors in bounded batches.
|
|
112
|
+
5. Enable shadow comparison and reconcile drift.
|
|
113
|
+
6. Move a small cohort of reads to actors.
|
|
114
|
+
7. Expand the cohort while watching latency, database growth, retries, and
|
|
115
|
+
divergence.
|
|
116
|
+
8. Move writes to the actor.
|
|
117
|
+
9. Retain the legacy state through an explicit rollback window.
|
|
118
|
+
10. Remove dual writes and legacy data only after the rollback window closes.
|
|
119
|
+
|
|
120
|
+
Use a feature flag whose rollback restores legacy reads and writes without
|
|
121
|
+
requiring actor deletion. Do not assume a timed-out synchronous actor call did
|
|
122
|
+
not commit; query the durable result or use an idempotency key before retrying.
|
|
123
|
+
|
|
124
|
+
## 6. Plan for dormant state and future changes
|
|
125
|
+
|
|
126
|
+
Actor state migrations and legacy-store migration solve different problems:
|
|
127
|
+
|
|
128
|
+
- this cookbook moves ownership from another store into an actor;
|
|
129
|
+
- `state_version` evolves actor JSON after that ownership exists.
|
|
130
|
+
|
|
131
|
+
Keep every published actor migration step. A dormant actor can reactivate years
|
|
132
|
+
later with an old state representation. See the
|
|
133
|
+
[state migration guide](state-migrations.md) for rolling-deployment rules.
|
data/docs/operations.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Operations guide
|
|
2
2
|
|
|
3
|
+
## Installation verification
|
|
4
|
+
|
|
5
|
+
Run the installation doctor after generating the initializer and migrating:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bin/rails solid_objects:doctor
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It validates runtime configuration, required tables and columns, neutral policy
|
|
12
|
+
posture, live runtime roles, and a real workerless synchronous actor round-trip.
|
|
13
|
+
Engine migration timestamps are rewritten when copied into a host application,
|
|
14
|
+
so the schema check compares the required shape instead of a fixed timestamp.
|
|
15
|
+
Warnings such as an all-deny neutral policy do not fail the command because a
|
|
16
|
+
context-aware production policy may correctly deny the probe.
|
|
17
|
+
|
|
3
18
|
## Runtime
|
|
4
19
|
|
|
5
20
|
Start all configured roles:
|
|
@@ -114,9 +129,46 @@ Alert on:
|
|
|
114
129
|
## Retention and backups
|
|
115
130
|
|
|
116
131
|
The schema has cleanup indexes, but automatic pruning commands are still
|
|
117
|
-
roadmap work.
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
roadmap work. Every actor call creates a durable message-history row, including
|
|
133
|
+
queries and attribute reads. Choose a retention period from measured call
|
|
134
|
+
volume, storage budget, audit needs, and the longest promised synchronous-result
|
|
135
|
+
lookup window.
|
|
136
|
+
|
|
137
|
+
An application-owned pruning job can start from this conservative relation:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
cutoff = 30.days.ago
|
|
141
|
+
|
|
142
|
+
prunable_messages = SolidObjects::Message
|
|
143
|
+
.where(completed_at: ...cutoff)
|
|
144
|
+
.where.not(id: SolidObjects::ReadyMessage.select(:message_id))
|
|
145
|
+
.where.not(id: SolidObjects::ClaimedMessage.select(:message_id))
|
|
146
|
+
.where.not(id: SolidObjects::DeadLetter.select(:message_id))
|
|
147
|
+
.where.not(
|
|
148
|
+
id: SolidObjects::DeadLetter
|
|
149
|
+
.where.not(retried_message_id: nil)
|
|
150
|
+
.select(:retried_message_id)
|
|
151
|
+
)
|
|
152
|
+
.where.not(
|
|
153
|
+
id: SolidObjects::Effect
|
|
154
|
+
.where.not(status: "completed")
|
|
155
|
+
.select(:message_id)
|
|
156
|
+
)
|
|
157
|
+
.where.not(
|
|
158
|
+
id: SolidObjects::Broadcast
|
|
159
|
+
.where.not(status: "delivered")
|
|
160
|
+
.select(:message_id)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
prunable_messages.in_batches(of: 1_000).delete_all
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Deleting a message cascades to its completed effects, delivered broadcasts, and
|
|
167
|
+
other message-owned records. Test the exact relation against a restored
|
|
168
|
+
production snapshot before scheduling it. Keep source and retried messages for
|
|
169
|
+
dead letters under investigation, and never prune pending, processing, ready, or
|
|
170
|
+
claimed work. Choose a cutoff longer than every `sync` timeout because a caller
|
|
171
|
+
whose result row disappears can no longer observe that result.
|
|
120
172
|
|
|
121
173
|
Back up actor tables with the same consistency guarantees as application data.
|
|
122
174
|
Restoring only instances without their mailboxes/outboxes, or vice versa, can
|
data/docs/roadmap.md
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
- One-shot and recurring reminders with `:latest` or `:all` catch-up
|
|
18
18
|
- Durable observable broadcast outbox and authorized Action Cable refresh
|
|
19
19
|
- Reconciliation read APIs
|
|
20
|
+
- Installation doctor, authorization reference, fit guide, and legacy-state
|
|
21
|
+
migration cookbook
|
|
20
22
|
- SQLite, PostgreSQL, and MySQL integration suites
|
|
21
23
|
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
|
|
22
24
|
RuboCop policy, and a warning-free Brakeman scan
|
|
@@ -50,7 +52,8 @@
|
|
|
50
52
|
eviction.
|
|
51
53
|
8. Expand security scanning and run compatibility CI across supported Rails and
|
|
52
54
|
Ruby versions.
|
|
53
|
-
9. Benchmark all workloads under documented hardware/database settings
|
|
55
|
+
9. Benchmark all workloads under documented hardware/database settings and
|
|
56
|
+
publish adapter-specific adoption measurements.
|
|
54
57
|
|
|
55
58
|
No production-ready claim should be made until these hardening milestones have
|
|
56
59
|
operational soak evidence.
|
data/docs/security.md
CHANGED
|
@@ -7,6 +7,9 @@ destroying actors, subscribing to actor streams, and administration. The host
|
|
|
7
7
|
application supplies the authenticated request or connection as
|
|
8
8
|
`authorization_context`. All five hooks deny by default.
|
|
9
9
|
|
|
10
|
+
The [authorization reference](authorization.md) lists the caller context and
|
|
11
|
+
risk for every hook and includes a tenant-aware policy example.
|
|
12
|
+
|
|
10
13
|
Method-style reference calls do not bypass these hooks. Public instance methods
|
|
11
14
|
declared on an actor are part of its remotely addressable message surface and
|
|
12
15
|
delegate to the authorized synchronous invocation path. Keep implementation
|
data/docs/state-migrations.md
CHANGED
|
@@ -44,3 +44,7 @@ A safe destructive rollout normally uses:
|
|
|
44
44
|
|
|
45
45
|
Never update actor JSON in a bulk SQL migration. Use actor messages so fencing,
|
|
46
46
|
ordering, observables, and outboxes remain intact.
|
|
47
|
+
|
|
48
|
+
This guide covers evolution after state belongs to Solid Objects. For moving
|
|
49
|
+
existing Redis, key-value, or relational state into actors without downtime,
|
|
50
|
+
use the [legacy-state migration cookbook](migrating-existing-state.md).
|
|
@@ -5,6 +5,23 @@ SolidObjects.configure do |configuration|
|
|
|
5
5
|
configuration.effect_worker_count = 1
|
|
6
6
|
configuration.broadcast_worker_count = 1
|
|
7
7
|
configuration.reminder_scheduler_count = 1
|
|
8
|
+
|
|
9
|
+
# Every policy denies by default, so a fresh installation is intentionally
|
|
10
|
+
# inert. Replace these policies before invoking actors.
|
|
11
|
+
#
|
|
12
|
+
# Message and query policies gate direct calls, sync, async, and state reads.
|
|
13
|
+
# Destroy removes an actor and all of its durable work. Subscription gates
|
|
14
|
+
# Action Cable streams. Administration gates engine pages and operational
|
|
15
|
+
# commands. Keep the last three denied until their callers are authenticated.
|
|
16
|
+
#
|
|
17
|
+
# Prefer policies that bind actor_type and actor_id to a trusted
|
|
18
|
+
# authorization_context. See:
|
|
19
|
+
# https://github.com/cardmagic/solid_objects/blob/main/docs/authorization.md
|
|
20
|
+
# and run:
|
|
21
|
+
#
|
|
22
|
+
# bin/rails solid_objects:doctor
|
|
23
|
+
#
|
|
24
|
+
# after configuring the application.
|
|
8
25
|
configuration.authorize_message = ->(**) { false }
|
|
9
26
|
configuration.authorize_query = ->(**) { false }
|
|
10
27
|
configuration.authorize_destroy = ->(**) { false }
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "solid_objects/mailbox"
|
|
4
|
+
require "solid_objects/synchronous_invocation"
|
|
5
|
+
|
|
6
|
+
module SolidObjects
|
|
7
|
+
class Doctor
|
|
8
|
+
class Check
|
|
9
|
+
# @rbs @name: Symbol
|
|
10
|
+
# @rbs @status: Symbol
|
|
11
|
+
# @rbs @message: String
|
|
12
|
+
|
|
13
|
+
attr_reader :name, :status, :message
|
|
14
|
+
|
|
15
|
+
# @rbs (name: Symbol, status: Symbol, message: String) -> void
|
|
16
|
+
def initialize(name:, status:, message:)
|
|
17
|
+
@name = name
|
|
18
|
+
@status = status
|
|
19
|
+
@message = message
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs () -> bool
|
|
24
|
+
def failed?
|
|
25
|
+
status == :fail
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class Report
|
|
30
|
+
# @rbs @checks: Array[Check]
|
|
31
|
+
|
|
32
|
+
attr_reader :checks
|
|
33
|
+
|
|
34
|
+
# @rbs (checks: Array[Check]) -> void
|
|
35
|
+
def initialize(checks:)
|
|
36
|
+
@checks = checks.freeze
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @rbs () -> bool
|
|
41
|
+
def healthy?
|
|
42
|
+
checks.none?(&:failed?)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @rbs (Symbol) -> Check
|
|
46
|
+
def check(name)
|
|
47
|
+
checks.find { |candidate| candidate.name == name } ||
|
|
48
|
+
raise(KeyError, "unknown doctor check #{name.inspect}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @rbs () -> String
|
|
52
|
+
def to_s
|
|
53
|
+
lines = [ "Solid Objects doctor #{SolidObjects::VERSION}" ]
|
|
54
|
+
lines.concat(
|
|
55
|
+
checks.map do |check|
|
|
56
|
+
"#{check.status.to_s.upcase.ljust(4)} #{check.name}: #{check.message}"
|
|
57
|
+
end
|
|
58
|
+
)
|
|
59
|
+
lines.join("\n")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
EXPECTED_COLUMNS = {
|
|
64
|
+
processes: %w[id kind hostname pid last_heartbeat_at shutdown_state],
|
|
65
|
+
instances: %w[
|
|
66
|
+
id actor_type actor_id state state_version next_message_sequence
|
|
67
|
+
activation_owner_id activation_token activation_expires_at
|
|
68
|
+
activation_generation
|
|
69
|
+
],
|
|
70
|
+
messages: %w[
|
|
71
|
+
id instance_id message_kind arguments sequence attempt_count request_id
|
|
72
|
+
result error rejection completed_at rejected_at
|
|
73
|
+
],
|
|
74
|
+
ready_messages: %w[id message_id instance_id sequence available_at],
|
|
75
|
+
claimed_messages: %w[
|
|
76
|
+
id message_id instance_id process_id activation_token
|
|
77
|
+
activation_generation claimed_at
|
|
78
|
+
],
|
|
79
|
+
reminders: %w[id instance_id message_name next_run_at status],
|
|
80
|
+
effects: %w[id message_id instance_id effect_id status available_at],
|
|
81
|
+
broadcasts: %w[id message_id instance_id broadcast_id status available_at],
|
|
82
|
+
dead_letters: %w[id message_id instance_id actor_type actor_id attempts]
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
85
|
+
class ProbeActor < Actor
|
|
86
|
+
actor_type "solid_objects_doctor"
|
|
87
|
+
|
|
88
|
+
# @rbs (value: String) -> String
|
|
89
|
+
def ping(value:)
|
|
90
|
+
value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @rbs @connection: untyped
|
|
95
|
+
# @rbs @configuration: Configuration
|
|
96
|
+
|
|
97
|
+
# @rbs (?connection: untyped, ?configuration: Configuration) -> void
|
|
98
|
+
def initialize(
|
|
99
|
+
connection: SolidObjects::Record.connection,
|
|
100
|
+
configuration: SolidObjects.configuration
|
|
101
|
+
)
|
|
102
|
+
@connection = connection
|
|
103
|
+
@configuration = configuration
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @rbs () -> Report
|
|
107
|
+
def call
|
|
108
|
+
configuration_check = check_configuration
|
|
109
|
+
schema_check = check_schema
|
|
110
|
+
checks = [
|
|
111
|
+
configuration_check,
|
|
112
|
+
schema_check,
|
|
113
|
+
check_authorization,
|
|
114
|
+
schema_check.failed? ? skipped_runtime : check_runtime,
|
|
115
|
+
ready_for_round_trip?(configuration_check, schema_check) ?
|
|
116
|
+
check_sync_round_trip :
|
|
117
|
+
skipped_round_trip
|
|
118
|
+
]
|
|
119
|
+
Report.new(checks:)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
attr_reader :connection, :configuration
|
|
125
|
+
|
|
126
|
+
# @rbs () -> Check
|
|
127
|
+
def check_configuration
|
|
128
|
+
configuration.validate!
|
|
129
|
+
pass(:configuration, "configuration is valid")
|
|
130
|
+
rescue => error
|
|
131
|
+
fail_check(:configuration, "#{error.class}: #{error.message}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @rbs () -> Check
|
|
135
|
+
def check_schema
|
|
136
|
+
missing_tables = expected_table_names - connection.data_sources
|
|
137
|
+
unless missing_tables.empty?
|
|
138
|
+
return fail_check(:schema, "missing tables: #{missing_tables.join(", ")}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
missing_columns = EXPECTED_COLUMNS.each_with_object([]) do |(name, expected), missing|
|
|
142
|
+
table_name = SolidObjects.table_name(name)
|
|
143
|
+
actual = connection.columns(table_name).map(&:name)
|
|
144
|
+
(expected - actual).each { |column| missing << "#{table_name}.#{column}" }
|
|
145
|
+
end
|
|
146
|
+
unless missing_columns.empty?
|
|
147
|
+
return fail_check(:schema, "missing columns: #{missing_columns.join(", ")}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
pass(:schema, "schema matches the #{SolidObjects::VERSION} runtime")
|
|
151
|
+
rescue => error
|
|
152
|
+
fail_check(:schema, "#{error.class}: #{error.message}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @rbs () -> Check
|
|
156
|
+
def check_authorization
|
|
157
|
+
outcomes = policy_probes.to_h do |name, arguments|
|
|
158
|
+
outcome = configuration.public_send(name).call(**arguments) ? :allow : :deny
|
|
159
|
+
[ name, outcome ]
|
|
160
|
+
rescue
|
|
161
|
+
[ name, :unknown ]
|
|
162
|
+
end
|
|
163
|
+
allowed = outcomes.select { |_, outcome| outcome == :allow }.keys
|
|
164
|
+
unknown = outcomes.select { |_, outcome| outcome == :unknown }.keys
|
|
165
|
+
|
|
166
|
+
if allowed.empty? && unknown.empty?
|
|
167
|
+
return warn_check(
|
|
168
|
+
:authorization,
|
|
169
|
+
"all five policies denied a neutral context; review the generated initializer before use"
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
risky = allowed & %i[
|
|
173
|
+
authorize_destroy
|
|
174
|
+
authorize_subscription
|
|
175
|
+
authorize_administration
|
|
176
|
+
]
|
|
177
|
+
unless risky.empty?
|
|
178
|
+
return warn_check(
|
|
179
|
+
:authorization,
|
|
180
|
+
"sensitive policies allowed a neutral context: #{risky.join(", ")}"
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
unless unknown.empty?
|
|
184
|
+
return warn_check(
|
|
185
|
+
:authorization,
|
|
186
|
+
"#{allowed.length} of 5 policies allowed a neutral context; " \
|
|
187
|
+
"#{unknown.join(", ")} could not evaluate without application context"
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
pass(:authorization, "#{allowed.length} of 5 policies allowed a neutral context")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @rbs () -> Check
|
|
195
|
+
def check_runtime
|
|
196
|
+
cutoff = SolidObjects.database_adapter.database_now -
|
|
197
|
+
configuration.process_alive_threshold
|
|
198
|
+
counts = Process
|
|
199
|
+
.where(shutdown_state: "running", last_heartbeat_at: cutoff..)
|
|
200
|
+
.group(:kind)
|
|
201
|
+
.count
|
|
202
|
+
if counts.empty?
|
|
203
|
+
return info(
|
|
204
|
+
:runtime,
|
|
205
|
+
"no live runtime roles; workerless synchronous calls are available, asynchronous features are not"
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
summary = counts.sort.map { |kind, count| "#{kind}=#{count}" }.join(", ")
|
|
210
|
+
pass(:runtime, "live runtime roles: #{summary}")
|
|
211
|
+
rescue => error
|
|
212
|
+
fail_check(:runtime, "#{error.class}: #{error.message}")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @rbs () -> Check
|
|
216
|
+
def check_sync_round_trip
|
|
217
|
+
actor_id = SecureRandom.uuid
|
|
218
|
+
value = SecureRandom.hex(8)
|
|
219
|
+
process_registry = SolidObjects.caller_process.process_registry
|
|
220
|
+
reference = ProbeActor.ref(actor_id)
|
|
221
|
+
message_reference = Mailbox.new.enqueue(
|
|
222
|
+
reference,
|
|
223
|
+
:ping,
|
|
224
|
+
{ value: },
|
|
225
|
+
kind: "sync"
|
|
226
|
+
)
|
|
227
|
+
result = SynchronousInvocation.new.call(message_reference, timeout: 5.seconds)
|
|
228
|
+
raise Error, "unexpected round-trip result" unless result == value
|
|
229
|
+
|
|
230
|
+
pass(:sync_round_trip, "durable synchronous actor call completed without a worker")
|
|
231
|
+
rescue => error
|
|
232
|
+
fail_check(:sync_round_trip, "#{error.class}: #{error.message}")
|
|
233
|
+
ensure
|
|
234
|
+
Instance.where(actor_type: ProbeActor.actor_type, actor_id:).delete_all if actor_id
|
|
235
|
+
process_registry&.stop
|
|
236
|
+
process_registry&.process_record&.delete
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# @rbs (Check, Check) -> bool
|
|
240
|
+
def ready_for_round_trip?(configuration_check, schema_check)
|
|
241
|
+
!configuration_check.failed? && !schema_check.failed?
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @rbs () -> Check
|
|
245
|
+
def skipped_runtime
|
|
246
|
+
skip(:runtime, "schema check failed")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @rbs () -> Check
|
|
250
|
+
def skipped_round_trip
|
|
251
|
+
skip(:sync_round_trip, "configuration or schema check failed")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# @rbs () -> Hash[Symbol, Hash[Symbol, untyped]]
|
|
255
|
+
def policy_probes
|
|
256
|
+
actor_arguments = {
|
|
257
|
+
actor_type: ProbeActor.actor_type,
|
|
258
|
+
actor_id: "doctor",
|
|
259
|
+
authorization_context: nil
|
|
260
|
+
}
|
|
261
|
+
{
|
|
262
|
+
authorize_message: actor_arguments.merge(
|
|
263
|
+
message_name: "ping",
|
|
264
|
+
arguments: { "value" => "doctor" }
|
|
265
|
+
),
|
|
266
|
+
authorize_query: actor_arguments.merge(
|
|
267
|
+
message_name: "value",
|
|
268
|
+
arguments: {}
|
|
269
|
+
),
|
|
270
|
+
authorize_destroy: actor_arguments,
|
|
271
|
+
authorize_subscription: actor_arguments,
|
|
272
|
+
authorize_administration: {
|
|
273
|
+
action: "doctor",
|
|
274
|
+
resource: "runtime",
|
|
275
|
+
resource_id: nil,
|
|
276
|
+
authorization_context: nil
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# @rbs () -> Array[String]
|
|
282
|
+
def expected_table_names
|
|
283
|
+
EXPECTED_COLUMNS.keys.map { |name| SolidObjects.table_name(name) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# @rbs (Symbol, String) -> Check
|
|
287
|
+
def pass(name, message)
|
|
288
|
+
Check.new(name:, status: :pass, message:)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# @rbs (Symbol, String) -> Check
|
|
292
|
+
def info(name, message)
|
|
293
|
+
Check.new(name:, status: :info, message:)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# @rbs (Symbol, String) -> Check
|
|
297
|
+
def warn_check(name, message)
|
|
298
|
+
Check.new(name:, status: :warn, message:)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# @rbs (Symbol, String) -> Check
|
|
302
|
+
def fail_check(name, message)
|
|
303
|
+
Check.new(name:, status: :fail, message:)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# @rbs (Symbol, String) -> Check
|
|
307
|
+
def skip(name, message)
|
|
308
|
+
Check.new(name:, status: :skip, message:)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require "solid_objects/doctor"
|
|
2
|
+
|
|
3
|
+
namespace :solid_objects do
|
|
4
|
+
desc "Verify the Solid Objects installation"
|
|
5
|
+
task doctor: :environment do
|
|
6
|
+
report = SolidObjects::Doctor.new.call
|
|
7
|
+
puts report
|
|
8
|
+
abort "Solid Objects doctor failed" unless report.healthy?
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Generated from lib/solid_objects/doctor.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Doctor
|
|
5
|
+
class Check
|
|
6
|
+
@name: Symbol
|
|
7
|
+
|
|
8
|
+
@status: Symbol
|
|
9
|
+
|
|
10
|
+
@message: String
|
|
11
|
+
|
|
12
|
+
attr_reader name: untyped
|
|
13
|
+
|
|
14
|
+
attr_reader status: untyped
|
|
15
|
+
|
|
16
|
+
attr_reader message: untyped
|
|
17
|
+
|
|
18
|
+
# @rbs (name: Symbol, status: Symbol, message: String) -> void
|
|
19
|
+
def initialize: (name: Symbol, status: Symbol, message: String) -> void
|
|
20
|
+
|
|
21
|
+
# @rbs () -> bool
|
|
22
|
+
def failed?: () -> bool
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Report
|
|
26
|
+
@checks: Array[Check]
|
|
27
|
+
|
|
28
|
+
attr_reader checks: untyped
|
|
29
|
+
|
|
30
|
+
# @rbs (checks: Array[Check]) -> void
|
|
31
|
+
def initialize: (checks: Array[Check]) -> void
|
|
32
|
+
|
|
33
|
+
# @rbs () -> bool
|
|
34
|
+
def healthy?: () -> bool
|
|
35
|
+
|
|
36
|
+
# @rbs (Symbol) -> Check
|
|
37
|
+
def check: (Symbol) -> Check
|
|
38
|
+
|
|
39
|
+
# @rbs () -> String
|
|
40
|
+
def to_s: () -> String
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
EXPECTED_COLUMNS: untyped
|
|
44
|
+
|
|
45
|
+
class ProbeActor < Actor
|
|
46
|
+
# @rbs (value: String) -> String
|
|
47
|
+
def ping: (value: String) -> String
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@configuration: Configuration
|
|
51
|
+
|
|
52
|
+
@connection: untyped
|
|
53
|
+
|
|
54
|
+
# @rbs (?connection: untyped, ?configuration: Configuration) -> void
|
|
55
|
+
def initialize: (?connection: untyped, ?configuration: Configuration) -> void
|
|
56
|
+
|
|
57
|
+
# @rbs () -> Report
|
|
58
|
+
def call: () -> Report
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader connection: untyped
|
|
63
|
+
|
|
64
|
+
attr_reader configuration: untyped
|
|
65
|
+
|
|
66
|
+
# @rbs () -> Check
|
|
67
|
+
def check_configuration: () -> Check
|
|
68
|
+
|
|
69
|
+
# @rbs () -> Check
|
|
70
|
+
def check_schema: () -> Check
|
|
71
|
+
|
|
72
|
+
# @rbs () -> Check
|
|
73
|
+
def check_authorization: () -> Check
|
|
74
|
+
|
|
75
|
+
# @rbs () -> Check
|
|
76
|
+
def check_runtime: () -> Check
|
|
77
|
+
|
|
78
|
+
# @rbs () -> Check
|
|
79
|
+
def check_sync_round_trip: () -> Check
|
|
80
|
+
|
|
81
|
+
# @rbs (Check, Check) -> bool
|
|
82
|
+
def ready_for_round_trip?: (Check, Check) -> bool
|
|
83
|
+
|
|
84
|
+
# @rbs () -> Check
|
|
85
|
+
def skipped_runtime: () -> Check
|
|
86
|
+
|
|
87
|
+
# @rbs () -> Check
|
|
88
|
+
def skipped_round_trip: () -> Check
|
|
89
|
+
|
|
90
|
+
# @rbs () -> Hash[Symbol, Hash[Symbol, untyped]]
|
|
91
|
+
def policy_probes: () -> Hash[Symbol, Hash[Symbol, untyped]]
|
|
92
|
+
|
|
93
|
+
# @rbs () -> Array[String]
|
|
94
|
+
def expected_table_names: () -> Array[String]
|
|
95
|
+
|
|
96
|
+
# @rbs (Symbol, String) -> Check
|
|
97
|
+
def pass: (Symbol, String) -> Check
|
|
98
|
+
|
|
99
|
+
# @rbs (Symbol, String) -> Check
|
|
100
|
+
def info: (Symbol, String) -> Check
|
|
101
|
+
|
|
102
|
+
# @rbs (Symbol, String) -> Check
|
|
103
|
+
def warn_check: (Symbol, String) -> Check
|
|
104
|
+
|
|
105
|
+
# @rbs (Symbol, String) -> Check
|
|
106
|
+
def fail_check: (Symbol, String) -> Check
|
|
107
|
+
|
|
108
|
+
# @rbs (Symbol, String) -> Check
|
|
109
|
+
def skip: (Symbol, String) -> Check
|
|
110
|
+
end
|
|
111
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_objects
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Carlson
|
|
@@ -278,6 +278,7 @@ files:
|
|
|
278
278
|
- app/views/solid_objects/instances/index.html.erb
|
|
279
279
|
- app/views/solid_objects/instances/show.html.erb
|
|
280
280
|
- benchmark/activation_cache.rb
|
|
281
|
+
- benchmark/adoption_latency.rb
|
|
281
282
|
- benchmark/claim.rb
|
|
282
283
|
- benchmark/cold_actors.rb
|
|
283
284
|
- benchmark/concurrent_actors.rb
|
|
@@ -303,11 +304,14 @@ files:
|
|
|
303
304
|
- docs/adr/0012-not-active-jobs.md
|
|
304
305
|
- docs/adr/0013-database-adapters.md
|
|
305
306
|
- docs/architecture.md
|
|
307
|
+
- docs/authorization.md
|
|
306
308
|
- docs/benchmarks.md
|
|
307
309
|
- docs/correctness.md
|
|
308
310
|
- docs/database-schema.md
|
|
309
311
|
- docs/development.md
|
|
312
|
+
- docs/fit.md
|
|
310
313
|
- docs/implementation-plan.md
|
|
314
|
+
- docs/migrating-existing-state.md
|
|
311
315
|
- docs/operations.md
|
|
312
316
|
- docs/realtime.md
|
|
313
317
|
- docs/research/solid_queue.md
|
|
@@ -349,6 +353,7 @@ files:
|
|
|
349
353
|
- lib/solid_objects/database_adapters/postgresql.rb
|
|
350
354
|
- lib/solid_objects/database_adapters/sqlite.rb
|
|
351
355
|
- lib/solid_objects/dead_letter_manager.rb
|
|
356
|
+
- lib/solid_objects/doctor.rb
|
|
352
357
|
- lib/solid_objects/dom_identity.rb
|
|
353
358
|
- lib/solid_objects/effect_executor.rb
|
|
354
359
|
- lib/solid_objects/effect_registry.rb
|
|
@@ -374,6 +379,7 @@ files:
|
|
|
374
379
|
- lib/solid_objects/version.rb
|
|
375
380
|
- lib/solid_objects/wake_up.rb
|
|
376
381
|
- lib/solid_objects/worker.rb
|
|
382
|
+
- lib/tasks/solid_objects_tasks.rake
|
|
377
383
|
- sig/generated/controllers/solid_objects/application_controller.rbs
|
|
378
384
|
- sig/generated/controllers/solid_objects/dead_letters_controller.rbs
|
|
379
385
|
- sig/generated/controllers/solid_objects/instances_controller.rbs
|
|
@@ -400,6 +406,7 @@ files:
|
|
|
400
406
|
- sig/generated/lib/solid_objects/database_adapters/postgresql.rbs
|
|
401
407
|
- sig/generated/lib/solid_objects/database_adapters/sqlite.rbs
|
|
402
408
|
- sig/generated/lib/solid_objects/dead_letter_manager.rbs
|
|
409
|
+
- sig/generated/lib/solid_objects/doctor.rbs
|
|
403
410
|
- sig/generated/lib/solid_objects/dom_identity.rbs
|
|
404
411
|
- sig/generated/lib/solid_objects/effect_executor.rbs
|
|
405
412
|
- sig/generated/lib/solid_objects/effect_registry.rbs
|