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
@@ -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
 
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.
@@ -0,0 +1,85 @@
1
+ # Authorization policies
2
+
3
+ Solid Objects treats actor identities as identifiers, never capabilities.
4
+ Knowing an actor ID, message ID, or signed stream token grants no permission.
5
+ All five policies deny by default, so a generated installation is
6
+ intentionally inert until the host application defines its trust boundary.
7
+
8
+ ## Policy reference
9
+
10
+ | Policy | Gates | Caller context | Risk if opened globally |
11
+ | --- | --- | --- | --- |
12
+ | `authorize_message` | Direct actor methods, explicit `sync` messages, and public `async` enqueue | Value passed as `authorization_context:`; often a user, service principal, or trusted internal marker | Anyone reaching the call site can mutate any known actor identity |
13
+ | `authorize_query` | Attribute reads, declared queries, observable reads, and component reads | Explicit call context or the Rails view context supplied by `solid_object` | Actor state can leak across users or tenants |
14
+ | `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
15
+ | `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
16
+ | `authorize_administration` | Engine administration controllers, process inspection/cleanup, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, and retries become exposed or mutable |
17
+
18
+ Internal reminder, effect-callback, and actor-to-actor deliveries come from
19
+ already committed runtime rows and do not re-enter the public client policy.
20
+
21
+ ## A tenant-aware policy
22
+
23
+ Pass the authenticated user as the call context:
24
+
25
+ ```ruby
26
+ cart = ShoppingCart.ref(Current.user.id)
27
+ cart.add_item(
28
+ product_id: "shirt-123",
29
+ authorization_context: Current.user
30
+ )
31
+ ```
32
+
33
+ Authorize only the matching user and actor type:
34
+
35
+ ```ruby
36
+ SolidObjects.configure do |configuration|
37
+ owns_actor = lambda do |actor_type:, actor_id:, authorization_context:, **|
38
+ user = authorization_context
39
+
40
+ actor_type == "ShoppingCart" &&
41
+ user.present? &&
42
+ actor_id == user.id.to_s
43
+ end
44
+
45
+ configuration.authorize_message = owns_actor
46
+ configuration.authorize_query = owns_actor
47
+ configuration.authorize_destroy = owns_actor
48
+
49
+ configuration.authorize_subscription = lambda do |actor_type:, actor_id:, authorization_context:|
50
+ connection = authorization_context
51
+
52
+ actor_type == "ShoppingCart" &&
53
+ connection.current_user.present? &&
54
+ actor_id == connection.current_user.id.to_s
55
+ end
56
+
57
+ configuration.authorize_administration = lambda do |authorization_context:, **|
58
+ context = authorization_context
59
+ user = context.respond_to?(:current_user) ? context.current_user : nil
60
+
61
+ user&.administrator?
62
+ end
63
+ end
64
+ ```
65
+
66
+ The policy receives normalized actor type and ID strings, message name and
67
+ arguments where relevant, and the context supplied by the caller. Avoid
68
+ authorizing from arguments alone; bind the actor identity to the authenticated
69
+ principal and tenant.
70
+
71
+ ## Server-side-only pilots
72
+
73
+ Allowing `authorize_message` and `authorize_query` unconditionally can be a
74
+ reasonable short-lived pilot only when every call site is trusted server code,
75
+ actor IDs cannot come from an unauthorized request, and the feature is not
76
+ exposed through Action Cable or administration routes.
77
+
78
+ Keep `authorize_destroy`, `authorize_subscription`, and
79
+ `authorize_administration` denied until each feature has an explicit policy.
80
+ Replace unconditional policies before exposing actor IDs to controllers, API
81
+ clients, MCP tools, jobs carrying user input, or browser subscriptions.
82
+
83
+ Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
84
+ probe is deliberately conservative: a context-aware policy may correctly warn
85
+ because it denies a `nil` context.
data/docs/benchmarks.md CHANGED
@@ -1,25 +1,95 @@
1
- # Local benchmarks
1
+ # Performance and storage costs
2
2
 
3
3
  These numbers are development measurements, not universal capacity guarantees.
4
4
  They include the runtime's Active Record and database query overhead and will
5
5
  vary with hardware, schema size, connection pools, durability settings, and
6
6
  contention.
7
7
 
