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
data/docs/architecture.md CHANGED
@@ -58,9 +58,11 @@ The registry maps a stable persisted actor type string to a Ruby actor class. Re
58
58
 
59
59
  A reference contains actor type and normalized actor ID. It is cheap,
60
60
  serializable as data, and does not imply an active Ruby object. Declared
61
- message methods delegate to `tell`; declared query and attribute methods
62
- delegate to `ask`. `destroy` is a reserved synchronous reference operation.
63
- All three paths authorize through the client.
61
+ message, query, and attribute methods use synchronous caller-assisted
62
+ invocation. `sync` provides the same behavior for a dynamic operation name,
63
+ while `async` only durably enqueues a message and returns its reference.
64
+ `destroy` is a reserved synchronous reference operation. Every path authorizes
65
+ through the client.
64
66
 
65
67
  ### Client and mailbox
66
68
 
@@ -73,10 +75,13 @@ Message execution state is table membership, not a status column. The durable me
73
75
  An activation lease is stored on the actor instance:
74
76
 
75
77
  - Owner process UUID
78
+ - Unique activation token
76
79
  - Database-time expiration
77
80
  - Monotonic generation
78
81
 
79
- Acquisition and renewal are short database writes. Generation is the fencing token used by every state commit.
82
+ Acquisition and renewal are short database writes. A fresh activation token
83
+ distinguishes concurrent activations owned by the same process. Generation is
84
+ the monotonic fencing token used by every state commit.
80
85
 
81
86
  ### Activation
82
87
 
@@ -140,9 +145,10 @@ end
140
145
  `attribute` creates actor instance readers and writers and an ordered read query.
141
146
  Public instance methods declared on the actor are messages. Declare helpers as
142
147
  private or protected. Messages and queries are exposed as methods on a
143
- reference: message methods delegate to `tell`, while query and attribute
144
- methods delegate to `ask`. Returned state snapshots are deeply frozen. The
145
- explicit `message` DSL remains available for dynamic definitions.
148
+ reference through the same synchronous caller-assisted path. Returned state
149
+ snapshots are deeply frozen. Use `async` for durable fire-and-forget delivery
150
+ and `sync` for dynamic operation names. The explicit `message` DSL remains
151
+ available for dynamic definitions.
146
152
 
147
153
  `message` and `query` both execute as durable mailbox turns. A query may not
148
154
  mutate state. The executor detects query mutation and fails the message. An
@@ -234,7 +240,7 @@ Before actor code:
234
240
 
235
241
  1. Renew if the lease would expire before the next renewal window.
236
242
  2. Load the earliest nonterminal mailbox sequence.
237
- 3. Atomically move its ready membership to claimed membership for the current owner and generation and increment the durable attempt counter.
243
+ 3. Atomically move its ready membership to claimed membership for the current owner, activation token, and generation and increment the durable attempt counter.
238
244
  4. Set the actor's current message context.
239
245
  5. Snapshot state and observable values.
240
246
 
@@ -248,7 +254,7 @@ Actor code then executes with no open database transaction and no pinned connect
248
254
 
249
255
  It cannot:
250
256
 
251
- - Call `ask` from actor context
257
+ - Call another actor directly or with `sync` from actor context
252
258
  - Perform a synchronous actor-to-actor wait
253
259
  - Assume execution happens once
254
260
  - Commit actor state directly
@@ -260,8 +266,8 @@ After actor code, the executor validates state and staged data as JSON and compu
260
266
  Successful completion uses one database transaction:
261
267
 
262
268
  1. Lock the instance row.
263
- 2. Verify owner, generation, and an unexpired lease using database time.
264
- 3. Lock the durable message and verify its claimed membership belongs to that owner and generation.
269
+ 2. Verify owner, activation token, generation, and an unexpired lease using database time.
270
+ 3. Lock the durable message and verify its claimed membership belongs to that owner, activation token, and generation.
265
271
  4. Update native JSON state and state version.
266
272
  5. Store the completion timestamp and result on the durable message and delete claimed membership.
267
273
  6. Insert staged effects.
@@ -319,21 +325,46 @@ The replacement has a higher generation. When the paused worker resumes, its con
319
325
 
320
326
  The effect can be delivered again. The stable effect ID is the idempotency key. This is why effect handlers must be idempotent.
