solid_objects 0.2.0 → 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 +23 -0
- data/README.md +223 -15
- data/benchmark/adoption_latency.rb +5 -0
- data/benchmark/support.rb +35 -0
- data/docs/architecture.md +62 -15
- data/docs/authorization.md +98 -0
- data/docs/benchmarks.md +75 -1
- data/docs/correctness.md +19 -1
- data/docs/database-schema.md +5 -0
- data/docs/development.md +45 -4
- data/docs/fit.md +98 -0
- data/docs/migrating-existing-state.md +140 -0
- data/docs/operations.md +94 -4
- data/docs/roadmap.md +12 -3
- data/docs/security.md +28 -3
- data/docs/state-migrations.md +6 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +45 -0
- 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/doctor.rb +311 -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/lib/tasks/solid_objects_tasks.rake +10 -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/doctor.rbs +111 -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 +26 -1
data/docs/roadmap.md
CHANGED
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
- One-shot and recurring reminders with `:latest` or `:all` catch-up
|
|
18
18
|
- Durable observable broadcast outbox and authorized Action Cable refresh
|
|
19
19
|
- Reconciliation read APIs
|
|
20
|
+
- Installation doctor, authorization reference, fit guide, and legacy-state
|
|
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
|
|
20
28
|
- SQLite, PostgreSQL, and MySQL integration suites
|
|
21
29
|
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
|
|
22
30
|
RuboCop policy, and a warning-free Brakeman scan
|
|
@@ -41,8 +49,8 @@
|
|
|
41
49
|
1. Add automatic supervisor role replacement and periodic dead-process cleanup.
|
|
42
50
|
2. Add PostgreSQL notification and optional Redis wake-up adapters with latency
|
|
43
51
|
benchmarks and polling-race tests.
|
|
44
|
-
3. Add
|
|
45
|
-
4. Add
|
|
52
|
+
3. Add result lookup by request ID and broader deadlock retry classification.
|
|
53
|
+
4. Add scheduled retention and stale-process maintenance.
|
|
46
54
|
5. Add database/server-version checks and MySQL InnoDB verification at boot.
|
|
47
55
|
6. Add component broadcast rendering, Turbo append intents, and reconnect tests
|
|
48
56
|
in a full browser.
|
|
@@ -50,7 +58,8 @@
|
|
|
50
58
|
eviction.
|
|
51
59
|
8. Expand security scanning and run compatibility CI across supported Rails and
|
|
52
60
|
Ruby versions.
|
|
53
|
-
9. Benchmark all workloads under documented hardware/database settings
|
|
61
|
+
9. Benchmark all workloads under documented hardware/database settings and
|
|
62
|
+
publish adapter-specific adoption measurements.
|
|
54
63
|
|
|
55
64
|
No production-ready claim should be made until these hardening milestones have
|
|
56
65
|
operational soak evidence.
|
data/docs/security.md
CHANGED
|
@@ -7,12 +7,17 @@ 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
15
|
delegate to the authorized synchronous invocation path. Keep implementation
|
|
13
|
-
helpers private or protected. Query
|
|
14
|
-
query authorization policy. Explicit `async`
|
|
15
|
-
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.
|
|
16
21
|
`reference.destroy` delegates to `authorize_destroy` before checking whether
|
|
17
22
|
the actor exists, so denial does not reveal actor existence.
|
|
18
23
|
|
|
@@ -51,10 +56,30 @@ host authentication and audit their use.
|
|
|
51
56
|
Instrumentation excludes arguments, state, results, and effect payloads by
|
|
52
57
|
default. Review custom logging and effect handlers for accidental disclosure.
|
|
53
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
|
+
|
|
54
74
|
Actor destruction is not an administrative shortcut. Authorize tenancy and
|
|
55
75
|
ownership explicitly in `authorize_destroy`; knowledge of an actor ID is never
|
|
56
76
|
permission to delete its state or queued work.
|
|
57
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
|
+
|
|
58
83
|
## Denial of service
|
|
59
84
|
|
|
60
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
|
|
|
@@ -44,3 +46,7 @@ A safe destructive rollout normally uses:
|
|
|
44
46
|
|
|
45
47
|
Never update actor JSON in a bulk SQL migration. Use actor messages so fencing,
|
|
46
48
|
ordering, observables, and outboxes remain intact.
|
|
49
|
+
|
|
50
|
+
This guide covers evolution after state belongs to Solid Objects. For moving
|
|
51
|
+
existing Redis, key-value, or relational state into actors without downtime,
|
|
52
|
+
use the [legacy-state migration cookbook](migrating-existing-state.md).
|
|
@@ -5,9 +5,54 @@ 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
|
|
28
|
+
|
|
29
|
+
# Every policy denies by default, so a fresh installation is intentionally
|
|
30
|
+
# inert. Replace these policies before invoking actors.
|
|
31
|
+
#
|
|
32
|
+
# Message and query policies gate direct calls, sync, async, and state reads.
|
|
33
|
+
# Destroy removes an actor and all of its durable work. Subscription gates
|
|
34
|
+
# Action Cable streams. Administration gates engine pages, pruning, and
|
|
35
|
+
# operational commands. Keep the last three denied until their callers are
|
|
36
|
+
# authenticated.
|
|
37
|
+
#
|
|
38
|
+
# Prefer policies that bind actor_type and actor_id to a trusted
|
|
39
|
+
# authorization_context. See:
|
|
40
|
+
# https://github.com/cardmagic/solid_objects/blob/main/docs/authorization.md
|
|
41
|
+
# and run:
|
|
42
|
+
#
|
|
43
|
+
# bin/rails solid_objects:doctor
|
|
44
|
+
#
|
|
45
|
+
# after configuring the application.
|
|
8
46
|
configuration.authorize_message = ->(**) { false }
|
|
9
47
|
configuration.authorize_query = ->(**) { false }
|
|
10
48
|
configuration.authorize_destroy = ->(**) { false }
|
|
11
49
|
configuration.authorize_subscription = ->(**) { false }
|
|
12
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
|
|
13
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
|
)
|
data/lib/solid_objects/client.rb
CHANGED
|
@@ -50,16 +50,57 @@ module SolidObjects
|
|
|
50
50
|
arguments,
|
|
51
51
|
authorization_context:
|
|
52
52
|
)
|
|
53
|
-
|
|
53
|
+
reject_sync_inside_transaction!(reference, message_name)
|
|
54
|
+
SyncDeadline.with(timeout:) do
|
|
55
|
+
message_reference = enqueue_sync(
|
|
56
|
+
reference,
|
|
57
|
+
message_name,
|
|
58
|
+
arguments,
|
|
59
|
+
idempotency_key:,
|
|
60
|
+
timeout:
|
|
61
|
+
)
|
|
62
|
+
SynchronousInvocation.new.call(message_reference, timeout:)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @rbs (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped
|
|
67
|
+
def wait(message_reference, timeout:, authorization_context: nil)
|
|
68
|
+
message = Message.find(message_reference.id)
|
|
69
|
+
validate_message_reference!(message_reference, message)
|
|
70
|
+
reference = Reference.new(
|
|
71
|
+
actor_type: message.actor_type,
|
|
72
|
+
actor_id: message.actor_id
|
|
73
|
+
)
|
|
74
|
+
actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
75
|
+
query = actor_class.definition.queries.key?(message.message_name.to_sym)
|
|
76
|
+
actor_message = actor_class.definition.messages.key?(message.message_name.to_sym)
|
|
77
|
+
unless query || actor_message
|
|
78
|
+
raise UnknownMessage, "unknown message #{message.message_name.inspect}"
|
|
79
|
+
end
|
|
80
|
+
authorize!(
|
|
81
|
+
query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
|
|
54
82
|
reference,
|
|
55
|
-
message_name,
|
|
56
|
-
arguments,
|
|
57
|
-
|
|
58
|
-
idempotency_key:
|
|
83
|
+
message.message_name,
|
|
84
|
+
message.arguments,
|
|
85
|
+
authorization_context:
|
|
59
86
|
)
|
|
87
|
+
reject_sync_inside_transaction!(reference, message.message_name)
|
|
60
88
|
SynchronousInvocation.new.call(message_reference, timeout:)
|
|
61
89
|
end
|
|
62
90
|
|
|
91
|
+
# @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot
|
|
92
|
+
def snapshot(reference, authorization_context: nil)
|
|
93
|
+
SolidObjects.registry.fetch(reference.actor_type)
|
|
94
|
+
authorize!(
|
|
95
|
+
SolidObjects.configuration.authorize_query,
|
|
96
|
+
reference,
|
|
97
|
+
"__snapshot__",
|
|
98
|
+
{},
|
|
99
|
+
authorization_context:
|
|
100
|
+
)
|
|
101
|
+
StateSnapshot.new(reference)
|
|
102
|
+
end
|
|
103
|
+
|
|
63
104
|
# @rbs (Reference, ?authorization_context: untyped) -> bool
|
|
64
105
|
def destroy(reference, authorization_context: nil)
|
|
65
106
|
raise ActorCallCycle, "actors cannot synchronously destroy another actor" if Context.current_actor
|
|
@@ -93,6 +134,58 @@ module SolidObjects
|
|
|
93
134
|
|
|
94
135
|
attr_reader :mailbox
|
|
95
136
|
|
|
137
|
+
# @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference
|
|
138
|
+
def enqueue_sync(reference, message_name, arguments, idempotency_key:, timeout:)
|
|
139
|
+
mailbox.enqueue(
|
|
140
|
+
reference,
|
|
141
|
+
message_name,
|
|
142
|
+
arguments,
|
|
143
|
+
kind: "sync",
|
|
144
|
+
idempotency_key:
|
|
145
|
+
)
|
|
146
|
+
rescue DatabaseDeadlineExceeded
|
|
147
|
+
SolidObjects.instrument(
|
|
148
|
+
:"sync.enqueue_timeout",
|
|
149
|
+
actor_type: reference.actor_type,
|
|
150
|
+
actor_id: reference.actor_id,
|
|
151
|
+
message_name: message_name.to_s
|
|
152
|
+
)
|
|
153
|
+
raise SyncEnqueueTimeout.new(
|
|
154
|
+
timeout:,
|
|
155
|
+
actor_type: reference.actor_type,
|
|
156
|
+
actor_id: reference.actor_id,
|
|
157
|
+
message_name: message_name.to_s
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @rbs (MessageReference, Message) -> void
|
|
162
|
+
def validate_message_reference!(message_reference, message)
|
|
163
|
+
valid = message_reference.request_id == message.request_id &&
|
|
164
|
+
message_reference.actor_type == message.actor_type &&
|
|
165
|
+
message_reference.actor_id == message.actor_id &&
|
|
166
|
+
message_reference.sequence == message.sequence
|
|
167
|
+
return if valid
|
|
168
|
+
|
|
169
|
+
raise ActorDestroyed, "message reference no longer identifies this invocation"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @rbs (Reference, Symbol | String) -> void
|
|
173
|
+
def reject_sync_inside_transaction!(reference, message_name)
|
|
174
|
+
return unless SolidObjects::Record.connection.transaction_open?
|
|
175
|
+
|
|
176
|
+
SolidObjects.instrument(
|
|
177
|
+
:"sync.transaction_rejected",
|
|
178
|
+
actor_type: reference.actor_type,
|
|
179
|
+
actor_id: reference.actor_id,
|
|
180
|
+
message_name: message_name.to_s
|
|
181
|
+
)
|
|
182
|
+
raise SyncInsideTransaction.new(
|
|
183
|
+
actor_type: reference.actor_type,
|
|
184
|
+
actor_id: reference.actor_id,
|
|
185
|
+
message_name: message_name.to_s
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
96
189
|
# @rbs (Proc, Reference, Symbol | String, Hash[Symbol | String, untyped], authorization_context: untyped) -> void
|
|
97
190
|
def authorize!(hook, reference, message_name, arguments, authorization_context:)
|
|
98
191
|
authorized = hook.call(
|