solid_objects 0.1.0 → 0.2.0

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +67 -37
  4. data/app/models/solid_objects/claimed_message.rb +2 -0
  5. data/app/models/solid_objects/message.rb +6 -1
  6. data/benchmark/support.rb +10 -15
  7. data/benchmark/{ask_latency.rb → sync_latency.rb} +1 -1
  8. data/db/migrate/20260805000000_create_solid_objects_tables.rb +15 -3
  9. data/docs/adr/0006-at-least-once-delivery.md +1 -1
  10. data/docs/adr/0008-actor-communication.md +4 -1
  11. data/docs/adr/0011-wake-up-strategy.md +13 -4
  12. data/docs/architecture.md +51 -20
  13. data/docs/benchmarks.md +9 -9
  14. data/docs/correctness.md +28 -9
  15. data/docs/database-schema.md +15 -7
  16. data/docs/development.md +3 -3
  17. data/docs/implementation-plan.md +22 -16
  18. data/docs/operations.md +3 -3
  19. data/docs/research/solid_queue.md +2 -1
  20. data/docs/roadmap.md +5 -2
  21. data/docs/security.md +4 -2
  22. data/lib/solid_objects/activation.rb +35 -19
  23. data/lib/solid_objects/activation_manager.rb +22 -6
  24. data/lib/solid_objects/actor.rb +16 -3
  25. data/lib/solid_objects/caller_process.rb +57 -0
  26. data/lib/solid_objects/client.rb +6 -37
  27. data/lib/solid_objects/configuration.rb +4 -4
  28. data/lib/solid_objects/engine.rb +1 -0
  29. data/lib/solid_objects/errors.rb +17 -1
  30. data/lib/solid_objects/executor.rb +42 -2
  31. data/lib/solid_objects/lease.rb +22 -10
  32. data/lib/solid_objects/message_reference.rb +1 -0
  33. data/lib/solid_objects/process_registry.rb +5 -1
  34. data/lib/solid_objects/reference.rb +8 -8
  35. data/lib/solid_objects/synchronous_invocation.rb +93 -0
  36. data/lib/solid_objects/version.rb +1 -1
  37. data/lib/solid_objects.rb +7 -0
  38. data/sig/generated/lib/solid_objects/activation.rbs +6 -0
  39. data/sig/generated/lib/solid_objects/activation_manager.rbs +6 -0
  40. data/sig/generated/lib/solid_objects/actor.rbs +9 -6
  41. data/sig/generated/lib/solid_objects/caller_process.rbs +32 -0
  42. data/sig/generated/lib/solid_objects/client.rbs +2 -8
  43. data/sig/generated/lib/solid_objects/configuration.rbs +2 -2
  44. data/sig/generated/lib/solid_objects/errors.rbs +18 -1
  45. data/sig/generated/lib/solid_objects/executor.rbs +3 -0
  46. data/sig/generated/lib/solid_objects/lease.rbs +14 -10
  47. data/sig/generated/lib/solid_objects/reference.rbs +3 -3
  48. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +28 -0
  49. data/sig/generated/lib/solid_objects.rbs +3 -0
  50. data/sig/generated/models/solid_objects/message.rbs +3 -0
  51. metadata +8 -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: 4d95ae7c1b6791ce00951e73eaf535a7b0b357c6708112509544de82eb59314d
4
+ data.tar.gz: a6a9ef715c217e38959438d1ab549447e0814c621746967dece190ae46b3108b
5
5
  SHA512:
6
- metadata.gz: 1c9992bce15fd9b1edacffe02e9f5fc5558e98853a3ae206b08ddd3753a5b28874c6ea73134af03e27dadb093b13c59ccb6a1f564efa2613a386a913b7d182b1
7
- data.tar.gz: 71eaf3e7c278be7f1f2277ad753bb36421f90895da9ee3f9289178c4b5c7f10c180526504571d49d08a7d0437174353376b71c7801d9d46e3df169b3881e3a15
6
+ metadata.gz: d604e195d678ae5f2ae24932fd0431a7076d0f8258b9e064bbc463042b911512edc7a6b613673b6731256d0b0603311708beb2158d79e421dc85b5febfb37f4d
7
+ data.tar.gz: cd817466462c8d0c843fa9766df1f2190ed3b477466661e32a47859f7fc63349327af7cb077d75ab5a43b621bb3ee98e321363b007ff5ad19e311123d871e040
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-08-06
4
+
5
+ - Make direct actor methods synchronous Durable Object-style RPC.
6
+ - Add explicit `sync` and `async` invocation modes.
7
+ - Let synchronous callers assist execution through the ordered mailbox using
8
+ the same activation leases and fencing checks as workers.
9
+ - Add terminal domain rejections that roll back actor state without retrying or
10
+ creating dead letters.
11
+ - Give each activation a unique token so concurrent callers in one process
12
+ cannot share lease ownership.
13
+ - Fix Action View helper loading when Action View initializes before engine
14
+ autoload paths.
15
+
3
16
  ## 0.1.0 - 2026-08-06
