solid_objects 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +160 -43
- data/app/models/solid_objects/claimed_message.rb +2 -0
- data/app/models/solid_objects/message.rb +6 -1
- data/benchmark/adoption_latency.rb +5 -0
- data/benchmark/support.rb +45 -15
- data/benchmark/{ask_latency.rb → sync_latency.rb} +1 -1
- data/db/migrate/20260805000000_create_solid_objects_tables.rb +15 -3
- data/docs/adr/0006-at-least-once-delivery.md +1 -1
- data/docs/adr/0008-actor-communication.md +4 -1
- data/docs/adr/0011-wake-up-strategy.md +13 -4
- data/docs/architecture.md +51 -20
- data/docs/authorization.md +85 -0
- data/docs/benchmarks.md +80 -10
- data/docs/correctness.md +28 -9
- data/docs/database-schema.md +15 -7
- data/docs/development.md +6 -5
- data/docs/fit.md +90 -0
- data/docs/implementation-plan.md +22 -16
- data/docs/migrating-existing-state.md +133 -0
- data/docs/operations.md +56 -4
- data/docs/research/solid_queue.md +2 -1
- data/docs/roadmap.md +9 -3
- data/docs/security.md +7 -2
- data/docs/state-migrations.md +4 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +17 -0
- data/lib/solid_objects/activation.rb +35 -19
- data/lib/solid_objects/activation_manager.rb +22 -6
- data/lib/solid_objects/actor.rb +16 -3
- data/lib/solid_objects/caller_process.rb +57 -0
- data/lib/solid_objects/client.rb +6 -37
- data/lib/solid_objects/configuration.rb +4 -4
- data/lib/solid_objects/doctor.rb +311 -0
- data/lib/solid_objects/engine.rb +1 -0
- data/lib/solid_objects/errors.rb +17 -1
- data/lib/solid_objects/executor.rb +42 -2
- data/lib/solid_objects/lease.rb +22 -10
- data/lib/solid_objects/message_reference.rb +1 -0
- data/lib/solid_objects/process_registry.rb +5 -1
- data/lib/solid_objects/reference.rb +8 -8
- data/lib/solid_objects/synchronous_invocation.rb +93 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +7 -0
- data/lib/tasks/solid_objects_tasks.rake +10 -0
- data/sig/generated/lib/solid_objects/activation.rbs +6 -0
- data/sig/generated/lib/solid_objects/activation_manager.rbs +6 -0
- data/sig/generated/lib/solid_objects/actor.rbs +9 -6
- data/sig/generated/lib/solid_objects/caller_process.rbs +32 -0
- data/sig/generated/lib/solid_objects/client.rbs +2 -8
- data/sig/generated/lib/solid_objects/configuration.rbs +2 -2
- data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
- data/sig/generated/lib/solid_objects/errors.rbs +18 -1
- data/sig/generated/lib/solid_objects/executor.rbs +3 -0
- data/sig/generated/lib/solid_objects/lease.rbs +14 -10
- data/sig/generated/lib/solid_objects/reference.rbs +3 -3
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +28 -0
- data/sig/generated/lib/solid_objects.rbs +3 -0
- data/sig/generated/models/solid_objects/message.rbs +3 -0
- metadata +15 -8
|
@@ -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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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.
|
|
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
|
|
144
|
-
|
|
145
|
-
explicit `message` DSL remains
|
|
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 `
|
|
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
|
-
##
|
|
328
|
+
## Synchronous invocation
|
|
323
329
|
|
|
324
|
-
|
|
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
|
-
|
|
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::
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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 |
|
|
15
|
-
| Claim, 200 actors | 1,
|
|
16
|
-
| Process, 40 actors round-robin | 548.
|
|
17
|
-
| Process, 200 cold actors |
|
|
18
|
-
| Process, one hot actor |
|
|
19
|
-
| Process, four workers |
|
|
20
|
-
|
|
|
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 |
|
|
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,
|
|
24
|
-
monotonically increasing generation.
|
|
25
|
-
|
|
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
|
-
##
|
|
112
|
+
## Synchronous invocation
|
|
110
113
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
caller
|
|
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
|
|
data/docs/database-schema.md
CHANGED
|
@@ -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
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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/
|
|
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.
|