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
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
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Migrating existing state
|
|
2
|
+
|
|
3
|
+
Moving an existing Redis, cache, or key-value state machine into Solid Objects
|
|
4
|
+
is a data migration and a coordination cutover. Treat it as a staged production
|
|
5
|
+
change, not a rewrite that switches storage in one deploy.
|
|
6
|
+
|
|
7
|
+
## 1. Write down the existing contract
|
|
8
|
+
|
|
9
|
+
Inventory:
|
|
10
|
+
|
|
11
|
+
- every read and write path;
|
|
12
|
+
- the current canonical and secondary keys;
|
|
13
|
+
- expiration and cleanup behavior;
|
|
14
|
+
- concurrency guards and idempotency keys;
|
|
15
|
+
- external effects;
|
|
16
|
+
- expected request latency and volume; and
|
|
17
|
+
- rollback requirements.
|
|
18
|
+
|
|
19
|
+
Run the [fit checklist](fit.md#decision-checklist) before migrating. A hot
|
|
20
|
+
counter or append pipeline may be better left in its existing store.
|
|
21
|
+
|
|
22
|
+
## 2. Choose one canonical identity
|
|
23
|
+
|
|
24
|
+
Solid Objects addresses an actor with one `(actor_type, actor_id)` pair. Do not
|
|
25
|
+
hide two competing identities inside actor code or reintroduce a scan.
|
|
26
|
+
|
|
27
|
+
When existing state is written by `(user_id, assessment_short)` but read by
|
|
28
|
+
`session_id`, create a normalized lookup record:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
class AssessmentSession < ApplicationRecord
|
|
32
|
+
validates :session_id, uniqueness: true
|
|
33
|
+
validates :assessment_short, uniqueness: { scope: :user_id }
|
|
34
|
+
|
|
35
|
+
def actor
|
|
36
|
+
Assessment.ref(id)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The lookup row gives both old keys one stable primary key. The actor ID is the
|
|
42
|
+
lookup record ID, and ordinary indexed Active Record queries resolve either
|
|
43
|
+
external key. This is clearer and safer than delimiter-joining composite values
|
|
44
|
+
or preserving a `LIKE` scan.
|
|
45
|
+
|
|
46
|
+
Create and backfill the lookup table before actor traffic begins. Enforce every
|
|
47
|
+
identity invariant with unique database indexes.
|
|
48
|
+
|
|
49
|
+
## 3. Add an idempotent bootstrap message
|
|
50
|
+
|
|
51
|
+
Never bulk-update `solid_objects_instances.state`. Direct writes bypass actor
|
|
52
|
+
ordering, state migrations, observables, activation ownership, and fencing.
|
|
53
|
+
|
|
54
|
+
Import through a normal actor message:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
class Assessment < SolidObjects::Actor
|
|
58
|
+
attribute :imported, default: false
|
|
59
|
+
attribute :answers, default: -> { [] }
|
|
60
|
+
|
|
61
|
+
def bootstrap(answers:)
|
|
62
|
+
return if imported
|
|
63
|
+
|
|
64
|
+
self.answers = answers
|
|
65
|
+
self.imported = true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Give every bootstrap call an idempotency key derived from the legacy record:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
session.actor.async(
|
|
74
|
+
:bootstrap,
|
|
75
|
+
answers: legacy.answers,
|
|
76
|
+
idempotency_key: "legacy-assessment:#{legacy.id}",
|
|
77
|
+
available_at: jittered_time
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Spread large backfills over a dispatch window and monitor mailbox age,
|
|
82
|
+
failures, and dead letters. Asynchronous backfill requires the worker runtime.
|
|
83
|
+
|
|
84
|
+
## 4. Prefer shadow comparison over blind dual writes
|
|
85
|
+
|
|
86
|
+
Two independent stores cannot be updated atomically without a shared
|
|
87
|
+
transaction or outbox. A controller that writes Redis and an actor in sequence
|
|
88
|
+
can leave them divergent after a timeout or crash.
|
|
89
|
+
|
|
90
|
+
A safer rollout:
|
|
91
|
+
|
|
92
|
+
1. Keep the legacy store authoritative.
|
|
93
|
+
2. Bootstrap the actor from a consistent legacy snapshot.
|
|
94
|
+
3. Mirror new changes to the actor with stable idempotency keys.
|
|
95
|
+
4. Read both stores in a background comparison path.
|
|
96
|
+
5. Record divergence counts without changing the user response.
|
|
97
|
+
6. Repair through actor messages, never direct actor-state SQL.
|
|
98
|
+
7. Cut reads over only after divergence remains acceptably low.
|
|
99
|
+
|
|
100
|
+
If the actor becomes authoritative before the legacy system is retired, emit a
|
|
101
|
+
transactional effect that updates the legacy store. The effect is at least once,
|
|
102
|
+
so the legacy write still needs idempotency.
|
|
103
|
+
|
|
104
|
+
## 5. Cut over in reversible stages
|
|
105
|
+
|
|
106
|
+
A typical zero-downtime sequence is:
|
|
107
|
+
|
|
108
|
+
1. Deploy the lookup table and dual-key resolution.
|
|
109
|
+
2. Deploy actor code and policies with reads still on the legacy store.
|
|
110
|
+
3. Start the required runtime roles.
|
|
111
|
+
4. Backfill actors in bounded batches.
|
|
112
|
+
5. Enable shadow comparison and reconcile drift.
|
|
113
|
+
6. Move a small cohort of reads to actors.
|
|
114
|
+
7. Expand the cohort while watching latency, database growth, retries, and
|
|
115
|
+
divergence.
|
|
116
|
+
8. Move writes to the actor.
|
|
117
|
+
9. Retain the legacy state through an explicit rollback window.
|
|
118
|
+
10. Remove dual writes and legacy data only after the rollback window closes.
|
|
119
|
+
|
|
120
|
+
Use a feature flag whose rollback restores legacy reads and writes without
|
|
121
|
+
requiring actor deletion. Do not assume a timed-out synchronous actor call did
|
|
122
|
+
not commit; query the durable result or use an idempotency key before retrying.
|
|
123
|
+
|
|
124
|
+
## 6. Plan for dormant state and future changes
|
|
125
|
+
|
|
126
|
+
Actor state migrations and legacy-store migration solve different problems:
|
|
127
|
+
|
|
128
|
+
- this cookbook moves ownership from another store into an actor;
|
|
129
|
+
- `state_version` evolves actor JSON after that ownership exists.
|
|
130
|
+
|
|
131
|
+
Keep every published actor migration step. A dormant actor can reactivate years
|
|
132
|
+
later with an old state representation. See the
|
|
133
|
+
[state migration guide](state-migrations.md) for rolling-deployment rules.
|
data/docs/operations.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Operations guide
|
|
2
2
|
|
|
3
|
+
## Installation verification
|
|
4
|
+
|
|
5
|
+
Run the installation doctor after generating the initializer and migrating:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bin/rails solid_objects:doctor
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It validates runtime configuration, required tables and columns, neutral policy
|
|
12
|
+
posture, live runtime roles, and a real workerless synchronous actor round-trip.
|
|
13
|
+
Engine migration timestamps are rewritten when copied into a host application,
|
|
14
|
+
so the schema check compares the required shape instead of a fixed timestamp.
|
|
15
|
+
Warnings such as an all-deny neutral policy do not fail the command because a
|
|
16
|
+
context-aware production policy may correctly deny the probe.
|
|
17
|
+
|
|
3
18
|
## Runtime
|
|
4
19
|
|
|
5
20
|
Start all configured roles:
|
|
@@ -61,7 +76,7 @@ operational task until that roadmap item lands.
|
|
|
61
76
|
|
|
62
77
|
Self-scheduling actors need a daily or similarly low-frequency reconciliation
|
|
63
78
|
job because an application-level alarm can be lost. The reconciler reads state
|
|
64
|
-
but sends every repair through `
|
|
79
|
+
but sends every repair through `async`.
|
|
65
80
|
|
|
66
81
|
Use:
|
|
67
82
|
|
|
@@ -114,9 +129,46 @@ Alert on:
|
|
|
114
129
|
## Retention and backups
|
|
115
130
|
|
|
116
131
|
The schema has cleanup indexes, but automatic pruning commands are still
|
|
117
|
-
roadmap work.
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
roadmap work. Every actor call creates a durable message-history row, including
|
|
133
|
+
queries and attribute reads. Choose a retention period from measured call
|
|
134
|
+
volume, storage budget, audit needs, and the longest promised synchronous-result
|
|
135
|
+
lookup window.
|
|
136
|
+
|
|
137
|
+
An application-owned pruning job can start from this conservative relation:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
cutoff = 30.days.ago
|
|
141
|
+
|
|
142
|
+
prunable_messages = SolidObjects::Message
|
|
143
|
+
.where(completed_at: ...cutoff)
|
|
144
|
+
.where.not(id: SolidObjects::ReadyMessage.select(:message_id))
|
|
145
|
+
.where.not(id: SolidObjects::ClaimedMessage.select(:message_id))
|
|
146
|
+
.where.not(id: SolidObjects::DeadLetter.select(:message_id))
|
|
147
|
+
.where.not(
|
|
148
|
+
id: SolidObjects::DeadLetter
|
|
149
|
+
.where.not(retried_message_id: nil)
|
|
150
|
+
.select(:retried_message_id)
|
|
151
|
+
)
|
|
152
|
+
.where.not(
|
|
153
|
+
id: SolidObjects::Effect
|
|
154
|
+
.where.not(status: "completed")
|
|
155
|
+
.select(:message_id)
|
|
156
|
+
)
|
|
157
|
+
.where.not(
|
|
158
|
+
id: SolidObjects::Broadcast
|
|
159
|
+
.where.not(status: "delivered")
|
|
160
|
+
.select(:message_id)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
prunable_messages.in_batches(of: 1_000).delete_all
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Deleting a message cascades to its completed effects, delivered broadcasts, and
|
|
167
|
+
other message-owned records. Test the exact relation against a restored
|
|
168
|
+
production snapshot before scheduling it. Keep source and retried messages for
|
|
169
|
+
dead letters under investigation, and never prune pending, processing, ready, or
|
|
170
|
+
claimed work. Choose a cutoff longer than every `sync` timeout because a caller
|
|
171
|
+
whose result row disappears can no longer observe that result.
|
|
120
172
|
|
|
121
173
|
Back up actor tables with the same consistency guarantees as application data.
|
|
122
174
|
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,16 +4,21 @@
|
|
|
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
|
|
15
18
|
- Durable observable broadcast outbox and authorized Action Cable refresh
|
|
16
19
|
- Reconciliation read APIs
|
|
20
|
+
- Installation doctor, authorization reference, fit guide, and legacy-state
|
|
21
|
+
migration cookbook
|
|
17
22
|
- SQLite, PostgreSQL, and MySQL integration suites
|
|
18
23
|
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
|
|
19
24
|
RuboCop policy, and a warning-free Brakeman scan
|
|
@@ -47,7 +52,8 @@
|
|
|
47
52
|
eviction.
|
|
48
53
|
8. Expand security scanning and run compatibility CI across supported Rails and
|
|
49
54
|
Ruby versions.
|
|
50
|
-
9. Benchmark all workloads under documented hardware/database settings
|
|
55
|
+
9. Benchmark all workloads under documented hardware/database settings and
|
|
56
|
+
publish adapter-specific adoption measurements.
|
|
51
57
|
|
|
52
58
|
No production-ready claim should be made until these hardening milestones have
|
|
53
59
|
operational soak evidence.
|
data/docs/security.md
CHANGED
|
@@ -7,10 +7,15 @@ destroying actors, subscribing to actor streams, and administration. The host
|
|
|
7
7
|
application supplies the authenticated request or connection as
|
|
8
8
|
`authorization_context`. All five hooks deny by default.
|
|
9
9
|
|
|
10
|
+
The [authorization reference](authorization.md) lists the caller context and
|
|
11
|
+
risk for every hook and includes a tenant-aware policy example.
|
|
12
|
+
|
|
10
13
|
Method-style reference calls do not bypass these hooks. Public instance methods
|
|
11
14
|
declared on an actor are part of its remotely addressable message surface and
|
|
12
|
-
delegate to the authorized
|
|
13
|
-
protected. Query and attribute methods
|
|
15
|
+
delegate to the authorized synchronous invocation path. Keep implementation
|
|
16
|
+
helpers private or protected. Query and attribute methods use the separate
|
|
17
|
+
query authorization policy. Explicit `async` message delivery uses the same
|
|
18
|
+
message authorization policy as direct calls.
|
|
14
19
|
`reference.destroy` delegates to `authorize_destroy` before checking whether
|
|
15
20
|
the actor exists, so denial does not reveal actor existence.
|
|
16
21
|
|
data/docs/state-migrations.md
CHANGED
|
@@ -44,3 +44,7 @@ A safe destructive rollout normally uses:
|
|
|
44
44
|
|
|
45
45
|
Never update actor JSON in a bulk SQL migration. Use actor messages so fencing,
|
|
46
46
|
ordering, observables, and outboxes remain intact.
|
|
47
|
+
|
|
48
|
+
This guide covers evolution after state belongs to Solid Objects. For moving
|
|
49
|
+
existing Redis, key-value, or relational state into actors without downtime,
|
|
50
|
+
use the [legacy-state migration cookbook](migrating-existing-state.md).
|
|
@@ -5,6 +5,23 @@ SolidObjects.configure do |configuration|
|
|
|
5
5
|
configuration.effect_worker_count = 1
|
|
6
6
|
configuration.broadcast_worker_count = 1
|
|
7
7
|
configuration.reminder_scheduler_count = 1
|
|
8
|
+
|
|
9
|
+
# Every policy denies by default, so a fresh installation is intentionally
|
|
10
|
+
# inert. Replace these policies before invoking actors.
|
|
11
|
+
#
|
|
12
|
+
# Message and query policies gate direct calls, sync, async, and state reads.
|
|
13
|
+
# Destroy removes an actor and all of its durable work. Subscription gates
|
|
14
|
+
# Action Cable streams. Administration gates engine pages and operational
|
|
15
|
+
# commands. Keep the last three denied until their callers are authenticated.
|
|
16
|
+
#
|
|
17
|
+
# Prefer policies that bind actor_type and actor_id to a trusted
|
|
18
|
+
# authorization_context. See:
|
|
19
|
+
# https://github.com/cardmagic/solid_objects/blob/main/docs/authorization.md
|
|
20
|
+
# and run:
|
|
21
|
+
#
|
|
22
|
+
# bin/rails solid_objects:doctor
|
|
23
|
+
#
|
|
24
|
+
# after configuring the application.
|
|
8
25
|
configuration.authorize_message = ->(**) { false }
|
|
9
26
|
configuration.authorize_query = ->(**) { false }
|
|
10
27
|
configuration.authorize_destroy = ->(**) { false }
|
|
@@ -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
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -150,7 +150,17 @@ module SolidObjects
|
|
|
150
150
|
Context.current_message
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
-
# @rbs (Symbol | String,
|
|
153
|
+
# @rbs (Symbol | String, String, ?details: Hash[String | Symbol, untyped]) -> bot
|
|
154
|
+
def reject(code, message, details: {})
|
|
155
|
+
rejection_code = code.to_s
|
|
156
|
+
unless rejection_code.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
157
|
+
raise ArgumentError, "rejection code must contain lowercase letters, digits, and underscores"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
raise Rejected.new(code: rejection_code, message:, details:)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
|
|
154
164
|
def emit(name, on_success: nil, on_failure: nil, **arguments)
|
|
155
165
|
validate_effect_callback!(on_success)
|
|
156
166
|
validate_effect_callback!(on_failure)
|
|
@@ -162,9 +172,10 @@ module SolidObjects
|
|
|
162
172
|
).tap do |intent|
|
|
163
173
|
effect_intents << intent
|
|
164
174
|
end
|
|
175
|
+
nil
|
|
165
176
|
end
|
|
166
177
|
|
|
167
|
-
# @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) ->
|
|
178
|
+
# @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> nil
|
|
168
179
|
def schedule(name, at:, every: nil, missed: :latest, arguments: {})
|
|
169
180
|
interval_seconds = every&.to_f
|
|
170
181
|
if interval_seconds && !interval_seconds.positive?
|
|
@@ -184,11 +195,13 @@ module SolidObjects
|
|
|
184
195
|
).tap do |intent|
|
|
185
196
|
reminder_intents << intent
|
|
186
197
|
end
|
|
198
|
+
nil
|
|
187
199
|
end
|
|
188
200
|
|
|
189
|
-
# @rbs (Reference, Symbol | String, ?available_at: Time?, ?idempotency_key: String?, **untyped) ->
|
|
201
|
+
# @rbs (Reference, Symbol | String, ?available_at: Time?, ?idempotency_key: String?, **untyped) -> nil
|
|
190
202
|
def send_to(reference, message_name, available_at: nil, idempotency_key: nil, **arguments)
|
|
191
203
|
stage_outbound_message(reference, message_name, arguments, available_at:, idempotency_key:)
|
|
204
|
+
nil
|
|
192
205
|
end
|
|
193
206
|
|
|
194
207
|
# @rbs (Symbol | String, Hash[String, untyped]) -> untyped
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class CallerProcess
|
|
5
|
+
# @rbs @mutex: Thread::Mutex
|
|
6
|
+
# @rbs @process_id: Integer?
|
|
7
|
+
# @rbs @registry: ProcessRegistry?
|
|
8
|
+
|
|
9
|
+
# @rbs () -> void
|
|
10
|
+
def initialize
|
|
11
|
+
@mutex = Thread::Mutex.new
|
|
12
|
+
@process_id = nil
|
|
13
|
+
@registry = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @rbs () -> ProcessRegistry
|
|
17
|
+
def process_registry
|
|
18
|
+
mutex.synchronize do
|
|
19
|
+
reset_after_fork
|
|
20
|
+
register unless reusable_registry?
|
|
21
|
+
registry.heartbeat
|
|
22
|
+
registry
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :mutex, :registry
|
|
29
|
+
|
|
30
|
+
# @rbs () -> void
|
|
31
|
+
def reset_after_fork
|
|
32
|
+
return if @process_id == ::Process.pid
|
|
33
|
+
|
|
34
|
+
@process_id = ::Process.pid
|
|
35
|
+
@registry = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs () -> bool
|
|
39
|
+
def reusable_registry?
|
|
40
|
+
return false unless registry&.process_record
|
|
41
|
+
|
|
42
|
+
registry.process_record.reload.shutdown_state == "running"
|
|
43
|
+
rescue ActiveRecord::RecordNotFound
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @rbs () -> ProcessRegistry
|
|
48
|
+
def register
|
|
49
|
+
@registry = ProcessRegistry.new
|
|
50
|
+
registry.register(
|
|
51
|
+
kind: "caller",
|
|
52
|
+
metadata: { execution: "synchronous" }
|
|
53
|
+
)
|
|
54
|
+
registry
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|