321
327
 
322
- ## Ask
328
+ ## Synchronous invocation
323
329
 
324
- `ask` durably enqueues a normal message with a request ID, then waits for the row to become completed, dead-lettered, or timed out. Every waiter re-queries durable rows. The implemented wake-up interface provides same-process signaling, bounded polling, and dependency injection. PostgreSQL `LISTEN/NOTIFY` and optional Redis Pub/Sub are planned adapters.
330
+ A direct actor method or explicit `sync` call durably enqueues a normal mailbox
331
+ message, then tries to claim that actor for the caller process. If successful,
332
+ it drains earlier messages and the target through the same activation and
333
+ executor used by workers. If another process owns the actor, the caller waits
334
+ for the row to become completed, rejected, dead-lettered, destroyed, or timed
335
+ out. Every wait re-queries durable rows. The implemented wake-up interface
336
+ provides same-process signaling, bounded polling, and dependency injection.
337
+ PostgreSQL `LISTEN/NOTIFY` and optional Redis Pub/Sub are planned adapters.
325
338
 
326
- With a healthy cross-process wake-up adapter, the coordination-overhead target from enqueue or completion commit to the confirming query is p99 at or below 100 milliseconds. End-to-end latency also includes mailbox queueing and handler execution. Polling-only deployments can pay the configured interval on both the worker and result legs, so polling-only `ask` is for background callers, scripts, and control paths rather than latency-sensitive Rails request handlers.
339
+ The normal path does not wait for a worker polling interval because the caller
340
+ assists execution immediately. End-to-end latency still includes earlier
341
+ mailbox work, handler execution, and database commits. When another process
342
+ owns the actor, a healthy cross-process wake-up adapter targets p99 coordination
343
+ overhead at or below 100 milliseconds; polling fallback can pay up to
344
+ `sync_polling_interval` between observations.
327
345
 
328
346
  Caller timeout:
329
347
 
330
- - Raises `SolidObjects::AskTimeout`.
348
+ - Raises `SolidObjects::SyncTimeout`.
331
349
  - Does not cancel or delete the message.
332
350
  - Does not prevent later execution.
333
351
  - Leaves the result available until retention cleanup.
334
352
 
335
353
  The durable row remains after timeout and can be inspected directly by message ID. A public request-ID lookup and bounded retention commands are not yet implemented.
336
354
 
355
+ `async` performs the same durable enqueue without caller assistance or result
356
+ waiting and immediately returns a `MessageReference`. Runtime workers process
357
+ it normally.
358
+
359
+ ## Domain rejection
360
+
361
+ Actor code can call `reject` for a validation or business-rule outcome that
362
+ must not retry. The executor restores pre-turn state, discards staged intents,
363
+ stores the structured rejection, completes the claimed membership, and
364
+ continues with the next sequence in one fenced transaction. Synchronous callers
365
+ receive `SolidObjects::Rejected`. A rejection is neither an exception retry nor
366
+ a dead letter.
367
+
337
368
  ## Effects and actor-to-actor delivery
338
369
 
339
370
  `emit` creates a staged effect:
@@ -380,7 +411,7 @@ Solid Objects persists each occurrence by its mailbox row. Unlike Orleans remind
380
411
 
381
412
  Durable reminders are alarms, and an alarm can be lost at the application level even while the database and actor state remain healthy. A reminder callback may dead-letter, a handler may fail to schedule its successor, or a signup/configuration path may never enqueue the first message. Unlike a periodic full sweep, a self-scheduling actor can then remain silently inert forever.
382
413
 
383
- Applications with self-scheduling actors should run a lower-frequency reconciliation job. The reconciler may read actor state and report drift, but it never writes actor state directly. Every repair is a normal authorized `tell`, so the actor decides whether the transition is still necessary and all ordering, lease, fencing, and audit rules remain intact.
414
+ Applications with self-scheduling actors should run a lower-frequency reconciliation job. The reconciler may read actor state and report drift, but it never writes actor state directly. Every repair is a normal authorized `async` invocation, so the actor decides whether the transition is still necessary and all ordering, lease, fencing, and audit rules remain intact.
384
415
 
385
416
  `SolidObjects::Instance` exposes batchable read relations:
386
417
 
@@ -392,7 +423,7 @@ The expected drift categories are actors with a lost alarm, missing actors for l
392
423
 