8
- Measured 2026-08-05 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1,
8
+ ## Production-shaped adoption measurement
9
+
10
+ An adoption evaluation measured Solid Objects 0.2.0 from a macOS Rails process
11
+ against Docker MySQL 8 over a published TCP port. The host used Rails 8.1, Ruby
12
+ 4.0.5, roughly 165 gems, and an approximately 2,200-line schema.
13
+
14
+ | Operation | Existing key-value row | Solid Objects |
15
+ | --- | ---: | ---: |
16
+ | Write | median 4.7 ms | median 60 ms, minimum 33 ms, maximum 163 ms |
17
+ | Read | median 0.2 ms | median 28 ms |
18
+ | First call for a cold identity | approximately 5 ms | 315 ms |
19
+
20
+ This is not a controlled cross-database benchmark and no sample count was
21
+ recorded. It is still useful adoption evidence: a synchronous actor call is not
22
+ a substitute for a direct indexed row read when single-digit-millisecond
23
+ latency is the requirement. The first call includes actor-instance creation,
24
+ caller-process registration, message enqueue, activation claim, handler
25
+ execution, fenced commit, and activation release.
26
+
27
+ ## Project development benchmark
28
+
29
+ Measured 2026-08-06 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1,
9
30
  and SQLite 3.51.0. Each throughput scenario used 200 operations; the concurrent
10
31
  scenario used four worker threads.
11
32
 
12
33
  | Scenario | Result |
13
34
  | --- | ---: |
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 |
35
+ | Enqueue, one actor | 629.2 messages/s |
36
+ | Claim, 200 actors | 1,038.4 claims/s |
37
+ | Process, 40 actors round-robin | 548.1 messages/s |
38
+ | Process, 200 cold actors | 121.5 messages/s |
39
+ | Process, one hot actor | 726.4 messages/s |
40
+ | Process, four workers | 556.5 messages/s |
41
+ | Synchronous latency | p50 1.8 ms, p95 25.6 ms, p99 156.2 ms |
21
42
  | Activation reuse | 98.0%, four activations for 200 messages |
