solid_objects 0.2.1 → 0.3.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 +14 -0
- data/README.md +129 -8
- data/docs/architecture.md +62 -15
- data/docs/authorization.md +16 -3
- data/docs/benchmarks.md +6 -2
- data/docs/correctness.md +19 -1
- data/docs/database-schema.md +5 -0
- data/docs/development.md +40 -0
- data/docs/fit.md +10 -2
- data/docs/migrating-existing-state.md +8 -1
- data/docs/operations.md +74 -36
- data/docs/roadmap.md +8 -2
- data/docs/security.md +25 -3
- data/docs/state-migrations.md +2 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +30 -2
- data/lib/solid_objects/activation.rb +33 -4
- data/lib/solid_objects/actor.rb +47 -6
- data/lib/solid_objects/actor_definition.rb +2 -0
- data/lib/solid_objects/actor_snapshot.rb +10 -4
- data/lib/solid_objects/application_write_guard.rb +24 -0
- data/lib/solid_objects/caller_process.rb +28 -0
- data/lib/solid_objects/cli.rb +44 -5
- data/lib/solid_objects/client.rb +98 -5
- data/lib/solid_objects/commit_action_registry.rb +42 -0
- data/lib/solid_objects/configuration.rb +27 -1
- data/lib/solid_objects/database_adapter.rb +28 -1
- data/lib/solid_objects/database_adapters/mysql.rb +46 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +28 -0
- data/lib/solid_objects/errors.rb +144 -0
- data/lib/solid_objects/executor.rb +64 -4
- data/lib/solid_objects/instance_pruner.rb +97 -0
- data/lib/solid_objects/message_pruner.rb +97 -0
- data/lib/solid_objects/message_reference.rb +9 -0
- data/lib/solid_objects/process_pruner.rb +49 -0
- data/lib/solid_objects/reference.rb +5 -0
- data/lib/solid_objects/state_snapshot.rb +41 -0
- data/lib/solid_objects/sync_deadline.rb +57 -0
- data/lib/solid_objects/sync_diagnostics.rb +133 -0
- data/lib/solid_objects/synchronous_invocation.rb +26 -7
- data/lib/solid_objects/test_helper.rb +78 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/worker.rb +1 -1
- data/lib/solid_objects.rb +34 -0
- data/sig/generated/lib/solid_objects/activation.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor.rbs +26 -0
- data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
- data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
- data/sig/generated/lib/solid_objects/cli.rbs +11 -2
- data/sig/generated/lib/solid_objects/client.rbs +15 -0
- data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +27 -7
- data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +8 -0
- data/sig/generated/lib/solid_objects/errors.rbs +118 -0
- data/sig/generated/lib/solid_objects/executor.rbs +12 -0
- data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
- data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
- data/sig/generated/lib/solid_objects/reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
- data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
- data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
- data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
- data/sig/generated/lib/solid_objects.rbs +12 -0
- metadata +19 -1
data/docs/operations.md
CHANGED
|
@@ -34,6 +34,9 @@ Process inspection, cleanup, dead-letter inspection, and retry all require an
|
|
|
34
34
|
administration policy that authorizes the CLI context:
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
+
bundle exec solid_objects prune_messages
|
|
38
|
+
bundle exec solid_objects prune_instances
|
|
39
|
+
bundle exec solid_objects prune_processes
|
|
37
40
|
bundle exec solid_objects dead_letters
|
|
38
41
|
bundle exec solid_objects retry_dead_letter 123
|
|
39
42
|
```
|
|
@@ -56,6 +59,9 @@ Important controls include:
|
|
|
56
59
|
- payload, state, and result byte limits
|
|
57
60
|
- retry attempts and delay
|
|
58
61
|
- heartbeat interval and alive threshold
|
|
62
|
+
- message retention and per-actor-type overrides
|
|
63
|
+
- opt-in actor-instance retention by actor type
|
|
64
|
+
- stopped-process retention and prune batch size
|
|
59
65
|
|
|
60
66
|
Keep lease duration comfortably above renewal interval and expected database
|
|
61
67
|
pause time. A handler can exceed the pass-duration budget because Ruby code is
|
|
@@ -126,49 +132,81 @@ Alert on:
|
|
|
126
132
|
- reconciliation drift;
|
|
127
133
|
- database lock waits, deadlocks, and SQLite busy errors.
|
|
128
134
|
|
|
135
|
+
## Instrumentation and logging
|
|
136
|
+
|
|
137
|
+
Active Support notifications use the `solid_objects.` prefix. Core events
|
|
138
|
+
include message enqueue/start/completion/failure/rejection, activation
|
|
139
|
+
claim/start/renew/release/deactivation failure, sync timeout/enqueue timeout/
|
|
140
|
+
transaction rejection, commit-action start/completion/failure, effect and
|
|
141
|
+
broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
|
|
142
|
+
retention pruning, process cleanup, and supervisor lifecycle.
|
|
143
|
+
|
|
144
|
+
Payloads contain stable runtime identifiers, actor identity, sequence,
|
|
145
|
+
attempts, ownership generations, and safe exception summaries where relevant.
|
|
146
|
+
Arguments, actor state, results, and outbox payloads are excluded. The bundled
|
|
147
|
+
log subscriber turns the same notifications into structured logger hashes.
|
|
148
|
+
|
|
129
149
|
## Retention and backups
|
|
130
150
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
volume, storage budget, audit needs, and the longest promised synchronous-result
|
|
135
|
-
lookup window.
|
|
151
|
+
Every actor call creates a durable message-history row, including queries and
|
|
152
|
+
attribute reads. The default retention policy keeps terminal message history
|
|
153
|
+
for 30 days and stopped process records for 7 days:
|
|
136
154
|
|
|
137
|
-
|
|
155
|
+
`reference.snapshot` is the explicit exception: it performs an authorized
|
|
156
|
+
current-state read without mailbox ordering or a message row.
|
|
138
157
|
|
|
139
158
|
```ruby
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
SolidObjects.configure do |configuration|
|
|
160
|
+
configuration.message_retention = 30.days
|
|
161
|
+
configuration.message_retention_by_actor_type = {
|
|
162
|
+
"AuditActor" => 365.days,
|
|
163
|
+
"EphemeralCounter" => 1.day
|
|
164
|
+
}
|
|
165
|
+
configuration.instance_retention_by_actor_type = {
|
|
166
|
+
"EphemeralCounter" => 30.days
|
|
167
|
+
}
|
|
168
|
+
configuration.process_retention = 7.days
|
|
169
|
+
configuration.prune_batch_size = 1_000
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Both pruning commands are dry-run previews by default:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
bundle exec solid_objects prune_messages
|
|
177
|
+
bundle exec solid_objects prune_instances
|
|
178
|
+
bundle exec solid_objects prune_processes
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
After reviewing the counts, execute bounded deletion:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
bundle exec solid_objects prune_messages --execute
|
|
185
|
+
bundle exec solid_objects prune_instances --execute
|
|
186
|
+
bundle exec solid_objects prune_processes --execute
|
|
164
187
|
```
|
|
165
188
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
189
|
+
Message pruning keeps ready and claimed work, dead letters and their retry
|
|
190
|
+
links, messages with unfinished effects, and messages with undelivered
|
|
191
|
+
broadcasts. Deleting eligible history cascades to completed effects, delivered
|
|
192
|
+
broadcasts, and other message-owned rows. Choose a cutoff longer than every
|
|
193
|
+
`sync` timeout because a caller whose result row disappears can no longer
|
|
194
|
+
observe it.
|
|
195
|
+
|
|
196
|
+
Actor expiration is disabled by default. `prune_instances` considers only
|
|
197
|
+
actor types listed in `instance_retention_by_actor_type`, excludes active or
|
|
198
|
+
paused actors, and preserves ready/claimed mailbox work, scheduled reminders,
|
|
199
|
+
unfinished or dead outboxes, and dead letters. It locks and rechecks every
|
|
200
|
+
candidate before cascading deletion. Preview counts first, then schedule
|
|
201
|
+
`--execute` only after the application has accepted the loss of dormant state
|
|
202
|
+
and completed history.
|
|
203
|
+
|
|
204
|
+
Use authorized `reference.destroy` when deletion is an explicit application
|
|
205
|
+
operation rather than a retention policy.
|
|
206
|
+
|
|
207
|
+
Run stale-process `cleanup` before `prune_processes`. Normal caller processes
|
|
208
|
+
mark their registrations stopped at exit; hard kills remain recoverable through
|
|
209
|
+
heartbeat cleanup.
|
|
172
210
|
|
|
173
211
|
Back up actor tables with the same consistency guarantees as application data.
|
|
174
212
|
Restoring only instances without their mailboxes/outboxes, or vice versa, can
|
data/docs/roadmap.md
CHANGED
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
- Reconciliation read APIs
|
|
20
20
|
- Installation doctor, authorization reference, fit guide, and legacy-state
|
|
21
21
|
migration cookbook
|
|
22
|
+
- Handler Active Record write isolation, same-database commit actions, ambient
|
|
23
|
+
transaction rejection, adapter lock/query deadlines, structured sync timeout
|
|
24
|
+
diagnostics, and result recovery
|
|
25
|
+
- Bounded message/process pruning, actor-type opt-in instance expiration,
|
|
26
|
+
graceful caller shutdown, committed state snapshots, and an opt-in Minitest
|
|
27
|
+
helper
|
|
22
28
|
- SQLite, PostgreSQL, and MySQL integration suites
|
|
23
29
|
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
|
|
24
30
|
RuboCop policy, and a warning-free Brakeman scan
|
|
@@ -43,8 +49,8 @@
|
|
|
43
49
|
1. Add automatic supervisor role replacement and periodic dead-process cleanup.
|
|
44
50
|
2. Add PostgreSQL notification and optional Redis wake-up adapters with latency
|
|
45
51
|
benchmarks and polling-race tests.
|
|
46
|
-
3. Add
|
|
47
|
-
4. Add
|
|
52
|
+
3. Add result lookup by request ID and broader deadlock retry classification.
|
|
53
|
+
4. Add scheduled retention and stale-process maintenance.
|
|
48
54
|
5. Add database/server-version checks and MySQL InnoDB verification at boot.
|
|
49
55
|
6. Add component broadcast rendering, Turbo append intents, and reconnect tests
|
|
50
56
|
in a full browser.
|
data/docs/security.md
CHANGED
|
@@ -13,9 +13,11 @@ risk for every hook and includes a tenant-aware policy example.
|
|
|
13
13
|
Method-style reference calls do not bypass these hooks. Public instance methods
|
|
14
14
|
declared on an actor are part of its remotely addressable message surface and
|
|
15
15
|
delegate to the authorized synchronous invocation path. Keep implementation
|
|
16
|
-
helpers private or protected. Query
|
|
17
|
-
query authorization policy. Explicit `async`
|
|
18
|
-
message authorization policy as direct calls.
|
|
16
|
+
helpers private or protected. Query, attribute, observable, and committed
|
|
17
|
+
`snapshot` reads use the separate query authorization policy. Explicit `async`
|
|
18
|
+
message delivery uses the same message authorization policy as direct calls.
|
|
19
|
+
Recovering a timed-out result through `MessageReference#wait` reauthorizes the
|
|
20
|
+
stored operation.
|
|
19
21
|
`reference.destroy` delegates to `authorize_destroy` before checking whether
|
|
20
22
|
the actor exists, so denial does not reveal actor existence.
|
|
21
23
|
|
|
@@ -54,10 +56,30 @@ host authentication and audit their use.
|
|
|
54
56
|
Instrumentation excludes arguments, state, results, and effect payloads by
|
|
55
57
|
default. Review custom logging and effect handlers for accidental disclosure.
|
|
56
58
|
|
|
59
|
+
## Handler database access
|
|
60
|
+
|
|
61
|
+
Handlers, observables, lifecycle hooks, and state migrations run with Active
|
|
62
|
+
Record writes prevented. They may query application records, but a direct
|
|
63
|
+
write becomes
|
|
64
|
+
`SolidObjects::ApplicationWriteForbidden` and dead-letters without retry.
|
|
65
|
+
This prevents application data from escaping a later actor failure or stale
|
|
66
|
+
fence.
|
|
67
|
+
|
|
68
|
+
Registered commit actions are privileged application code. They execute inside
|
|
69
|
+
the fenced actor transaction and receive stored JSON arguments, so register
|
|
70
|
+
only fixed names, validate record ownership again, and keep the block to
|
|
71
|
+
bounded database work. Never perform network I/O or authorize solely from a
|
|
72
|
+
record ID in commit-action arguments.
|
|
73
|
+
|
|
57
74
|
Actor destruction is not an administrative shortcut. Authorize tenancy and
|
|
58
75
|
ownership explicitly in `authorize_destroy`; knowledge of an actor ID is never
|
|
59
76
|
permission to delete its state or queued work.
|
|
60
77
|
|
|
78
|
+
Instance pruning is likewise destructive and requires administration
|
|
79
|
+
authorization. Only opt-in actor types are eligible, and live work is
|
|
80
|
+
preserved, but the host application must decide whether dormant state and
|
|
81
|
+
completed history may expire.
|
|
82
|
+
|
|
61
83
|
## Denial of service
|
|
62
84
|
|
|
63
85
|
Configure mailbox and byte limits. Add host rate limiting before public actor
|
data/docs/state-migrations.md
CHANGED
|
@@ -15,6 +15,8 @@ end
|
|
|
15
15
|
|
|
16
16
|
Migration runs in memory during activation. The new version is persisted only
|
|
17
17
|
with the next successful fenced message commit.
|
|
18
|
+
Migration blocks may read application records but cannot write them directly;
|
|
19
|
+
the same Active Record write guard used for handlers applies before activation.
|
|
18
20
|
|
|
19
21
|
## Runtime rules
|
|
20
22
|
|
|
@@ -5,14 +5,35 @@ 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
|
+
configuration.message_retention = 30.days
|
|
9
|
+
configuration.process_retention = 7.days
|
|
10
|
+
configuration.prune_batch_size = 1_000
|
|
11
|
+
|
|
12
|
+
# Override message retention only for actor types with different audit or
|
|
13
|
+
# privacy requirements:
|
|
14
|
+
#
|
|
15
|
+
# configuration.message_retention_by_actor_type = {
|
|
16
|
+
# "AuditActor" => 365.days,
|
|
17
|
+
# "EphemeralCounter" => 1.day
|
|
18
|
+
# }
|
|
19
|
+
#
|
|
20
|
+
# Actor instances never expire unless their type is listed here. Expiration
|
|
21
|
+
# removes idle state and completed history, so start with the preview command:
|
|
22
|
+
#
|
|
23
|
+
# configuration.instance_retention_by_actor_type = {
|
|
24
|
+
# "EphemeralCounter" => 30.days
|
|
25
|
+
# }
|
|
26
|
+
#
|
|
27
|
+
# bundle exec solid_objects prune_instances
|
|
8
28
|
|
|
9
29
|
# Every policy denies by default, so a fresh installation is intentionally
|
|
10
30
|
# inert. Replace these policies before invoking actors.
|
|
11
31
|
#
|
|
12
32
|
# Message and query policies gate direct calls, sync, async, and state reads.
|
|
13
33
|
# Destroy removes an actor and all of its durable work. Subscription gates
|
|
14
|
-
# Action Cable streams. Administration gates engine pages and
|
|
15
|
-
# commands. Keep the last three denied until their callers are
|
|
34
|
+
# Action Cable streams. Administration gates engine pages, pruning, and
|
|
35
|
+
# operational commands. Keep the last three denied until their callers are
|
|
36
|
+
# authenticated.
|
|
16
37
|
#
|
|
17
38
|
# Prefer policies that bind actor_type and actor_id to a trusted
|
|
18
39
|
# authorization_context. See:
|
|
@@ -27,4 +48,11 @@ SolidObjects.configure do |configuration|
|
|
|
27
48
|
configuration.authorize_destroy = ->(**) { false }
|
|
28
49
|
configuration.authorize_subscription = ->(**) { false }
|
|
29
50
|
configuration.authorize_administration = ->(**) { false }
|
|
51
|
+
|
|
52
|
+
# On hosts where shell access is already an authenticated administrative
|
|
53
|
+
# boundary, this enables only gem commands that pass the CLI context:
|
|
54
|
+
#
|
|
55
|
+
# configuration.authorize_administration = lambda do |authorization_context:, **|
|
|
56
|
+
# authorization_context.is_a?(Hash) && authorization_context[:source] == "cli"
|
|
57
|
+
# end
|
|
30
58
|
end
|
|
@@ -91,9 +91,25 @@ module SolidObjects
|
|
|
91
91
|
# @rbs () -> void
|
|
92
92
|
def deactivate
|
|
93
93
|
actor.deactivate
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
rescue => error
|
|
95
|
+
SolidObjects.instrument(
|
|
96
|
+
:"activation.deactivation_failed",
|
|
97
|
+
instance_id: lease.instance_id,
|
|
98
|
+
actor_type: actor.class.actor_type,
|
|
99
|
+
actor_id: actor.actor_id,
|
|
100
|
+
owner_id: lease.owner_id,
|
|
101
|
+
generation: lease.generation,
|
|
102
|
+
error_class: error.class.name,
|
|
103
|
+
error_message: error.message
|
|
104
|
+
)
|
|
105
|
+
SolidObjects.configuration.logger.error(
|
|
106
|
+
"SolidObjects activation deactivation failed " \
|
|
107
|
+
"actor_type=#{actor.class.actor_type.inspect} " \
|
|
108
|
+
"actor_id=#{actor.actor_id.inspect} " \
|
|
109
|
+
"error_class=#{error.class.name}"
|
|
110
|
+
)
|
|
111
|
+
ensure
|
|
112
|
+
release_lease
|
|
97
113
|
end
|
|
98
114
|
|
|
99
115
|
private
|
|
@@ -129,13 +145,26 @@ module SolidObjects
|
|
|
129
145
|
|
|
130
146
|
# @rbs (Instance) -> Actor
|
|
131
147
|
def build_actor(instance)
|
|
132
|
-
state_data =
|
|
148
|
+
state_data = ApplicationWriteGuard.call(
|
|
149
|
+
actor_type: instance.actor_type,
|
|
150
|
+
actor_id: instance.actor_id,
|
|
151
|
+
operation: "state_migration"
|
|
152
|
+
) do
|
|
153
|
+
actor_class.definition.migrate_state(instance.state_version, instance.state)
|
|
154
|
+
end
|
|
133
155
|
actor_class.new(
|
|
134
156
|
actor_id: instance.actor_id,
|
|
135
157
|
state: State.new(actor_class.definition.state_definition, state_data)
|
|
136
158
|
)
|
|
137
159
|
end
|
|
138
160
|
|
|
161
|
+
# @rbs () -> void
|
|
162
|
+
def release_lease
|
|
163
|
+
lease.release
|
|
164
|
+
rescue LostActivation
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
139
168
|
# @rbs () -> Message?
|
|
140
169
|
def claim_next_message
|
|
141
170
|
lease.fenced_transaction do |instance|
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Actor
|
|
5
5
|
EffectIntent = Data.define(:name, :arguments, :success_message_name, :failure_message_name)
|
|
6
|
+
CommitActionIntent = Data.define(:name, :arguments)
|
|
6
7
|
ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
|
|
7
8
|
OutboundMessageIntent = Data.define(:actor_type, :actor_id, :message_name, :arguments, :available_at, :idempotency_key)
|
|
8
9
|
|
|
@@ -131,6 +132,7 @@ module SolidObjects
|
|
|
131
132
|
# @rbs @actor_id: String
|
|
132
133
|
# @rbs @state: State
|
|
133
134
|
# @rbs @effect_intents: Array[EffectIntent]
|
|
135
|
+
# @rbs @commit_action_intents: Array[CommitActionIntent]
|
|
134
136
|
# @rbs @reminder_intents: Array[ReminderIntent]
|
|
135
137
|
# @rbs @outbound_message_intents: Array[OutboundMessageIntent]
|
|
136
138
|
|
|
@@ -141,6 +143,7 @@ module SolidObjects
|
|
|
141
143
|
@actor_id = actor_id
|
|
142
144
|
@state = state
|
|
143
145
|
@effect_intents = []
|
|
146
|
+
@commit_action_intents = []
|
|
144
147
|
@reminder_intents = []
|
|
145
148
|
@outbound_message_intents = []
|
|
146
149
|
end
|
|
@@ -175,6 +178,17 @@ module SolidObjects
|
|
|
175
178
|
nil
|
|
176
179
|
end
|
|
177
180
|
|
|
181
|
+
# @rbs (Symbol | String, **untyped) -> nil
|
|
182
|
+
def commit_action(name, **arguments)
|
|
183
|
+
CommitActionIntent.new(
|
|
184
|
+
name: name.to_s,
|
|
185
|
+
arguments: Serialization.dump(arguments)
|
|
186
|
+
).tap do |intent|
|
|
187
|
+
commit_action_intents << intent
|
|
188
|
+
end
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
178
192
|
# @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> nil
|
|
179
193
|
def schedule(name, at:, every: nil, missed: :latest, arguments: {})
|
|
180
194
|
interval_seconds = every&.to_f
|
|
@@ -210,24 +224,32 @@ module SolidObjects
|
|
|
210
224
|
self.class.definition.queries[message_name.to_sym]
|
|
211
225
|
raise UnknownMessage, "unknown message #{message_name.inspect} for #{self.class.actor_type}" unless handler
|
|
212
226
|
|
|
213
|
-
|
|
227
|
+
guard_application_writes(message_name.to_s) do
|
|
228
|
+
instance_exec(**keyword_arguments(arguments), &handler.block)
|
|
229
|
+
end
|
|
214
230
|
end
|
|
215
231
|
|
|
216
232
|
# @rbs () -> Hash[String, untyped]
|
|
217
233
|
def observable_values
|
|
218
|
-
|
|
219
|
-
|
|
234
|
+
guard_application_writes("observables") do
|
|
235
|
+
self.class.definition.observables.each_with_object({}) do |(name, handler), values|
|
|
236
|
+
values[name.to_s] = Serialization.dump(instance_exec(&handler.block))
|
|
237
|
+
end
|
|
220
238
|
end
|
|
221
239
|
end
|
|
222
240
|
|
|
223
241
|
# @rbs () -> void
|
|
224
242
|
def activate
|
|
225
|
-
|
|
243
|
+
guard_application_writes("on_activate") do
|
|
244
|
+
self.class.definition.activation_hooks.each { |hook| instance_exec(&hook) }
|
|
245
|
+
end
|
|
226
246
|
end
|
|
227
247
|
|
|
228
248
|
# @rbs () -> void
|
|
229
249
|
def deactivate
|
|
230
|
-
|
|
250
|
+
guard_application_writes("on_deactivate") do
|
|
251
|
+
self.class.definition.deactivation_hooks.each { |hook| instance_exec(&hook) }
|
|
252
|
+
end
|
|
231
253
|
end
|
|
232
254
|
|
|
233
255
|
# @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
|
|
@@ -247,6 +269,11 @@ module SolidObjects
|
|
|
247
269
|
effect_intents.shift(effect_intents.length)
|
|
248
270
|
end
|
|
249
271
|
|
|
272
|
+
# @rbs () -> Array[CommitActionIntent]
|
|
273
|
+
def drain_commit_action_intents
|
|
274
|
+
commit_action_intents.shift(commit_action_intents.length)
|
|
275
|
+
end
|
|
276
|
+
|
|
250
277
|
# @rbs () -> Array[ReminderIntent]
|
|
251
278
|
def drain_reminder_intents
|
|
252
279
|
reminder_intents.shift(reminder_intents.length)
|
|
@@ -260,13 +287,27 @@ module SolidObjects
|
|
|
260
287
|
# @rbs () -> void
|
|
261
288
|
def discard_intents
|
|
262
289
|
effect_intents.clear
|
|
290
|
+
commit_action_intents.clear
|
|
263
291
|
reminder_intents.clear
|
|
264
292
|
outbound_message_intents.clear
|
|
265
293
|
end
|
|
266
294
|
|
|
267
295
|
private
|
|
268
296
|
|
|
269
|
-
attr_reader :effect_intents,
|
|
297
|
+
attr_reader :effect_intents,
|
|
298
|
+
:commit_action_intents,
|
|
299
|
+
:reminder_intents,
|
|
300
|
+
:outbound_message_intents
|
|
301
|
+
|
|
302
|
+
# @rbs (String) { () -> untyped } -> untyped
|
|
303
|
+
def guard_application_writes(operation, &block)
|
|
304
|
+
ApplicationWriteGuard.call(
|
|
305
|
+
actor_type: self.class.actor_type,
|
|
306
|
+
actor_id:,
|
|
307
|
+
operation:,
|
|
308
|
+
&block
|
|
309
|
+
)
|
|
310
|
+
end
|
|
270
311
|
|
|
271
312
|
# @rbs (Hash[String, untyped]) -> Hash[Symbol, untyped]
|
|
272
313
|
def keyword_arguments(arguments)
|
|
@@ -29,10 +29,16 @@ module SolidObjects
|
|
|
29
29
|
actor_id: reference.actor_id
|
|
30
30
|
)
|
|
31
31
|
state_version = instance&.state_version || actor_class.state_version
|
|
32
|
-
state_data =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
state_data = ApplicationWriteGuard.call(
|
|
33
|
+
actor_type: reference.actor_type,
|
|
34
|
+
actor_id: reference.actor_id,
|
|
35
|
+
operation: "state_migration"
|
|
36
|
+
) do
|
|
37
|
+
actor_class.definition.migrate_state(
|
|
38
|
+
state_version,
|
|
39
|
+
instance&.state || {}
|
|
40
|
+
)
|
|
41
|
+
end
|
|
36
42
|
actor_class.new(
|
|
37
43
|
actor_id: reference.actor_id,
|
|
38
44
|
state: State.new(actor_class.definition.state_definition, state_data)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ApplicationWriteGuard
|
|
5
|
+
class << self
|
|
6
|
+
# @rbs (actor_type: String, actor_id: String, operation: String) { () -> untyped } -> untyped
|
|
7
|
+
def call(actor_type:, actor_id:, operation:)
|
|
8
|
+
ActiveRecord::Base.while_preventing_writes { yield }
|
|
9
|
+
rescue ActiveRecord::ReadOnlyError
|
|
10
|
+
SolidObjects.instrument(
|
|
11
|
+
:"actor_code.write_forbidden",
|
|
12
|
+
actor_type:,
|
|
13
|
+
actor_id:,
|
|
14
|
+
operation:
|
|
15
|
+
)
|
|
16
|
+
raise ApplicationWriteForbidden.new(
|
|
17
|
+
actor_type:,
|
|
18
|
+
actor_id:,
|
|
19
|
+
message_name: operation
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -5,12 +5,14 @@ module SolidObjects
|
|
|
5
5
|
# @rbs @mutex: Thread::Mutex
|
|
6
6
|
# @rbs @process_id: Integer?
|
|
7
7
|
# @rbs @registry: ProcessRegistry?
|
|
8
|
+
# @rbs @shutdown_hook_installed: bool
|
|
8
9
|
|
|
9
10
|
# @rbs () -> void
|
|
10
11
|
def initialize
|
|
11
12
|
@mutex = Thread::Mutex.new
|
|
12
13
|
@process_id = nil
|
|
13
14
|
@registry = nil
|
|
15
|
+
@shutdown_hook_installed = false
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
# @rbs () -> ProcessRegistry
|
|
@@ -18,11 +20,22 @@ module SolidObjects
|
|
|
18
20
|
mutex.synchronize do
|
|
19
21
|
reset_after_fork
|
|
20
22
|
register unless reusable_registry?
|
|
23
|
+
install_shutdown_hook
|
|
21
24
|
registry.heartbeat
|
|
22
25
|
registry
|
|
23
26
|
end
|
|
24
27
|
end
|
|
25
28
|
|
|
29
|
+
# @rbs () -> bool
|
|
30
|
+
def stop
|
|
31
|
+
mutex.synchronize do
|
|
32
|
+
return false unless @process_id == ::Process.pid
|
|
33
|
+
return false unless registry&.process_record
|
|
34
|
+
|
|
35
|
+
registry.stop.tap { @registry = nil }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
26
39
|
private
|
|
27
40
|
|
|
28
41
|
attr_reader :mutex, :registry
|
|
@@ -53,5 +66,20 @@ module SolidObjects
|
|
|
53
66
|
)
|
|
54
67
|
registry
|
|
55
68
|
end
|
|
69
|
+
|
|
70
|
+
# @rbs () -> void
|
|
71
|
+
def install_shutdown_hook
|
|
72
|
+
return if @shutdown_hook_installed
|
|
73
|
+
|
|
74
|
+
@shutdown_hook_installed = true
|
|
75
|
+
at_exit { stop_after_exit }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @rbs () -> bool
|
|
79
|
+
def stop_after_exit
|
|
80
|
+
stop
|
|
81
|
+
rescue
|
|
82
|
+
false
|
|
83
|
+
end
|
|
56
84
|
end
|
|
57
85
|
end
|
data/lib/solid_objects/cli.rb
CHANGED
|
@@ -34,7 +34,7 @@ module SolidObjects
|
|
|
34
34
|
# @rbs () -> void
|
|
35
35
|
def status
|
|
36
36
|
boot_application
|
|
37
|
-
authorize_administration!(:status)
|
|
37
|
+
authorize_administration!(:status, resource: "processes")
|
|
38
38
|
rows = SolidObjects::Process.order(:kind, :started_at).map do |process_record|
|
|
39
39
|
{
|
|
40
40
|
id: process_record.id,
|
|
@@ -54,10 +54,49 @@ module SolidObjects
|
|
|
54
54
|
# @rbs () -> void
|
|
55
55
|
def cleanup
|
|
56
56
|
boot_application
|
|
57
|
-
authorize_administration!(:cleanup)
|
|
57
|
+
authorize_administration!(:cleanup, resource: "processes")
|
|
58
58
|
puts JSON.generate(cleaned_processes: ProcessRegistry.cleanup_dead)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
desc "prune_messages", "Preview or delete expired terminal message history"
|
|
62
|
+
option :environment, type: :string, aliases: "-e"
|
|
63
|
+
option :execute, type: :boolean, default: false
|
|
64
|
+
|
|
65
|
+
# @rbs () -> void
|
|
66
|
+
def prune_messages
|
|
67
|
+
boot_application
|
|
68
|
+
authorize_administration!(:prune, resource: "messages")
|
|
69
|
+
pruner = MessagePruner.new
|
|
70
|
+
count = options[:execute] ? pruner.prune : pruner.preview
|
|
71
|
+
puts JSON.generate(mode: options[:execute] ? "execute" : "preview", messages: count)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
desc "prune_instances", "Preview or delete expired idle actor instances"
|
|
75
|
+
option :environment, type: :string, aliases: "-e"
|
|
76
|
+
option :execute, type: :boolean, default: false
|
|
77
|
+
|
|
78
|
+
# @rbs () -> void
|
|
79
|
+
def prune_instances
|
|
80
|
+
boot_application
|
|
81
|
+
authorize_administration!(:prune, resource: "instances")
|
|
82
|
+
pruner = InstancePruner.new
|
|
83
|
+
count = options[:execute] ? pruner.prune : pruner.preview
|
|
84
|
+
puts JSON.generate(mode: options[:execute] ? "execute" : "preview", instances: count)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
desc "prune_processes", "Preview or delete expired stopped process records"
|
|
88
|
+
option :environment, type: :string, aliases: "-e"
|
|
89
|
+
option :execute, type: :boolean, default: false
|
|
90
|
+
|
|
91
|
+
# @rbs () -> void
|
|
92
|
+
def prune_processes
|
|
93
|
+
boot_application
|
|
94
|
+
authorize_administration!(:prune, resource: "processes")
|
|
95
|
+
pruner = ProcessPruner.new
|
|
96
|
+
count = options[:execute] ? pruner.prune : pruner.preview
|
|
97
|
+
puts JSON.generate(mode: options[:execute] ? "execute" : "preview", processes: count)
|
|
98
|
+
end
|
|
99
|
+
|
|
61
100
|
desc "dead_letters", "Print dead actor messages"
|
|
62
101
|
option :environment, type: :string, aliases: "-e"
|
|
63
102
|
|
|
@@ -102,11 +141,11 @@ module SolidObjects
|
|
|
102
141
|
value ? Integer(value) : default
|
|
103
142
|
end
|
|
104
143
|
|
|
105
|
-
# @rbs (Symbol) -> void
|
|
106
|
-
def authorize_administration!(action)
|
|
144
|
+
# @rbs (Symbol, resource: String) -> void
|
|
145
|
+
def authorize_administration!(action, resource:)
|
|
107
146
|
authorized = SolidObjects.configuration.authorize_administration.call(
|
|
108
147
|
action:,
|
|
109
|
-
resource
|
|
148
|
+
resource:,
|
|
110
149
|
resource_id: nil,
|
|
111
150
|
authorization_context: { source: "cli" }
|
|
112
151
|
)
|