393
424
  Bulk repair updates to `solid_objects_instances` are forbidden. They bypass activation ownership and fencing and can overwrite a concurrently committed actor state. Direct reads are observational; writes go through actor messages.
394
425
 
395
- Large repairs use `tell(..., available_at:)` to spread work over an application-defined dispatch window. The durable message records the requested availability and ready membership drives the hot polling query. This prevents reconciliation from flooding mailboxes and starving normal traffic.
426
+ Large repairs use `async(..., available_at:)` to spread work over an application-defined dispatch window. The durable message records the requested availability and ready membership drives the hot polling query. This prevents reconciliation from flooding mailboxes and starving normal traffic.
396
427
 
397
428
  ## Realtime integration
398
429
 
@@ -589,8 +620,8 @@ All backends use unique identity and sequence constraints, short transactions, a
589
620
  ## Answers to required correctness questions
590
621
 
591
622
  1. **How is a per-actor sequence allocated safely?** The instance row is created uniquely, locked in the enqueue transaction, incremented, and the message inserted under a unique actor/sequence index.
592
- 2. **How is one valid activation guaranteed?** Claim atomically changes owner and increments generation on one locked instance row. Only the matching unexpired owner/generation can commit.
593
- 3. **How are stale writes rejected?** Every commit verifies owner, generation, and database-time expiration.
623
+ 2. **How is one valid activation guaranteed?** Claim atomically changes owner, creates a unique activation token, and increments generation on one locked instance row. Only the matching unexpired owner/token/generation can commit.
624
+ 3. **How are stale writes rejected?** Every commit verifies owner, activation token, generation, and database-time expiration.
594
625
  4. **What if a worker dies during execution?** No actor transaction remains open. After lease expiry, a new generation retries the uncommitted message.
595
626
  5. **What if it dies after commit but before acknowledgement?** Completion and state are already durable, so the new activation skips that message.
596
627
  6. **How are external effects retry-safe?** Effects are inserted atomically into an outbox and use a stable effect ID for handler idempotency.
@@ -601,7 +632,7 @@ All backends use unique identity and sequence constraints, short transactions, a
601
632
  11. **How are actors deactivated?** Idle cache timeout or eviction, best-effort hook, conditional lease release.
602
633
  12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
603
634
  13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
604
- 14. **How does ask work across processes?** A wake-up adapter prompts a durable result query; periodic polling remains the fallback. Polling-only use is scoped to non-latency-sensitive callers.
635
+ 14. **How does synchronous invocation work across processes?** The caller first tries to claim and execute the actor locally. If another process owns it, a wake-up adapter prompts a durable result query and bounded polling remains the fallback.
605
636
  15. **What happens after caller timeout?** The durable message continues and its eventual result remains on the message row.
606
637
  16. **How are results cleaned up?** The schema has cleanup indexes, but bounded retention tooling is not implemented yet.
607
638
  17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields; rate and global admission controls remain roadmap work.
data/docs/benchmarks.md CHANGED
@@ -5,21 +5,21 @@ They include the runtime's Active Record and database query overhead and will
5
5
  vary with hardware, schema size, connection pools, durability settings, and
6
6
  contention.
7
7
 
8
- Measured 2026-08-05 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1,
8
+ Measured 2026-08-06 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1,
9
9
  and SQLite 3.51.0. Each throughput scenario used 200 operations; the concurrent
10
10
  scenario used four worker threads.
11
11
 
12
12
  | Scenario | Result |
13
13
  | --- | ---: |
14
- | Enqueue, one actor | 522.4 messages/s |
15
- | Claim, 200 actors | 1,009.3 claims/s |
16
- | Process, 40 actors round-robin | 548.7 messages/s |
17
- | Process, 200 cold actors | 119.5 messages/s |
18
- | Process, one hot actor | 729.1 messages/s |
19
- | Process, four workers | 568.2 messages/s |
20
- | Ask latency | p50 9.2 ms, p95 15.6 ms, p99 53.7 ms |
14
+ | Enqueue, one actor | 629.2 messages/s |
15
+ | Claim, 200 actors | 1,038.4 claims/s |
16
+ | Process, 40 actors round-robin | 548.1 messages/s |
17
+ | Process, 200 cold actors | 121.5 messages/s |
18
+ | Process, one hot actor | 726.4 messages/s |
19
+ | Process, four workers | 556.5 messages/s |
20
+ | Synchronous latency | p50 1.8 ms, p95 25.6 ms, p99 156.2 ms |
21
21
  | Activation reuse | 98.0%, four activations for 200 messages |
