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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +160 -43
  4. data/app/models/solid_objects/claimed_message.rb +2 -0
  5. data/app/models/solid_objects/message.rb +6 -1
  6. data/benchmark/adoption_latency.rb +5 -0
  7. data/benchmark/support.rb +45 -15
  8. data/benchmark/{ask_latency.rb → sync_latency.rb} +1 -1
  9. data/db/migrate/20260805000000_create_solid_objects_tables.rb +15 -3
  10. data/docs/adr/0006-at-least-once-delivery.md +1 -1
  11. data/docs/adr/0008-actor-communication.md +4 -1
  12. data/docs/adr/0011-wake-up-strategy.md +13 -4
  13. data/docs/architecture.md +51 -20
  14. data/docs/authorization.md +85 -0
  15. data/docs/benchmarks.md +80 -10
  16. data/docs/correctness.md +28 -9
  17. data/docs/database-schema.md +15 -7
  18. data/docs/development.md +6 -5
  19. data/docs/fit.md +90 -0
  20. data/docs/implementation-plan.md +22 -16
  21. data/docs/migrating-existing-state.md +133 -0
  22. data/docs/operations.md +56 -4
  23. data/docs/research/solid_queue.md +2 -1
  24. data/docs/roadmap.md +9 -3
  25. data/docs/security.md +7 -2
  26. data/docs/state-migrations.md +4 -0
  27. data/lib/generators/solid_objects/templates/solid_objects.rb +17 -0
  28. data/lib/solid_objects/activation.rb +35 -19
  29. data/lib/solid_objects/activation_manager.rb +22 -6
  30. data/lib/solid_objects/actor.rb +16 -3
  31. data/lib/solid_objects/caller_process.rb +57 -0
  32. data/lib/solid_objects/client.rb +6 -37
  33. data/lib/solid_objects/configuration.rb +4 -4
  34. data/lib/solid_objects/doctor.rb +311 -0
  35. data/lib/solid_objects/engine.rb +1 -0
  36. data/lib/solid_objects/errors.rb +17 -1
  37. data/lib/solid_objects/executor.rb +42 -2
  38. data/lib/solid_objects/lease.rb +22 -10
  39. data/lib/solid_objects/message_reference.rb +1 -0
  40. data/lib/solid_objects/process_registry.rb +5 -1
  41. data/lib/solid_objects/reference.rb +8 -8
  42. data/lib/solid_objects/synchronous_invocation.rb +93 -0
  43. data/lib/solid_objects/version.rb +1 -1
  44. data/lib/solid_objects.rb +7 -0
  45. data/lib/tasks/solid_objects_tasks.rake +10 -0
  46. data/sig/generated/lib/solid_objects/activation.rbs +6 -0
  47. data/sig/generated/lib/solid_objects/activation_manager.rbs +6 -0
  48. data/sig/generated/lib/solid_objects/actor.rbs +9 -6
  49. data/sig/generated/lib/solid_objects/caller_process.rbs +32 -0
  50. data/sig/generated/lib/solid_objects/client.rbs +2 -8
  51. data/sig/generated/lib/solid_objects/configuration.rbs +2 -2
  52. data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
  53. data/sig/generated/lib/solid_objects/errors.rbs +18 -1
  54. data/sig/generated/lib/solid_objects/executor.rbs +3 -0
  55. data/sig/generated/lib/solid_objects/lease.rbs +14 -10
  56. data/sig/generated/lib/solid_objects/reference.rbs +3 -3
  57. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +28 -0
  58. data/sig/generated/lib/solid_objects.rbs +3 -0
  59. data/sig/generated/models/solid_objects/message.rbs +3 -0
  60. metadata +15 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf365fb1bf63f59d2e13544271d9c86adfb37b6f4ea962c8a38befbd4f2ef53d
4
- data.tar.gz: ab3e2a3b06f43e9257bc4415edc371e23c652a8616be8a08b5d217b599fce7bc
3
+ metadata.gz: 654720ae19caa970f403b5cded61287e1f6a9eb9d731ff90fd9f4f103d55fa53
4
+ data.tar.gz: 256712423dd88685643a0345de44eb774efbca12188c78c9e89571ef884b59fc
5
5
  SHA512:
