solid_objects 0.1.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 +22 -0
- data/README.md +160 -43
- data/app/models/solid_objects/claimed_message.rb +2 -0
- data/app/models/solid_objects/message.rb +6 -1
- data/benchmark/adoption_latency.rb +5 -0
- data/benchmark/support.rb +45 -15
- data/benchmark/{ask_latency.rb → sync_latency.rb} +1 -1
- data/db/migrate/20260805000000_create_solid_objects_tables.rb +15 -3
- data/docs/adr/0006-at-least-once-delivery.md +1 -1
- data/docs/adr/0008-actor-communication.md +4 -1
- data/docs/adr/0011-wake-up-strategy.md +13 -4
- data/docs/architecture.md +51 -20
- data/docs/authorization.md +85 -0
- data/docs/benchmarks.md +80 -10
- data/docs/correctness.md +28 -9
- data/docs/database-schema.md +15 -7
- data/docs/development.md +6 -5
- data/docs/fit.md +90 -0
- data/docs/implementation-plan.md +22 -16
- data/docs/migrating-existing-state.md +133 -0
- data/docs/operations.md +56 -4
- data/docs/research/solid_queue.md +2 -1
- data/docs/roadmap.md +9 -3
- data/docs/security.md +7 -2
- data/docs/state-migrations.md +4 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +17 -0
- data/lib/solid_objects/activation.rb +35 -19
- data/lib/solid_objects/activation_manager.rb +22 -6
- data/lib/solid_objects/actor.rb +16 -3
- data/lib/solid_objects/caller_process.rb +57 -0
- data/lib/solid_objects/client.rb +6 -37
- data/lib/solid_objects/configuration.rb +4 -4
- data/lib/solid_objects/doctor.rb +311 -0
- data/lib/solid_objects/engine.rb +1 -0
- data/lib/solid_objects/errors.rb +17 -1
- data/lib/solid_objects/executor.rb +42 -2
- data/lib/solid_objects/lease.rb +22 -10
- data/lib/solid_objects/message_reference.rb +1 -0
- data/lib/solid_objects/process_registry.rb +5 -1
- data/lib/solid_objects/reference.rb +8 -8
- data/lib/solid_objects/synchronous_invocation.rb +93 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +7 -0
- data/lib/tasks/solid_objects_tasks.rake +10 -0
- data/sig/generated/lib/solid_objects/activation.rbs +6 -0
- data/sig/generated/lib/solid_objects/activation_manager.rbs +6 -0
- data/sig/generated/lib/solid_objects/actor.rbs +9 -6
- data/sig/generated/lib/solid_objects/caller_process.rbs +32 -0
- data/sig/generated/lib/solid_objects/client.rbs +2 -8
- data/sig/generated/lib/solid_objects/configuration.rbs +2 -2
- data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
- data/sig/generated/lib/solid_objects/errors.rbs +18 -1
- data/sig/generated/lib/solid_objects/executor.rbs +3 -0
- data/sig/generated/lib/solid_objects/lease.rbs +14 -10
- data/sig/generated/lib/solid_objects/reference.rbs +3 -3
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +28 -0
- data/sig/generated/lib/solid_objects.rbs +3 -0
- data/sig/generated/models/solid_objects/message.rbs +3 -0
- metadata +15 -8
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,27 @@
|
|
|
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
|
+
|
|
12
|
+
## 0.2.0 - 2026-08-06
|
|
13
|
+
|
|
14
|
+
- Make direct actor methods synchronous Durable Object-style RPC.
|
|
15
|
+
- Add explicit `sync` and `async` invocation modes.
|
|
16
|
+
- Let synchronous callers assist execution through the ordered mailbox using
|
|
17
|
+
the same activation leases and fencing checks as workers.
|
|
18
|
+
- Add terminal domain rejections that roll back actor state without retrying or
|
|
19
|
+
creating dead letters.
|
|
20
|
+
- Give each activation a unique token so concurrent callers in one process
|
|
21
|
+
cannot share lease ownership.
|
|
22
|
+
- Fix Action View helper loading when Action View initializes before engine
|
|
23
|
+
autoload paths.
|
|
24
|
+
|
|
3
25
|
## 0.1.0 - 2026-08-06
|
|
4
26
|
|
|
5
27
|
- Introduce the Rails engine, actor API, and `solid_objects` executable.
|
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,11 +35,28 @@ 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).
|
|
36
58
|
|
|
37
|
-
Version 0.
|
|
59
|
+
Version 0.2 is an early release. Its correctness core is implemented and tested,
|
|
38
60
|
but the project does not yet claim production readiness. See
|
|
39
61
|
[Status](#status) and the [roadmap](docs/roadmap.md).
|
|
40
62
|
|
|
@@ -43,9 +65,10 @@ 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)
|
|
49
72
|
- [Effects](#effects)
|
|
50
73
|
- [Reminders](#reminders)
|
|
51
74
|
- [Destroying an object](#destroying-an-object)
|
|
@@ -171,7 +194,7 @@ refresh from current actor state.
|
|
|
171
194
|
`cart.component(:summary)` supports initial rendering of
|
|
172
195
|
`actors/shopping_cart/_summary`. Durable live component replacement and
|
|
173
196
|
Turbo append actions are roadmap work; observable replacement is the live path
|
|
174
|
-
implemented in 0.
|
|
197
|
+
implemented in 0.2.
|
|
175
198
|
|
|
176
199
|
Reactive views require `turbo-rails` and a working Action Cable adapter in the
|
|
177
200
|
host application. They are optional; the actor runtime itself does not depend
|
|
@@ -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,15 +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.
|
|
243
|
+
|
|
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
|
|
208
268
|
|
|
209
|
-
|
|
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:
|
|
210
286
|
|
|
211
287
|
```bash
|
|
212
288
|
bundle exec solid_objects start
|
|
213
289
|
```
|
|
214
290
|
|
|
215
|
-
|
|
216
|
-
|
|
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.
|
|
217
294
|
|
|
218
295
|
## Defining an actor
|
|
219
296
|
|
|
@@ -251,18 +328,20 @@ declared on the actor are durable message handlers. They can use `items`,
|
|
|
251
328
|
`self.checkout_status = "pending"`, or the lower-level `state` object. Declare
|
|
252
329
|
helper methods as private or protected so they are not exposed as messages.
|
|
253
330
|
|
|
254
|
-
Attributes also become ordered read queries on a reference.
|
|
255
|
-
|
|
331
|
+
Attributes also become ordered read queries on a reference. Public actor
|
|
332
|
+
methods and attribute readers are synchronous caller-assisted invocations:
|
|
256
333
|
|
|
257
334
|
```ruby
|
|
258
335
|
cart = ShoppingCart.ref("alice")
|
|
259
|
-
|
|
336
|
+
cart.add_item(product_id: "shirt-123", quantity: 2)
|
|
260
337
|
items = cart.items
|
|
261
338
|
```
|
|
262
339
|
|
|
263
|
-
`
|
|
264
|
-
|
|
265
|
-
|
|
340
|
+
Use `cart.async(:add_item, product_id: "shirt-123", quantity: 2)` to enqueue
|
|
341
|
+
without waiting; that call returns a `SolidObjects::MessageReference`. `items`
|
|
342
|
+
is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
|
|
343
|
+
mailbox. State changes must go through public actor methods or explicit
|
|
344
|
+
`async`.
|
|
266
345
|
|
|
267
346
|
State, arguments, results, effects, and reminder arguments accept
|
|
268
347
|
JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
|
|
@@ -310,7 +389,7 @@ end
|
|
|
310
389
|
Actor types resolve only through the explicit registry. Solid Objects never
|
|
311
390
|
constantizes a type supplied by a client.
|
|
312
391
|
|
|
313
|
-
##
|
|
392
|
+
## Invoking an object
|
|
314
393
|
|
|
315
394
|
As with a Durable Object stub, declared actor operations are available directly
|
|
316
395
|
on a reference:
|
|
@@ -325,24 +404,27 @@ class Counter < SolidObjects::Actor
|
|
|
325
404
|
end
|
|
326
405
|
|
|
327
406
|
counter = Counter.ref("global")
|
|
328
|
-
|
|
407
|
+
value = counter.increment(amount: 5)
|
|
329
408
|
value = counter.value
|
|
330
409
|
```
|
|
331
410
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
411
|
+
Like RPC on a Durable Object stub, a direct call is synchronous from the
|
|
412
|
+
caller's perspective. Solid Objects first durably enqueues the invocation, then
|
|
413
|
+
executes that actor locally when its fenced activation is available. It returns
|
|
414
|
+
the committed, deeply frozen result. Earlier mailbox entries still run first,
|
|
415
|
+
and a remote worker may win the activation without changing the result
|
|
416
|
+
semantics.
|
|
417
|
+
|
|
418
|
+
The `message(:name) { ... }` and `query(:name) { ... }` DSLs remain available
|
|
419
|
+
for dynamic definitions.
|
|
339
420
|
|
|
340
|
-
### `
|
|
421
|
+
### `async`
|
|
341
422
|
|
|
342
|
-
`
|
|
423
|
+
Use `async` for durable fire-and-forget work. It returns a
|
|
424
|
+
`MessageReference` immediately and leaves execution to the worker fleet:
|
|
343
425
|
|
|
344
426
|
```ruby
|
|
345
|
-
message = order.
|
|
427
|
+
message = order.async(
|
|
346
428
|
:submit,
|
|
347
429
|
idempotency_key: "submit-order-123"
|
|
348
430
|
)
|
|
@@ -351,23 +433,48 @@ message = order.tell(
|
|
|
351
433
|
Use `available_at:` to spread bulk work or delay one message:
|
|
352
434
|
|
|
353
435
|
```ruby
|
|
354
|
-
order.
|
|
436
|
+
order.async(:evaluate, available_at: 10.minutes.from_now)
|
|
355
437
|
```
|
|
356
438
|
|
|
357
|
-
### `
|
|
439
|
+
### `sync`
|
|
358
440
|
|
|
359
|
-
`
|
|
441
|
+
Use explicit `sync` when the operation name is dynamic or collides with a
|
|
442
|
+
reference method:
|
|
360
443
|
|
|
361
444
|
```ruby
|
|
362
|
-
status = order.
|
|
445
|
+
status = order.sync(:status, timeout: 5.seconds)
|
|
363
446
|
```
|
|
364
447
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
request
|
|
448
|
+
Direct calls and `sync` use the same caller-assisted execution path. A healthy
|
|
449
|
+
actor normally needs no worker round trip, making this path suitable for HTTP
|
|
450
|
+
and MCP request/response boundaries when the handler itself fits the
|
|
451
|
+
application's latency budget. If another process owns the activation, the
|
|
452
|
+
caller waits for the durable result using wake-up hints with bounded database
|
|
453
|
+
polling as the fallback. A timeout never cancels the durable invocation.
|
|
454
|
+
|
|
455
|
+
Actor code cannot use direct calls or `sync` on another actor; synchronous
|
|
456
|
+
actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
|
|
457
|
+
result message.
|
|
458
|
+
|
|
459
|
+
### Domain rejection
|
|
368
460
|
|
|
369
|
-
|
|
370
|
-
|
|
461
|
+
Reject invalid input without retrying or creating a dead letter:
|
|
462
|
+
|
|
463
|
+
```ruby
|
|
464
|
+
def submit(response:)
|
|
465
|
+
reject :validation_failed, "Response is not valid" unless valid?(response)
|
|
466
|
+
|
|
467
|
+
self.response = response
|
|
468
|
+
end
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
The caller receives `SolidObjects::Rejected` with a stable code, message, and
|
|
472
|
+
JSON-compatible details. The rejected message remains durable for audit, actor
|
|
473
|
+
state is rolled back, and no later mailbox turn is blocked.
|
|
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`.
|
|
371
478
|
|
|
372
479
|
### Redelivery
|
|
373
480
|
|
|
@@ -445,7 +552,7 @@ enqueue each one.
|
|
|
445
552
|
|
|
446
553
|
Self-scheduling actors should also have a low-frequency application reconciler.
|
|
447
554
|
It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
|
|
448
|
-
`.orphaned`, but every repair must go through `
|
|
555
|
+
`.orphaned`, but every repair must go through `async`. Never bulk-update actor
|
|
449
556
|
state around the lease and fencing checks.
|
|
450
557
|
|
|
451
558
|
## Destroying an object
|
|
@@ -514,7 +621,7 @@ Important defaults:
|
|
|
514
621
|
| Setting | Default |
|
|
515
622
|
| --- | ---: |
|
|
516
623
|
| `polling_interval` | 0.1 seconds |
|
|
517
|
-
| `
|
|
624
|
+
| `sync_polling_interval` | 0.05 seconds |
|
|
518
625
|
| `lease_duration` | 30 seconds |
|
|
519
626
|
| `lease_renewal_interval` | 10 seconds |
|
|
520
627
|
| `idle_deactivation_timeout` | 30 seconds |
|
|
@@ -633,7 +740,7 @@ Solid Objects does not promise:
|
|
|
633
740
|
- global order across actors;
|
|
634
741
|
- distributed transactions;
|
|
635
742
|
- bounded end-to-end latency;
|
|
636
|
-
- cancellation when
|
|
743
|
+
- cancellation when a synchronous caller times out; or
|
|
637
744
|
- that a lease prevents stale Ruby code from continuing to run.
|
|
638
745
|
|
|
639
746
|
The fencing generation prevents stale code from committing.
|
|
@@ -659,6 +766,13 @@ Do not use it for stateless work, bulk pipelines, CPU-heavy computation,
|
|
|
659
766
|
cross-actor transactions, slow network calls inside handlers, or domains that
|
|
660
767
|
are clearer as normalized Active Record models and direct service objects.
|
|
661
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
|
+
|
|
662
776
|
## Comparisons
|
|
663
777
|
|
|
664
778
|
| Tool | What Solid Objects adds or changes |
|
|
@@ -700,15 +814,18 @@ See the [development guide](docs/development.md) and
|
|
|
700
814
|
|
|
701
815
|
## Status
|
|
702
816
|
|
|
703
|
-
Implemented and tested in 0.
|
|
817
|
+
Implemented and tested in 0.2:
|
|
704
818
|
|
|
705
819
|
- Rails engine, install generator, migrations, and `solid_objects` executable;
|
|
706
820
|
- actor registry, references, JSON state, and state migrations;
|
|
821
|
+
- direct synchronous actor RPC, explicit `sync`, and durable `async`;
|
|
707
822
|
- durable message history plus ready and claimed membership tables;
|
|
708
823
|
- concurrent sequence allocation and actor creation;
|
|
709
|
-
- activation leases,
|
|
824
|
+
- activation leases, per-activation tokens, fencing generations, and
|
|
825
|
+
stale-write rejection;
|
|
710
826
|
- bounded activation passes, idle activation cache, and hot-actor fairness;
|
|
711
|
-
- retries, strict poison ordering, dead letters,
|
|
827
|
+
- retries, terminal domain rejection, strict poison ordering, dead letters,
|
|
828
|
+
and retry tooling;
|
|
712
829
|
- transactional effects and asynchronous actor-to-actor messages;
|
|
713
830
|
- one-shot and recurring per-actor reminders;
|
|
714
831
|
- authorized actor destruction with fenced stale-write rejection and cascading
|
|
@@ -20,7 +20,7 @@ module SolidObjects
|
|
|
20
20
|
|
|
21
21
|
before_validation :supply_defaults
|
|
22
22
|
|
|
23
|
-
validates :message_kind, inclusion: { in: %w[
|
|
23
|
+
validates :message_kind, inclusion: { in: %w[async sync internal] }
|
|
24
24
|
|
|
25
25
|
# @rbs () -> bool
|
|
26
26
|
def ready?
|
|
@@ -37,6 +37,11 @@ module SolidObjects
|
|
|
37
37
|
completed_at.present?
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# @rbs () -> bool
|
|
41
|
+
def rejected?
|
|
42
|
+
rejected_at.present?
|
|
43
|
+
end
|
|
44
|
+
|
|
40
45
|
# @rbs () -> bool
|
|
41
46
|
def dead?
|
|
42
47
|
dead_letter.present?
|
data/benchmark/support.rb
CHANGED
|
@@ -61,13 +61,13 @@ module SolidObjectsBenchmark
|
|
|
61
61
|
def enqueue
|
|
62
62
|
reference = CounterActor.ref("enqueue")
|
|
63
63
|
measure("enqueue #{count} messages") do
|
|
64
|
-
count.times { reference.
|
|
64
|
+
count.times { reference.async(:increment) }
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# @rbs () -> void
|
|
69
69
|
def claim
|
|
70
|
-
count.times { |index| CounterActor.ref("claim-#{index}").
|
|
70
|
+
count.times { |index| CounterActor.ref("claim-#{index}").async(:increment) }
|
|
71
71
|
process_registry = SolidObjects::ProcessRegistry.new
|
|
72
72
|
owner_id = process_registry.register.id
|
|
73
73
|
activation_manager = SolidObjects::ActivationManager.new(owner_id:)
|
|
@@ -100,7 +100,7 @@ module SolidObjectsBenchmark
|
|
|
100
100
|
|
|
101
101
|
# @rbs () -> void
|
|
102
102
|
def cold_actors
|
|
103
|
-
count.times { |index| CounterActor.ref("cold-#{index}").
|
|
103
|
+
count.times { |index| CounterActor.ref("cold-#{index}").async(:increment) }
|
|
104
104
|
worker = SolidObjects::Worker.new
|
|
105
105
|
measure("process #{count} cold actors") { drain(worker) }
|
|
106
106
|
ensure
|
|
@@ -110,7 +110,7 @@ module SolidObjectsBenchmark
|
|
|
110
110
|
# @rbs () -> void
|
|
111
111
|
def hot_actor
|
|
112
112
|
reference = CounterActor.ref("hot")
|
|
113
|
-
count.times { reference.
|
|
113
|
+
count.times { reference.async(:increment) }
|
|
114
114
|
worker = SolidObjects::Worker.new
|
|
115
115
|
measure("process #{count} messages for one hot actor") { drain(worker) }
|
|
116
116
|
ensure
|
|
@@ -130,30 +130,51 @@ module SolidObjectsBenchmark
|
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
# @rbs () -> void
|
|
133
|
-
def
|
|
134
|
-
worker = SolidObjects::Worker.new
|
|
135
|
-
worker_thread = Thread.new { worker.run }
|
|
133
|
+
def sync_latency
|
|
136
134
|
samples = []
|
|
137
135
|
|
|
138
136
|
count.times do |index|
|
|
139
137
|
started_at = monotonic_now
|
|
140
|
-
CounterActor.ref("
|
|
138
|
+
CounterActor.ref("sync-#{index}").sync(:count, timeout: 5)
|
|
141
139
|
samples << monotonic_now - started_at
|
|
142
140
|
end
|
|
143
141
|
|
|
144
142
|
sorted = samples.sort
|
|
145
|
-
puts "
|
|
143
|
+
puts "sync #{count} calls: p50=#{milliseconds(percentile(sorted, 0.50))}ms " \
|
|
146
144
|
"p95=#{milliseconds(percentile(sorted, 0.95))}ms " \
|
|
147
145
|
"p99=#{milliseconds(percentile(sorted, 0.99))}ms"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
end
|
|
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}"
|
|
151
172
|
end
|
|
152
173
|
|
|
153
174
|
# @rbs () -> void
|
|
154
175
|
def activation_cache
|
|
155
176
|
reference = CounterActor.ref("cache")
|
|
156
|
-
count.times { reference.
|
|
177
|
+
count.times { reference.async(:increment) }
|
|
157
178
|
activations = 0
|
|
158
179
|
subscriber = ActiveSupport::Notifications.subscribe("solid_objects.activation.started") do
|
|
159
180
|
activations += 1
|
|
@@ -170,7 +191,7 @@ module SolidObjectsBenchmark
|
|
|
170
191
|
|
|
171
192
|
# @rbs () -> void
|
|
172
193
|
def query_count
|
|
173
|
-
CounterActor.ref("queries").
|
|
194
|
+
CounterActor.ref("queries").async(:increment)
|
|
174
195
|
worker = SolidObjects::Worker.new
|
|
175
196
|
queries = 0
|
|
176
197
|
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
|
|
@@ -232,7 +253,7 @@ module SolidObjectsBenchmark
|
|
|
232
253
|
def enqueue_round_robin
|
|
233
254
|
actor_count = [ concurrency * 10, count ].min
|
|
234
255
|
references = Array.new(actor_count) { |index| CounterActor.ref("actor-#{index}") }
|
|
235
|
-
count.times { |index| references[index % actor_count].
|
|
256
|
+
count.times { |index| references[index % actor_count].async(:increment) }
|
|
236
257
|
end
|
|
237
258
|
|
|
238
259
|
# @rbs (SolidObjects::Worker) -> Integer
|
|
@@ -260,6 +281,15 @@ module SolidObjectsBenchmark
|
|
|
260
281
|
samples.fetch(((samples.length - 1) * fraction).ceil)
|
|
261
282
|
end
|
|
262
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
|
+
|
|
263
293
|
# @rbs (Float) -> String
|
|
264
294
|
def milliseconds(seconds)
|
|
265
295
|
format("%.1f", seconds * 1_000)
|
|
@@ -60,6 +60,7 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
60
60
|
definition.integer :state_version, null: false, default: 1
|
|
61
61
|
definition.bigint :next_message_sequence, null: false, default: 1
|
|
62
62
|
definition.string :activation_owner_id, limit: 36
|
|
63
|
+
definition.string :activation_token, limit: 36
|
|
63
64
|
definition.datetime :activation_expires_at, precision: 6
|
|
64
65
|
definition.bigint :activation_generation, null: false, default: 0
|
|
65
66
|
definition.datetime :activated_at, precision: 6
|
|
@@ -75,12 +76,17 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
75
76
|
definition.check_constraint "state_version > 0", name: "chk_so_instances_state_version"
|
|
76
77
|
definition.check_constraint "next_message_sequence > 0", name: "chk_so_instances_sequence"
|
|
77
78
|
definition.check_constraint "activation_generation >= 0", name: "chk_so_instances_generation"
|
|
79
|
+
definition.check_constraint(
|
|
80
|
+
"(activation_owner_id IS NULL AND activation_token IS NULL) OR " \
|
|
81
|
+
"(activation_owner_id IS NOT NULL AND activation_token IS NOT NULL)",
|
|
82
|
+
name: "chk_so_instances_activation_owner"
|
|
83
|
+
)
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
add_foreign_key table(:instances),
|
|
81
87
|
table(:processes),
|
|
82
88
|
column: :activation_owner_id,
|
|
83
|
-
on_delete: :
|
|
89
|
+
on_delete: :restrict,
|
|
84
90
|
name: "fk_so_instances_owner"
|
|
85
91
|
end
|
|
86
92
|
|
|
@@ -102,10 +108,12 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
102
108
|
definition.string :idempotency_key, limit: 191
|
|
103
109
|
json_column definition, :result
|
|
104
110
|
json_column definition, :error
|
|
111
|
+
json_column definition, :rejection
|
|
105
112
|
definition.datetime :enqueued_at, null: false, precision: 6
|
|
106
113
|
definition.datetime :available_at, null: false, precision: 6
|
|
107
114
|
definition.datetime :started_at, precision: 6
|
|
108
115
|
definition.datetime :completed_at, precision: 6
|
|
116
|
+
definition.datetime :rejected_at, precision: 6
|
|
109
117
|
definition.datetime :last_failed_at, precision: 6
|
|
110
118
|
definition.timestamps precision: 6, null: false
|
|
111
119
|
|
|
@@ -114,10 +122,11 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
114
122
|
definition.index :request_id, unique: true, name: "idx_so_messages_request"
|
|
115
123
|
definition.index [ :instance_id, :idempotency_key ], unique: true, name: "idx_so_messages_idempotency"
|
|
116
124
|
definition.index [ :completed_at, :id ], name: "idx_so_messages_cleanup"
|
|
125
|
+
definition.index [ :rejected_at, :id ], name: "idx_so_messages_rejected"
|
|
117
126
|
definition.check_constraint "sequence > 0", name: "chk_so_messages_sequence"
|
|
118
127
|
definition.check_constraint "attempt_count >= 0", name: "chk_so_messages_attempt"
|
|
119
128
|
definition.check_constraint "max_attempts > 0", name: "chk_so_messages_max_attempts"
|
|
120
|
-
definition.check_constraint "message_kind IN ('
|
|
129
|
+
definition.check_constraint "message_kind IN ('async', 'sync', 'internal')", name: "chk_so_messages_kind"
|
|
121
130
|
end
|
|
122
131
|
end
|
|
123
132
|
|
|
@@ -148,6 +157,7 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
148
157
|
null: false,
|
|
149
158
|
foreign_key: { to_table: table(:instances), on_delete: :cascade, name: "fk_so_claimed_instance" }
|
|
150
159
|
definition.string :process_id, limit: 36
|
|
160
|
+
definition.string :activation_token, limit: 36
|
|
151
161
|
definition.bigint :activation_generation, null: false
|
|
152
162
|
definition.datetime :claimed_at, null: false, precision: 6
|
|
153
163
|
|
|
@@ -155,12 +165,14 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
155
165
|
definition.index :instance_id, unique: true, name: "idx_so_claimed_instance"
|
|
156
166
|
definition.index [ :process_id, :claimed_at ], name: "idx_so_claimed_process"
|
|
157
167
|
definition.check_constraint "activation_generation > 0", name: "chk_so_claimed_generation"
|
|
168
|
+
definition.check_constraint "process_id IS NULL OR activation_token IS NOT NULL",
|
|
169
|
+
name: "chk_so_claimed_activation_owner"
|
|
158
170
|
end
|
|
159
171
|
|
|
160
172
|
add_foreign_key table(:claimed_messages),
|
|
161
173
|
table(:processes),
|
|
162
174
|
column: :process_id,
|
|
163
|
-
on_delete: :
|
|
175
|
+
on_delete: :restrict,
|
|
164
176
|
name: "fk_so_claimed_process"
|
|
165
177
|
end
|
|
166
178
|
|
|
@@ -21,4 +21,4 @@ Message handlers themselves can run more than once. Sequential execution means o
|
|
|
21
21
|
- Pure state transitions are safe because failed transactions roll back.
|
|
22
22
|
- Application handlers need durable state guards for non-repeatable logical transitions.
|
|
23
23
|
- External systems require idempotency keys or deduplication.
|
|
24
|
-
-
|
|
24
|
+
- A synchronous invocation timing out does not cancel its durable message.
|
|
@@ -9,7 +9,10 @@ If actor A synchronously waits for actor B while B waits for A, sequential actor
|
|
|
9
9
|
|
|
10
10
|
## Decision
|
|
11
11
|
|
|
12
|
-
Actor references support durable asynchronous `
|
|
12
|
+
Actor references support durable asynchronous delivery through `async`.
|
|
13
|
+
Direct methods and `sync` are caller-facing request/response operations and
|
|
14
|
+
must not be called from actor code. Actor code uses staged actor messages or
|
|
15
|
+
request/result message pairs.
|
|
13
16
|
|
|
14
17
|
Messages staged during actor execution are delivered through a transactional outbox so they exist if and only if the source message commits.
|
|
15
18
|
|