22
- | Queries for one message turn | 28 |
43
+ | Queries for one message turn | 29 |
44
+
45
+ The difference between the SQLite development result and the MySQL adoption
46
+ result is why Solid Objects does not publish one latency promise. Network
47
+ topology, adapter behavior, host schema, logging, callbacks, and contention all
48
+ matter.
49
+
50
+ ## Durable row growth
51
+
52
+ The storage cost is deterministic even when latency is not:
53
+
54
+ - the first call for one actor identity inserts one
55
+ `solid_objects_instances` row;
56
+ - every direct, `sync`, query, attribute read, or `async` call inserts one
57
+ permanent `solid_objects_messages` row;
58
+ - ready and claimed membership rows exist only while the call is pending or
59
+ executing;
60
+ - one caller process row is registered per application process that performs
61
+ synchronous calls;
62
+ - effects and observable changes add outbox rows; and
63
+ - reminders add one row per named actor reminder.
64
+
65
+ Attribute reads are therefore not free snapshots from the instance row. They
66
+ are ordered durable query messages and grow message history exactly like
67
+ writes.
68
+
69
+ Version 0.2 has cleanup indexes but no built-in pruning command. Budget message
70
+ growth as:
71
+
72
+ ```text
73
+ daily durable messages = daily actor writes + daily actor reads + daily callbacks
74
+ ```
75
+
76
+ Review the [retention requirements](operations.md#retention-and-backups) before
77
+ adopting a high-volume surface.
78
+
79
+ ## Measure the host application
80
+
81
+ Run the adoption benchmark against a dedicated empty database with the same
82
+ adapter and topology as production:
83
+
84
+ ```bash
85
+ COUNT=25 \
86
+ SOLID_OBJECTS_DATABASE_URL=mysql2://localhost/solid_objects_benchmark \
87
+ bundle exec ruby -Ilib benchmark/adoption_latency.rb
88
+ ```
89
+
90
+ It reports the first cold call, warm synchronous writes, ordered reads, and
91
+ durable instance/message growth. Run it near the application process, with
92
+ production-like TLS and network boundaries where applicable.
23
93
 
24
94
  The scripts and invocation examples are in the
25
95
  [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
@@ -66,19 +66,20 @@ correct change, rerun the focused test, then the complete database matrix.
66
66
 
67
67
  ## Benchmarks
68
68
 
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
72
- capacity guarantees.
69
+ Scripts in `benchmark/` cover adoption latency and durable row growth, enqueue,
70
+ claim, processing, cold actors, a hot actor, concurrent actors, synchronous
71
+ latency, cache reuse, and query counts. Results describe one machine and
72
+ database configuration; they are not universal capacity guarantees.
73
73
 
74
74
  ```bash
75
+ COUNT=25 bundle exec ruby -Ilib benchmark/adoption_latency.rb
75
76
  COUNT=500 bundle exec ruby -Ilib benchmark/enqueue.rb
76
77
  COUNT=500 bundle exec ruby -Ilib benchmark/claim.rb
77
78
  COUNT=500 bundle exec ruby -Ilib benchmark/processing.rb
78
79
  COUNT=500 bundle exec ruby -Ilib benchmark/cold_actors.rb
79
80
  COUNT=500 bundle exec ruby -Ilib benchmark/hot_actor.rb
80
81
  COUNT=500 CONCURRENCY=4 bundle exec ruby -Ilib benchmark/concurrent_actors.rb
81
- COUNT=100 bundle exec ruby -Ilib benchmark/ask_latency.rb
82
+ COUNT=100 bundle exec ruby -Ilib benchmark/sync_latency.rb
82
83
  COUNT=500 bundle exec ruby -Ilib benchmark/activation_cache.rb
83
84
  bundle exec ruby -Ilib benchmark/query_count.rb
84
85
  ```
data/docs/fit.md ADDED
@@ -0,0 +1,90 @@
1
+ # Is Solid Objects a good fit?
2
+
3
+ Solid Objects trades database work and retained message history for one strong
4
+ property: all committed turns for one durable identity execute in order behind
5
+ a fenced activation. Adopt it when that coordination property removes
6
+ application-level locking, recovery, and scheduling code that would otherwise
7
+ be difficult to make correct.
8
+
9
+ ## Strong fit signals
10
+
11
+ Solid Objects is a good candidate when most of these are true:
12
+
13
+ - State belongs to one durable identity such as a cart, room, device, session,
14
+ workflow, or user-specific schedule.
15
+ - Writes for that identity must be serialized.
16
+ - The state is naturally a bounded JSON document.
17
+ - The object needs per-identity reminders, transactional external effects, or
18
+ reactive Rails views.
19
+ - Different identities should run concurrently while one hot identity remains
20
+ deliberately sequential.
21
+ - A durable mailbox and at-least-once retry are more valuable than minimum
22
+ request latency.
23
+ - The application can operate and monitor additional database tables and, for
24
+ asynchronous features, a Solid Objects runtime process.
25
+
26
+ Typical fits include checkout state machines, collaborative rooms, device
27
+ twins, durable assessments, approval workflows, and user-specific scheduling.
28
+
29
+ ## Poor fit and anti-patterns
30
+
31
+ Prefer ordinary Active Record, cache storage, Active Job, or an event pipeline
32
+ when any of these dominate:
33
+
34
+ - High-QPS request-path reads. Every actor attribute read is an ordered durable
35
+ message, not a direct `SELECT`, and retains a message-history row.
36
+ - Hot counters such as abuse limits, impressions, page views, or metrics. One
37
+ identity is a serialization point and cannot gain throughput by adding
38
+ workers.
39
+ - High-volume append workloads. Actor state rewrites a JSON document and the
40
+ mailbox retains one durable message per call.
41
+ - Latency budgets where tens of milliseconds are already unacceptable.
42
+ - Large, relational, or query-heavy state. Keep that data normalized in
43
+ application tables.
44
+ - CPU-heavy work or slow network I/O inside a handler.
45
+ - Cross-actor transactions or synchronous actor-to-actor call graphs.
46
+ - State that is clearer as a normal record with database constraints and direct
47
+ service methods.
48
+
49
+ A rate limiter is usually a poor actor: it is hot, request-critical, and often
50
+ expires rather than requiring permanent message history. An impressions
51
+ pipeline is also a poor actor: its value is high-throughput append and
52
+ aggregation, not serialized mutable state.
53
+
54
+ ## Cost model
55
+
56
+ Every synchronous or asynchronous invocation:
57
+
58
+ - inserts one permanent `solid_objects_messages` row;
59
+ - briefly occupies one ready or claimed membership row;
60
+ - performs several short coordination transactions; and
61
+ - may add effect, broadcast, or reminder records.
62
+
63
+ The first call for an identity also inserts one `solid_objects_instances` row.
64
+ Each application process that performs synchronous calls registers one caller
65
+ process row. Actor state is rewritten as a JSON value on each successful
66
+ mutation.
67
+
68
+ There is no built-in retention command in 0.2. Completed message history grows
69
+ until the host application implements a reviewed retention policy. See
70
+ [performance measurements](benchmarks.md) and
71
+ [retention guidance](operations.md#retention-and-backups).
72
+
73
+ ## Decision checklist
74
+
75
+ Before adopting an actor, answer:
76
+
77
+ 1. What exact race or lifecycle problem requires serialized per-identity turns?
78
+ 2. What is the canonical actor identity?
79
+ 3. How hot can one identity become?
80
+ 4. Can the request path tolerate the measured cold and warm latency?
81
+ 5. How many calls and durable rows will this surface create per day?
82
+ 6. Which calls can be asynchronous?
83
+ 7. Which effects need downstream idempotency?
84
+ 8. Which runtime roles and operational alerts will the feature require?
85
+ 9. How will completed messages and outbox history be retained?
86
+ 10. How will existing state be cut over and rolled back?
87
+
88
+ Benchmark the actual host database and deployment topology before committing a
89
+ latency-sensitive surface. Local benchmark results are evidence about query
90
+ shape, not universal capacity guarantees.