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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +129 -8
  4. data/docs/architecture.md +62 -15
  5. data/docs/authorization.md +16 -3
  6. data/docs/benchmarks.md +6 -2
  7. data/docs/correctness.md +19 -1
  8. data/docs/database-schema.md +5 -0
  9. data/docs/development.md +40 -0
  10. data/docs/fit.md +10 -2
  11. data/docs/migrating-existing-state.md +8 -1
  12. data/docs/operations.md +74 -36
  13. data/docs/roadmap.md +8 -2
  14. data/docs/security.md +25 -3
  15. data/docs/state-migrations.md +2 -0
  16. data/lib/generators/solid_objects/templates/solid_objects.rb +30 -2
  17. data/lib/solid_objects/activation.rb +33 -4
  18. data/lib/solid_objects/actor.rb +47 -6
  19. data/lib/solid_objects/actor_definition.rb +2 -0
  20. data/lib/solid_objects/actor_snapshot.rb +10 -4
  21. data/lib/solid_objects/application_write_guard.rb +24 -0
  22. data/lib/solid_objects/caller_process.rb +28 -0
  23. data/lib/solid_objects/cli.rb +44 -5
  24. data/lib/solid_objects/client.rb +98 -5
  25. data/lib/solid_objects/commit_action_registry.rb +42 -0
  26. data/lib/solid_objects/configuration.rb +27 -1
  27. data/lib/solid_objects/database_adapter.rb +28 -1
  28. data/lib/solid_objects/database_adapters/mysql.rb +46 -0
  29. data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
  30. data/lib/solid_objects/database_adapters/sqlite.rb +28 -0
  31. data/lib/solid_objects/errors.rb +144 -0
  32. data/lib/solid_objects/executor.rb +64 -4
  33. data/lib/solid_objects/instance_pruner.rb +97 -0
  34. data/lib/solid_objects/message_pruner.rb +97 -0
  35. data/lib/solid_objects/message_reference.rb +9 -0
  36. data/lib/solid_objects/process_pruner.rb +49 -0
  37. data/lib/solid_objects/reference.rb +5 -0
  38. data/lib/solid_objects/state_snapshot.rb +41 -0
  39. data/lib/solid_objects/sync_deadline.rb +57 -0
  40. data/lib/solid_objects/sync_diagnostics.rb +133 -0
  41. data/lib/solid_objects/synchronous_invocation.rb +26 -7
  42. data/lib/solid_objects/test_helper.rb +78 -0
  43. data/lib/solid_objects/version.rb +1 -1
  44. data/lib/solid_objects/worker.rb +1 -1
  45. data/lib/solid_objects.rb +34 -0
  46. data/sig/generated/lib/solid_objects/activation.rbs +3 -0
  47. data/sig/generated/lib/solid_objects/actor.rbs +26 -0
  48. data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
  49. data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
  50. data/sig/generated/lib/solid_objects/cli.rbs +11 -2
  51. data/sig/generated/lib/solid_objects/client.rbs +15 -0
  52. data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
  53. data/sig/generated/lib/solid_objects/configuration.rbs +27 -7
  54. data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
  55. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
  56. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  57. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +8 -0
  58. data/sig/generated/lib/solid_objects/errors.rbs +118 -0
  59. data/sig/generated/lib/solid_objects/executor.rbs +12 -0
  60. data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
  61. data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
  62. data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
  63. data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
  64. data/sig/generated/lib/solid_objects/reference.rbs +3 -0
  65. data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
  66. data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
  67. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
  68. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
  69. data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
  70. data/sig/generated/lib/solid_objects.rbs +12 -0
  71. 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
- The schema has cleanup indexes, but automatic pruning commands are still
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.
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
- An application-owned pruning job can start from this conservative relation:
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
- 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
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
- 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.
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 bounded retention/pruning commands and result lookup by request ID.
47
- 4. Add deadlock, lock-timeout, and SQLite-busy retry classification.
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 and attribute methods use the separate
17
- query authorization policy. Explicit `async` message delivery uses the same
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
@@ -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 operational
15
- # commands. Keep the last three denied until their callers are authenticated.
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
- lease.release
95
- rescue LostActivation
96
- nil
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 = actor_class.definition.migrate_state(instance.state_version, instance.state)
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|
@@ -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
- instance_exec(**keyword_arguments(arguments), &handler.block)
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
- self.class.definition.observables.each_with_object({}) do |(name, handler), values|
219
- values[name.to_s] = Serialization.dump(instance_exec(&handler.block))
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
- self.class.definition.activation_hooks.each { |hook| instance_exec(&hook) }
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
- self.class.definition.deactivation_hooks.each { |hook| instance_exec(&hook) }
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, :reminder_intents, :outbound_message_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)
@@ -136,6 +136,8 @@ module SolidObjects
136
136
  migrated
137
137
  rescue StateMigrationError
138
138
  raise
139
+ rescue ActiveRecord::ReadOnlyError
140
+ raise
139
141
  rescue => error
140
142
  raise StateMigrationError, "state migration failed: #{error.message}"
141
143
  end
@@ -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 = actor_class.definition.migrate_state(
33
- state_version,
34
- instance&.state || {}
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
@@ -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: "processes",
148
+ resource:,
110
149
  resource_id: nil,
111
150
  authorization_context: { source: "cli" }
112
151
  )