22
- | Queries for one message turn | 28 |
22
+ | Queries for one message turn | 29 |
23
23
 
24
24
  The scripts and invocation examples are in the
25
25
  [development guide](development.md#benchmarks). PostgreSQL and MySQL should be
data/docs/correctness.md CHANGED
@@ -20,11 +20,14 @@ messages until success or dead-lettering.
20
20
 
21
21
  ## Ownership and fencing
22
22
 
23
- Claiming an actor writes a process UUID, database-time expiration, and a
24
- monotonically increasing generation. Every successful or failed message
25
- finalization locks the instance and checks:
23
+ Claiming an actor writes a process UUID, a unique activation token,
24
+ database-time expiration, and a monotonically increasing generation. The token
25
+ separates concurrent activations in one process; the generation fences every
26
+ older activation. Every successful or failed message finalization locks the
27
+ instance and checks:
26
28
 
27
29
  - owner UUID matches;
30
+ - activation token matches;
28
31
  - generation matches;
29
32
  - expiration is still in the future according to database time; and
30
33
  - claimed-message membership names the same owner and generation.
@@ -106,13 +109,29 @@ The following are atomic:
106
109
 
107
110
  Actor Ruby code and external I/O are never inside the actor-state transaction.
108
111
 
109
- ## Ask
112
+ ## Synchronous invocation
110
113
 
111
- `ask` is a durable message followed by result polling and wake-up hints. Timeout
112
- does not cancel the message. The current cross-process fallback is polling;
113
- therefore polling-only ask is not recommended in latency-sensitive HTTP paths.
114
- Destroying the actor while an `ask` is waiting removes its message, wakes the
115
- caller, and raises `SolidObjects::ActorDestroyed`.
114
+ A direct reference method or explicit `sync` call durably enqueues an ordinary
115
+ mailbox message. The caller then attempts to claim that actor and execute
116
+ through the same activation, lease renewal, fencing, and executor code used by
117
+ a worker. It drains earlier messages first and returns only the committed
118
+ result. A worker may win the activation instead; the caller then observes the
119
+ durable result through wake-up hints with bounded polling as fallback.
120
+
121
+ Timeout raises `SolidObjects::SyncTimeout` but does not cancel the message.
122
+ Destroying the actor while a synchronous caller waits removes its message,
123
+ wakes the caller, and raises `SolidObjects::ActorDestroyed`.
124
+
125
+ `async` performs only the durable enqueue and immediately returns a
126
+ `MessageReference`.
127
+
128
+ ## Domain rejection
129
+
130
+ `reject` is a terminal domain outcome, not an infrastructure failure. It rolls
131
+ back in-memory state and staged intents, stores a structured rejection on the
132
+ message, removes claimed membership, and lets the next sequence run. It is
133
+ never retried or dead-lettered. The synchronous caller receives
134
+ `SolidObjects::Rejected`; asynchronous callers can inspect the message status.
116
135
 
117
136
  ## Database dependencies
118
137
 
@@ -8,8 +8,9 @@ migrations load. No partial indexes are used.
8
8
  ### `instances`
9
9
 
10
10
  One row per `(actor_type, actor_id)`. Stores JSON state, state version,
11
- next-message sequence, activation owner/expiration/generation, pause state, and
12
- lifecycle timestamps.
11
+ next-message sequence, activation owner/token/expiration/generation, pause
12
+ state, and lifecycle timestamps. The owner/token pairing is constrained so one
13
+ process row cannot make two concurrent activations appear identical.
13
14
 
14
15
  Deleting an instance is the actor-incarnation boundary. Foreign keys cascade
15
16
  the delete through messages, ready and claimed memberships, reminders, effects,
@@ -27,14 +28,17 @@ Indexes:
27
28
 
28
29
  Durable immutable invocation identity and arguments plus sequence, attempt
29
30
  count, request/idempotency IDs, result/error, requested availability, and
30
- execution timestamps. It intentionally has no status column.
31
+ execution timestamps. A terminal domain rejection stores a structured
32
+ code/message/details document and rejection time. The table intentionally has
33
+ no status column.
31
34
 
32
35
  Indexes:
33
36
 
34
37
  - unique instance/sequence and actor identity/sequence: mailbox order;
35
- - unique request ID: ask lookup;
38
+ - unique request ID: synchronous result lookup;
36
39
  - unique instance/idempotency key: deduplicated enqueue;
37
- - completion/ID: bounded retention cleanup.
40
+ - completion/ID: bounded retention cleanup;
41
+ - rejection/ID: bounded rejection inspection and cleanup.
38
42
 
39
43
  ### `ready_messages`
40
44
 
@@ -50,7 +54,8 @@ Indexes:
50
54
  ### `claimed_messages`
51
55
 
52
56
  Small hot membership table for one message currently owned by an activation.
53
- It records process UUID, activation generation, and claim time.
57
+ It records process UUID, unique activation token, activation generation, and
58
+ claim time.
54
59
 
55
60
  Indexes:
56
61
 
@@ -108,4 +113,7 @@ The public `reference.destroy` operation locks the instance row before deleting
108
113
  it. Every actor-owned table has a cascading foreign key either directly to the
109
114
  instance or through its message row. No application-side bulk delete can leave
110
115
  an executable orphan. Process registry rows are not actor-owned and remain
111
- available for worker lifecycle accounting.
116
+ available for worker lifecycle accounting. Activation-owner foreign keys use
117
+ restrictive deletion so the owner/token check remains enforceable on MySQL;
118
+ runtime deregistration clears leases and claims before a process row can be
119
+ pruned.
data/docs/development.md CHANGED
@@ -67,8 +67,8 @@ correct change, rerun the focused test, then the complete database matrix.
67
67
  ## Benchmarks
68
68
 
69
69
  Scripts in `benchmark/` cover enqueue, claim, processing, cold actors, a hot
70
- actor, concurrent actors, ask latency, cache reuse, and query counts. Results
71
- describe one machine and database configuration; they are not universal
70
+ actor, concurrent actors, synchronous latency, cache reuse, and query counts.
71
+ Results describe one machine and database configuration; they are not universal
72
72
  capacity guarantees.
73
73
 
74
74
  ```bash
@@ -78,7 +78,7 @@ COUNT=500 bundle exec ruby -Ilib benchmark/processing.rb
78
78
  COUNT=500 bundle exec ruby -Ilib benchmark/cold_actors.rb
79
79
  COUNT=500 bundle exec ruby -Ilib benchmark/hot_actor.rb
80
80
  COUNT=500 CONCURRENCY=4 bundle exec ruby -Ilib benchmark/concurrent_actors.rb
81
- COUNT=100 bundle exec ruby -Ilib benchmark/ask_latency.rb
81
+ COUNT=100 bundle exec ruby -Ilib benchmark/sync_latency.rb
82
82
  COUNT=500 bundle exec ruby -Ilib benchmark/activation_cache.rb
83
83
  bundle exec ruby -Ilib benchmark/query_count.rb
84
84
  ```
@@ -161,7 +161,7 @@ Create the seven domain tables plus ready- and claimed-message membership tables
161
161
 
162
162
  The schema installs in PostgreSQL, MySQL, and SQLite dummy databases and database constraints reject invalid states independently of Rails validations.
163
163
 
164
- ## Milestone 3: Durable enqueue, references, tell, and ask
164
+ ## Milestone 3: Durable enqueue and invocation modes
165
165
 
166
166
  ### Files
167
167
 
@@ -171,14 +171,15 @@ The schema installs in PostgreSQL, MySQL, and SQLite dummy databases and databas
171
171
  - `lib/solid_objects/message_reference.rb`
172
172
  - `lib/solid_objects/wake_up.rb`
173
173
  - `test/integration/enqueue_test.rb`
174
- - `test/integration/tell_test.rb`
175
- - `test/integration/ask_test.rb`
174
+ - `test/integration/enqueue_test.rb`
175
+ - `test/integration/sync_test.rb`
176
+ - `test/integration/synchronous_invocation_test.rb`
176
177
 
177
178
  ### Public API
178
179
 
179
- - `Reference#tell`
180
- - `Reference#ask`
181
- - Method-style message, query, and read-only attribute dispatch
180
+ - `Reference#async`
181
+ - `Reference#sync`
182
+ - Synchronous method-style message, query, and read-only attribute dispatch
182
183
  - `MessageReference#id`, `#status`, `#result`
183
184
  - Authorization context and hooks
184
185
 
@@ -191,8 +192,10 @@ No new tables. Use instance sequence and message request/idempotency columns.
191
192
  - Per-actor sequence allocation under concurrent connections
192
193
  - Independent sequences for different actors
193
194
  - Idempotency key deduplication
194
- - Tell return value
195
- - Ask success, failure, and timeout
195
+ - Async return value
196
+ - Synchronous success, rejection, failure, and timeout
197
+ - Caller-assisted processing behind earlier asynchronous work
198
+ - Same-actor serialization and different-actor concurrency
196
199
  - Mailbox and payload limits
197
200
  - Message/query authorization failure
198
201
 
@@ -201,12 +204,15 @@ No new tables. Use instance sequence and message request/idempotency columns.
201
204
  - Concurrent first enqueue
202
205
  - Lock timeout or deadlock
203
206
  - Duplicate idempotency key with different payload
204
- - Ask caller timeout
207
+ - Synchronous caller timeout
205
208
  - Oversized payload or mailbox
206
209
 
207
210
  ### Completion criteria
208
211
 
209
- Messages and ready membership enqueue durably in strict per-actor sequence and `ask` can observe a manually completed result. Polling-only `ask` is documented as unsuitable for latency-sensitive request handlers.
212
+ Messages and ready membership enqueue durably in strict per-actor sequence.
213
+ Direct methods and `sync` claim and execute the actor locally when possible,
214
+ while `async` returns immediately for worker execution. Every path uses the
215
+ same mailbox, lease, fencing, and durable result.
210
216
 
211
217
  ## Milestone 4: Fenced, runnable vertical slice
212
218
 
@@ -236,7 +242,7 @@ No new tables.
236
242
 
237
243
  ### Tests
238
244
 
239
- - Shopping cart tell and ask
245
+ - Shopping cart synchronous and asynchronous invocation
240
246
  - One actor processes messages sequentially
241
247
  - Different actors can execute concurrently
242
248
  - Lease acquire, renew, expire, and release
@@ -246,7 +252,7 @@ No new tables.
246
252
  - State and completion are atomic
247
253
  - Basic retry and strict head-of-mailbox blocking
248
254
  - Handler-level duplicate-delivery guards
249
- - Actor-to-actor tell outside actor context
255
+ - Actor-to-actor asynchronous delivery
250
256
 
251
257
  ### Failure modes
252
258
 
@@ -325,7 +331,7 @@ Use the effects table. Add delivery-token or outcome columns only through a migr
325
331
  - Stable idempotency context
326
332
  - Success/failure outcome messages
327
333
  - Transactional actor-to-actor delivery
328
- - `ask` rejected in actor context
334
+ - Direct and `sync` actor-to-actor calls rejected in actor context
329
335
 
330
336
  ### Failure modes
331
337
 
@@ -464,13 +470,13 @@ No expected changes.
464
470
  - Sensitive data in logs
465
471
  - Unbounded admin queries
466
472
  - Retrying wrong dead letter
467
- - Cleanup racing with ask waiter
468
- - Reconciliation code mutating actor state outside `tell`
473
+ - Cleanup racing with a synchronous waiter
474
+ - Reconciliation code mutating actor state outside `async`
469
475
  - Reconciliation stampedes without delayed `available_at`
470
476
 
471
477
  ### Completion criteria
472
478
 
473
- Operators can inspect health and failures without direct SQL, locate lost alarms and orphaned actors, and observe every required transition without raw arguments. Documentation requires reconciliation repairs to use delayed `tell` rather than direct instance updates.
479
+ Operators can inspect health and failures without direct SQL, locate lost alarms and orphaned actors, and observe every required transition without raw arguments. Documentation requires reconciliation repairs to use delayed `async` delivery rather than direct instance updates.
474
480
 
475
481
  ## Milestone 10: Examples, benchmarks, documentation, and release hardening
476
482
 
data/docs/operations.md CHANGED
@@ -61,7 +61,7 @@ operational task until that roadmap item lands.
61
61
 
62
62
  Self-scheduling actors need a daily or similarly low-frequency reconciliation
63
63
  job because an application-level alarm can be lost. The reconciler reads state
64
- but sends every repair through `tell`.
64
+ but sends every repair through `async`.
65
65
 
66
66
  Use:
67
67
 
@@ -115,8 +115,8 @@ Alert on:
115
115
 
116
116
  The schema has cleanup indexes, but automatic pruning commands are still
117
117
  roadmap work. Until implemented, define application-owned bounded deletes that
118
- preserve unfinished messages, dead letters under investigation, and ask results
119
- for the promised lookup period.
118
+ preserve unfinished messages, dead letters under investigation, and synchronous
119
+ results for the promised lookup period.
120
120
 
121
121
  Back up actor tables with the same consistency guarantees as application data.
122
122
  Restoring only instances without their mailboxes/outboxes, or vice versa, can
@@ -82,7 +82,8 @@ The equivalent thin public surface is:
82
82
 
83
83
  - `SolidObjects::Actor` for definitions.
84
84
  - `ActorClass.ref(actor_id)` for logical addressing.
85
- - `SolidObjects::Reference#tell` and `#ask` for invocation.
85
+ - Direct methods and `SolidObjects::Reference#sync` for request/response
86
+ invocation, plus `#async` for durable enqueue.
86
87
  - Explicit helpers for reminders, effects, observables, and lifecycle hooks.
87
88
 
88
89
  Mailbox rows, leases, worker records, and outboxes remain internal. Public message and dead-letter references should expose identifiers and safe inspection methods without leaking Active Record mutation APIs.
data/docs/roadmap.md CHANGED
@@ -4,11 +4,14 @@
4
4
 
5
5
  - Rails engine, install generator, migration, and CLI
6
6
  - Explicit actor registry, references, JSON state, and state migrations
7
+ - Direct synchronous RPC, explicit `sync`, and durable `async`
7
8
  - Durable message history plus ready/claimed membership tables
8
9
  - Concurrent sequence allocation and actor creation
9
- - Activation leases, renewal, generations, and fenced commits
10
+ - Activation leases, renewal, unique activation tokens, generations, and
11
+ fenced commits
10
12
  - Bounded activation passes, idle cache, hot-actor yield, and process records
11
- - At-least-once retries, strict poison ordering, dead letters, and tail retry
13
+ - At-least-once retries, terminal domain rejection, strict poison ordering,
14
+ dead letters, and tail retry
12
15
  - Transactional effects with success/failure actor messages
13
16
  - Actor-to-actor asynchronous outbox delivery
14
17
  - One-shot and recurring reminders with `:latest` or `:all` catch-up
data/docs/security.md CHANGED
@@ -9,8 +9,10 @@ application supplies the authenticated request or connection as
9
9
 
10
10
  Method-style reference calls do not bypass these hooks. Public instance methods
11
11
  declared on an actor are part of its remotely addressable message surface and
12
- delegate to the authorized `tell` path. Keep implementation helpers private or
13
- protected. Query and attribute methods delegate to the authorized `ask` path.
12
+ delegate to the authorized synchronous invocation path. Keep implementation
13
+ helpers private or protected. Query and attribute methods use the separate
14
+ query authorization policy. Explicit `async` message delivery uses the same
15
+ message authorization policy as direct calls.
14
16
  `reference.destroy` delegates to `authorize_destroy` before checking whether
15
17
  the actor exists, so denial does not reveal actor existence.
16
18
 
@@ -31,26 +31,12 @@ module SolidObjects
31
31
 
32
32
  # @rbs () -> Integer
33
33
  def drain
34
- processed_count = 0
35
- started_at = monotonic_now
36
- @pass_exhausted = false
37
-
38
- loop do
39
- if processed_count >= SolidObjects.configuration.max_messages_per_activation_pass ||
40
- monotonic_now - started_at >= SolidObjects.configuration.max_activation_duration
41
- @pass_exhausted = true
42
- break
43
- end
44
-
45
- message = claim_next_message
46
- break unless message
47
-
48
- Executor.new(activation: self, message:).call
49
- processed_count += 1
50
- @last_used_at = monotonic_now
51
- end
34
+ drain_messages
35
+ end
52
36
 
53
- processed_count
37
+ # @rbs (message_id: Integer, deadline: Float) -> Integer
38
+ def drain_until(message_id:, deadline:)
39
+ drain_messages(message_id:, deadline:)
54
40
  end
55
41
 
56
42
  # @rbs () -> bool
@@ -60,6 +46,7 @@ module SolidObjects
60
46
  ClaimedMessage.where(
61
47
  instance_id: lease.instance_id,
62
48
  process_id: lease.owner_id,
49
+ activation_token: lease.activation_token,
63
50
  activation_generation: lease.generation
64
51
  ).exists?
65
52
  end
@@ -113,6 +100,33 @@ module SolidObjects
113
100
 
114
101
  attr_reader :actor_class
115
102
 
103
+ # @rbs (?message_id: Integer?, ?deadline: Float?) -> Integer
104
+ def drain_messages(message_id: nil, deadline: nil)
105
+ processed_count = 0
106
+ started_at = monotonic_now
107
+ @pass_exhausted = false
108
+
109
+ loop do
110
+ break if deadline && monotonic_now >= deadline
111
+
112
+ if processed_count >= SolidObjects.configuration.max_messages_per_activation_pass ||
113
+ monotonic_now - started_at >= SolidObjects.configuration.max_activation_duration
114
+ @pass_exhausted = true
115
+ break
116
+ end
117
+
118
+ message = claim_next_message
119
+ break unless message
120
+
121
+ Executor.new(activation: self, message:).call
122
+ processed_count += 1
123
+ @last_used_at = monotonic_now
124
+ break if message.id == message_id
125
+ end
126
+
127
+ processed_count
128
+ end
129
+
116
130
  # @rbs (Instance) -> Actor
117
131
  def build_actor(instance)
118
132
  state_data = actor_class.definition.migrate_state(instance.state_version, instance.state)
@@ -145,6 +159,7 @@ module SolidObjects
145
159
  message:,
146
160
  instance:,
147
161
  process_id: lease.owner_id,
162
+ activation_token: lease.activation_token,
148
163
  activation_generation: lease.generation,
149
164
  claimed_at: now
150
165
  )