6
- metadata.gz: 1c9992bce15fd9b1edacffe02e9f5fc5558e98853a3ae206b08ddd3753a5b28874c6ea73134af03e27dadb093b13c59ccb6a1f564efa2613a386a913b7d182b1
7
- data.tar.gz: 71eaf3e7c278be7f1f2277ad753bb36421f90895da9ee3f9289178c4b5c7f10c180526504571d49d08a7d0437174353376b71c7801d9d46e3df169b3881e3a15
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
- # from anywhere in your app addressed by name:
24
- Counter.ref("global").increment(amount: 5)
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.1 is an early release. Its correctness core is implemented and tested,
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
- - [Messages and queries](#messages-and-queries)
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.1.
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 generated initializer denies all externally initiated operations. Replace
193
- the policy blocks with application-specific authorization before sending
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
- Start the runtime:
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
- The engine uses the application's primary Active Record connection by default.
216
- See [Database support](#database-support) for a separate database configuration.
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. Declared messages
255
- become asynchronous methods:
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
- message = cart.add_item(product_id: "shirt-123", quantity: 2)
336
+ cart.add_item(product_id: "shirt-123", quantity: 2)
260
337
  items = cart.items
261
338
  ```
262
339
 
263
- `message` is a `SolidObjects::MessageReference`. `items` is a deeply frozen
264
- JSON snapshot; mutating it cannot bypass the actor mailbox. State changes must
265
- go through public actor message methods.
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
- ## Messages and queries
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
- message = counter.increment(amount: 5)
407
+ value = counter.increment(amount: 5)
329
408
  value = counter.value
330
409
  ```
331
410
 
332
- Public actor methods are messages and become asynchronous syntax over `tell`,
333
- returning a durable `MessageReference`. Declared queries and attribute readers
334
- are synchronous syntax over `ask`. Unlike Cloudflare RPC, the initial `ask`
335
- implementation waits by polling the durable database row.
336
- The `message(:name) { ... }` DSL remains available for dynamic definitions.
337
- The explicit `tell` and `ask` forms remain available for dynamic operation
338
- names or names that collide with Ruby or reference methods.
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
- ### `tell`
421
+ ### `async`
341
422
 
342
- `tell` durably enqueues work and immediately returns a message reference:
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.tell(
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.tell(:evaluate, available_at: 10.minutes.from_now)
436
+ order.async(:evaluate, available_at: 10.minutes.from_now)
355
437
  ```
356
438
 
357
- ### `ask`
439
+ ### `sync`
358
440
 
359
- `ask` durably enqueues a query or message and waits for its result:
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.ask(:status, timeout: 5.seconds)
445
+ status = order.sync(:status, timeout: 5.seconds)
363
446
  ```
364
447
 
365
- The initial cross-process implementation polls the durable message row. It is
366
- intended for background callers and control paths, not latency-sensitive HTTP
367
- request handling. A timeout does not cancel the actor message.
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
- Actor code cannot call `ask`; synchronous actor-to-actor waits can deadlock in
370
- cycles. Use `tell` or `send_to` and a result message.
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 `tell`. Never bulk-update actor
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
- | `ask_polling_interval` | 0.05 seconds |
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 an `ask` caller times out; or
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.1:
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, renewal, fencing generations, and stale-write rejection;
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, and retry tooling;
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
@@ -10,5 +10,7 @@ module SolidObjects
10
10
  class_name: "SolidObjects::Process",
11
11
  foreign_key: :process_id,
12
12
  optional: true
13
+
14
+ validates :activation_token, presence: true, if: :process_id?
13
15
  end
14
16
  end
@@ -20,7 +20,7 @@ module SolidObjects
20
20
 
21
21
  before_validation :supply_defaults
22
22
 
23
- validates :message_kind, inclusion: { in: %w[tell ask internal] }
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?
@@ -0,0 +1,5 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative "support"
4
+
5
+ SolidObjectsBenchmark.adoption_latency
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.tell(:increment) }
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}").tell(:increment) }
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}").tell(:increment) }
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.tell(:increment) }
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 ask_latency
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("ask-#{index}").ask(:count, timeout: 5)
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 "ask #{count} calls: p50=#{milliseconds(percentile(sorted, 0.50))}ms " \
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
- ensure
149
- worker&.request_shutdown
150
- worker_thread&.join
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.tell(:increment) }
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").tell(:increment)
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].tell(:increment) }
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)
@@ -2,4 +2,4 @@
2
2
 
3
3
  require_relative "support"
4
4
 
5
- SolidObjectsBenchmark.ask_latency
5
+ SolidObjectsBenchmark.sync_latency
@@ -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: :nullify,
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 ('tell', 'ask', 'internal')", name: "chk_so_messages_kind"
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: :nullify,
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
- - `ask` timing out does not cancel its durable message.
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 `tell`. `ask` is a caller-facing request/response operation and must not be called from actor code. Actor code uses staged actor messages or request/result message pairs.
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