4
17
 
5
18
  - Introduce the Rails engine, actor API, and `solid_objects` executable.
data/README.md CHANGED
@@ -34,7 +34,7 @@ This is a port of the programming model, not Cloudflare's edge runtime or
34
34
  platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
35
35
  and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
36
36
 
37
- Version 0.1 is an early release. Its correctness core is implemented and tested,
37
+ Version 0.2 is an early release. Its correctness core is implemented and tested,
38
38
  but the project does not yet claim production readiness. See
39
39
  [Status](#status) and the [roadmap](docs/roadmap.md).
40
40
 
@@ -45,7 +45,7 @@ but the project does not yet claim production readiness. See
45
45
  - [Installation](#installation)
46
46
  - [Defining an actor](#defining-an-actor)
47
47
  - [Actor identity](#actor-identity)
48
- - [Messages and queries](#messages-and-queries)
48
+ - [Invoking an object](#invoking-an-object)
49
49
  - [Effects](#effects)
50
50
  - [Reminders](#reminders)
51
51
  - [Destroying an object](#destroying-an-object)
@@ -171,7 +171,7 @@ refresh from current actor state.
171
171
  `cart.component(:summary)` supports initial rendering of
172
172
  `actors/shopping_cart/_summary`. Durable live component replacement and
173
173
  Turbo append actions are roadmap work; observable replacement is the live path
174
- implemented in 0.1.
174
+ implemented in 0.2.
175
175
 
176
176
  Reactive views require `turbo-rails` and a working Action Cable adapter in the
177
177
  host application. They are optional; the actor runtime itself does not depend
@@ -206,7 +206,8 @@ end
206
206
 
207
207
  Knowledge of an actor ID or signed stream token is never authorization.
208
208
 
209
- Start the runtime:
209
+ Start the runtime for asynchronous messages, effects, reminders, and
210
+ broadcasts:
210
211
 
211
212
  ```bash
212
213
  bundle exec solid_objects start
@@ -251,18 +252,20 @@ declared on the actor are durable message handlers. They can use `items`,
251
252
  `self.checkout_status = "pending"`, or the lower-level `state` object. Declare
252
253
  helper methods as private or protected so they are not exposed as messages.
253
254
 
254
- Attributes also become ordered read queries on a reference. Declared messages
255
- become asynchronous methods:
255
+ Attributes also become ordered read queries on a reference. Public actor
256
+ methods and attribute readers are synchronous caller-assisted invocations:
256
257
 
257
258
  ```ruby
258
259
  cart = ShoppingCart.ref("alice")
259
- message = cart.add_item(product_id: "shirt-123", quantity: 2)
260
+ cart.add_item(product_id: "shirt-123", quantity: 2)
260
261
  items = cart.items
261
262
  ```
262
263
 
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.
264
+ Use `cart.async(:add_item, product_id: "shirt-123", quantity: 2)` to enqueue
265
+ without waiting; that call returns a `SolidObjects::MessageReference`. `items`
266
+ is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
267
+ mailbox. State changes must go through public actor methods or explicit
268
+ `async`.
266
269
 
267
270
  State, arguments, results, effects, and reminder arguments accept
268
271
  JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
@@ -310,7 +313,7 @@ end
310
313
  Actor types resolve only through the explicit registry. Solid Objects never
311
314
  constantizes a type supplied by a client.
312
315
 
313
- ## Messages and queries
316
+ ## Invoking an object
314
317
 
315
318
  As with a Durable Object stub, declared actor operations are available directly
316
319
  on a reference:
@@ -325,24 +328,27 @@ class Counter < SolidObjects::Actor
325
328
  end
326
329
 
327
330
  counter = Counter.ref("global")
328
- message = counter.increment(amount: 5)
331
+ value = counter.increment(amount: 5)
329
332
  value = counter.value
330
333
  ```
331
334
 
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.
335
+ Like RPC on a Durable Object stub, a direct call is synchronous from the
336
+ caller's perspective. Solid Objects first durably enqueues the invocation, then
337
+ executes that actor locally when its fenced activation is available. It returns
338
+ the committed, deeply frozen result. Earlier mailbox entries still run first,
339
+ and a remote worker may win the activation without changing the result
340
+ semantics.
339
341
 
340
- ### `tell`
342
+ The `message(:name) { ... }` and `query(:name) { ... }` DSLs remain available
343
+ for dynamic definitions.
341
344
 
342
- `tell` durably enqueues work and immediately returns a message reference:
345
+ ### `async`
346
+
347
+ Use `async` for durable fire-and-forget work. It returns a
348
+ `MessageReference` immediately and leaves execution to the worker fleet:
343
349
 
344
350
  ```ruby
345
- message = order.tell(
351
+ message = order.async(
346
352
  :submit,
347
353
  idempotency_key: "submit-order-123"
348
354
  )
@@ -351,23 +357,44 @@ message = order.tell(
351
357
  Use `available_at:` to spread bulk work or delay one message:
352
358
 
353
359
  ```ruby
354
- order.tell(:evaluate, available_at: 10.minutes.from_now)
360
+ order.async(:evaluate, available_at: 10.minutes.from_now)
355
361
  ```
356
362
 
357
- ### `ask`
363
+ ### `sync`
358
364
 
359
- `ask` durably enqueues a query or message and waits for its result:
365
+ Use explicit `sync` when the operation name is dynamic or collides with a
366
+ reference method:
360
367
 
361
368
  ```ruby
362
- status = order.ask(:status, timeout: 5.seconds)
369
+ status = order.sync(:status, timeout: 5.seconds)
363
370
  ```
364
371
 
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.
372
+ Direct calls and `sync` use the same caller-assisted execution path. A healthy
373
+ actor normally needs no worker round trip, making this path suitable for HTTP
374
+ and MCP request/response boundaries when the handler itself fits the
375
+ application's latency budget. If another process owns the activation, the
376
+ caller waits for the durable result using wake-up hints with bounded database
377
+ polling as the fallback. A timeout never cancels the durable invocation.
378
+
379
+ Actor code cannot use direct calls or `sync` on another actor; synchronous
380
+ actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
381
+ result message.
382
+
383
+ ### Domain rejection
384
+
385
+ Reject invalid input without retrying or creating a dead letter:
386
+
387
+ ```ruby
388
+ def submit(response:)
389
+ reject :validation_failed, "Response is not valid" unless valid?(response)
390
+
391
+ self.response = response
392
+ end
393
+ ```
368
394
 
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.
395
+ The caller receives `SolidObjects::Rejected` with a stable code, message, and
396
+ JSON-compatible details. The rejected message remains durable for audit, actor
397
+ state is rolled back, and no later mailbox turn is blocked.
371
398
 
372
399
  ### Redelivery
373
400
 
@@ -445,7 +472,7 @@ enqueue each one.
445
472
 
446
473
  Self-scheduling actors should also have a low-frequency application reconciler.
447
474
  It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
448
- `.orphaned`, but every repair must go through `tell`. Never bulk-update actor
475
+ `.orphaned`, but every repair must go through `async`. Never bulk-update actor
449
476
  state around the lease and fencing checks.
450
477
 
451
478
  ## Destroying an object
@@ -514,7 +541,7 @@ Important defaults:
514
541
  | Setting | Default |
515
542
  | --- | ---: |
516
543
  | `polling_interval` | 0.1 seconds |
517
- | `ask_polling_interval` | 0.05 seconds |
544
+ | `sync_polling_interval` | 0.05 seconds |
518
545
  | `lease_duration` | 30 seconds |
519
546
  | `lease_renewal_interval` | 10 seconds |
520
547
  | `idle_deactivation_timeout` | 30 seconds |
@@ -633,7 +660,7 @@ Solid Objects does not promise:
633
660
  - global order across actors;
634
661
  - distributed transactions;
635
662
  - bounded end-to-end latency;
636
- - cancellation when an `ask` caller times out; or
663
+ - cancellation when a synchronous caller times out; or
637
664
  - that a lease prevents stale Ruby code from continuing to run.
638
665
 
639
666
  The fencing generation prevents stale code from committing.
@@ -700,15 +727,18 @@ See the [development guide](docs/development.md) and
700
727
 
701
728
  ## Status
702
729
 
703
- Implemented and tested in 0.1:
730
+ Implemented and tested in 0.2:
704
731
 
705
732
  - Rails engine, install generator, migrations, and `solid_objects` executable;
706
733
  - actor registry, references, JSON state, and state migrations;
734
+ - direct synchronous actor RPC, explicit `sync`, and durable `async`;
707
735
  - durable message history plus ready and claimed membership tables;
708
736
  - concurrent sequence allocation and actor creation;
709
- - activation leases, renewal, fencing generations, and stale-write rejection;
737
+ - activation leases, per-activation tokens, fencing generations, and
738
+ stale-write rejection;
710
739
  - bounded activation passes, idle activation cache, and hot-actor fairness;
711
- - retries, strict poison ordering, dead letters, and retry tooling;
740
+ - retries, terminal domain rejection, strict poison ordering, dead letters,
741
+ and retry tooling;
712
742
  - transactional effects and asynchronous actor-to-actor messages;
713
743
  - one-shot and recurring per-actor reminders;
714
744
  - 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?
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,25 @@ 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
151
146
  end
152
147
 
153
148
  # @rbs () -> void
154
149
  def activation_cache
155
150
  reference = CounterActor.ref("cache")
156
- count.times { reference.tell(:increment) }
151
+ count.times { reference.async(:increment) }
157
152
  activations = 0
158
153
  subscriber = ActiveSupport::Notifications.subscribe("solid_objects.activation.started") do
159
154
  activations += 1
@@ -170,7 +165,7 @@ module SolidObjectsBenchmark
170
165
 
171
166
  # @rbs () -> void
172
167
  def query_count
173
- CounterActor.ref("queries").tell(:increment)
168
+ CounterActor.ref("queries").async(:increment)
174
169
  worker = SolidObjects::Worker.new
175
170
  queries = 0
176
171
  subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
@@ -232,7 +227,7 @@ module SolidObjectsBenchmark
232
227
  def enqueue_round_robin
233
228
  actor_count = [ concurrency * 10, count ].min
234
229
  references = Array.new(actor_count) { |index| CounterActor.ref("actor-#{index}") }
235
- count.times { |index| references[index % actor_count].tell(:increment) }
230
+ count.times { |index| references[index % actor_count].async(:increment) }
236
231
  end
237
232
 
238
233
  # @rbs (SolidObjects::Worker) -> Integer
@@ -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
 
@@ -9,7 +9,8 @@ Polling adds latency and database queries. PostgreSQL notifications are transact
9
9
 
10
10
  ## Decision
11
11
 
12
- Database rows remain the only durable source of work and results. Wake-up adapters only tell workers and `ask` waiters to re-query those rows.
12
+ Database rows remain the only durable source of work and results. Wake-up
13
+ adapters only prompt workers and synchronous waiters to re-query those rows.
13
14
 
14
15
  The interface supports:
15
16
 
@@ -20,9 +21,17 @@ The interface supports:
20
21
 
21
22
  MySQL uses polling or optional Redis. SQLite uses polling plus the in-process signal; multi-host SQLite is outside its supported operating model.
22
23
 
23
- The coordination-overhead latency budget, measured from durable enqueue or completion commit until a waiting worker or caller begins its confirming query, is p99 at or below 100 milliseconds when a healthy cross-process wake-up adapter is enabled. Polling-only deployments accept up to the configured polling interval on each wait leg. End-to-end `ask` latency additionally includes queueing and actor execution and cannot have a library-wide bound.
24
-
25
- Polling-only `ask` is intended for background callers, scripts, and control paths. It is not recommended in latency-sensitive Rails request handlers. A request handler may use it only with an explicit timeout and an operationally verified wake-up adapter and actor latency budget.
24
+ The synchronous caller first attempts to claim and execute the actor locally,
25
+ so the normal path has no worker polling leg. When another process owns the
26
+ activation, coordination overhead from completion commit until the caller's
27
+ confirming query targets p99 at or below 100 milliseconds with a healthy
28
+ cross-process wake-up adapter. Polling fallback accepts up to
29
+ `sync_polling_interval` between observations. End-to-end latency still includes
30
+ earlier mailbox work and actor execution and cannot have a library-wide bound.
31
+
32
+ Direct methods and `sync` are intended for HTTP, MCP, scripts, and control
33
+ paths whose handler and mailbox latency fit an explicit application budget.
34
+ Timeout does not cancel durable work.
26
35
 
27
36
  ## Consequences
28
37