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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +67 -37
- data/app/models/solid_objects/claimed_message.rb +2 -0
- data/app/models/solid_objects/message.rb +6 -1
- data/benchmark/support.rb +10 -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/benchmarks.md +9 -9
- data/docs/correctness.md +28 -9
- data/docs/database-schema.md +15 -7
- data/docs/development.md +3 -3
- data/docs/implementation-plan.md +22 -16
- data/docs/operations.md +3 -3
- data/docs/research/solid_queue.md +2 -1
- data/docs/roadmap.md +5 -2
- data/docs/security.md +4 -2
- 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/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/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/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 +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
|
|
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.
|
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-
|
|
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 |
|
|
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
|
-
|
|
|
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 |
|
|
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,
|
|
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
|
@@ -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,
|
|
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/
|
|
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
|
```
|
data/docs/implementation-plan.md
CHANGED
|
@@ -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
|
|
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/
|
|
175
|
-
- `test/integration/
|
|
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#
|
|
180
|
-
- `Reference#
|
|
181
|
-
-
|
|
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
|
-
-
|
|
195
|
-
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
- `
|
|
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
|
|
468
|
-
- Reconciliation code mutating actor state outside `
|
|
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 `
|
|
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 `
|
|
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
|
|
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#
|
|
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
|
|
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,
|
|
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
|
|
13
|
-
protected. Query and attribute methods
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|