@@ -161,6 +176,7 @@ module SolidObjects
161
176
  .first
162
177
  return unless claimed_message
163
178
  return if claimed_message.process_id == lease.owner_id &&
179
+ claimed_message.activation_token == lease.activation_token &&
164
180
  claimed_message.activation_generation == lease.generation
165
181
 
166
182
  message = claimed_message.message
@@ -13,17 +13,37 @@ module SolidObjects
13
13
 
14
14
  # @rbs () -> Activation?
15
15
  def claim_next
16
+ claim_from(candidate_instance_ids(database_adapter.database_now))
17
+ end
18
+
19
+ # @rbs (instance_id: Integer) -> Activation?
20
+ def claim(instance_id:)
21
+ claim_from([ instance_id ])
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :owner_id, :database_adapter
27
+
28
+ # @rbs (Array[Integer]) -> Activation?
29
+ def claim_from(instance_ids)
16
30
  lease = database_adapter.transaction do
17
31
  now = database_adapter.database_now
18
32
  claimed_lease = nil
19
- candidate_instance_ids(now).each do |instance_id|
33
+ instance_ids.each do |instance_id|
20
34
  instance = database_adapter.lock_candidates(
21
35
  Instance.where(id: instance_id)
22
36
  ).first
23
37
  next unless instance
24
38
  next unless claimable?(instance, now)
25
39
 
26
- claimed_lease = Lease.claim(instance:, owner_id:, now:, database_adapter:)
40
+ claimed_lease = Lease.claim(
41
+ instance:,
42
+ owner_id:,
43
+ activation_token: SecureRandom.uuid,
44
+ now:,
45
+ database_adapter:
46
+ )
27
47
  break if claimed_lease
28
48
  end
29
49
  claimed_lease
@@ -42,10 +62,6 @@ module SolidObjects
42
62
  raise
43
63
  end
44
64
 
45
- private
46
-
47
- attr_reader :owner_id, :database_adapter
48
-
49
65
  # @rbs (Time) -> Array[Integer]
50
66
  def candidate_instance_ids(now)
51
67
  (ready_instance_ids(now) + claimed_instance_ids(now)).uniq