solid_objects 0.10.2 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cd348d089d12af0c4ea20a5f163d58b69f4796e7741ed12038301139396baae
4
- data.tar.gz: e31ad3836b5782ee13f3669eef3645114498e7133dd5f8c561105310f4452867
3
+ metadata.gz: 530d40a11cbf715cc3657191124d336798d4e8efdccb129d8ee0431400ec36ff
4
+ data.tar.gz: 39abe950312fa41ab57aa55e7e01a088a01eb63dcfd535e0ada712ae75c03b01
5
5
  SHA512:
6
- metadata.gz: 470ca38a3faacd28a05a8f3cd020a353a1a20a9717aad29ec7c0f6cb9be6efbb6e44ef96abcc5a12c2b151b112cdbda47df73da361a94a26ff275bb0dd7d966b
7
- data.tar.gz: 874fa30b34175212a24ecdc115e4fd187c40f46c0e55ff62def214f5b350a54997b738f37a86991a9c032f7e1b7ec017613aa0cebaa6378c90aa0c02d6457fb5
6
+ metadata.gz: edd288ba3cc57f8ac7c241eda9e3e320385f26071a5dc1e11e3676af1106dc1ddadddfa520ff08f4ce61a0b2f73c23d1a2418199c8f6f98ebe85fe2b51b07426
7
+ data.tar.gz: 38e5561275048f00dcbd5e363b0d3da6a00242348094776c63da44df7e2ca694292e7bc0546d44ac5da7a8883eb70d3e237d7495c888d6d3e2dd9d286d1754f0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 - 2026-08-11
4
+
5
+ - Add `SolidObjects.configuration.register_component`. An extension gem can now
6
+ register a long running component, and the supervisor runs it beside the
7
+ workers, the effect executors, the broadcast executors, and the reminder
8
+ schedulers. The component joins the same supervision, replacement, and
9
+ shutdown timeout. Without it, an extension has to ask an operator to run and
10
+ monitor a second process for work that belongs to the same runtime. A
11
+ registered component must answer `run`, `request_shutdown`, `stopped?`, and
12
+ `stop`. The supervisor checks that contract when it builds the component and
13
+ raises `ArgumentError` when a method is missing. Registration never calls the
14
+ block, so a component may need a database connection that the application
15
+ does not have while it boots.
16
+
17
+ - Stop the components already built when a later one fails. The supervisor
18
+ builds its components one after another, so a factory that raised, or a
19
+ component that failed the contract check, left the earlier ones constructed
20
+ and unreachable while they still held whatever their constructors took. Each
21
+ one now receives `stop`, and a failure inside that cleanup never replaces the
22
+ failure that caused it.
23
+
24
+ - Replace a crashed component through the builder that made it. The supervisor
25
+ called `component.class.new`, which discards every constructor argument, so a
26
+ component built with arguments returned with its defaults after a crash. Each
27
+ component now keeps its builder. The built in components take no constructor
28
+ arguments, so their behavior does not change.
29
+
30
+ ## 0.10.3 - 2026-08-11
31
+
32
+ - Delete every actor-owned row in `SolidObjects::TestHelper#reset_actors!`. It
33
+ deleted actor instances and processes and left the other seven tables to the
34
+ database cascade. That cascade is not enforced everywhere: SQLite has to be
35
+ asked for foreign keys, MySQL has to be on InnoDB, and a host application may
36
+ have stripped the constraints out of the copied migration. Where it does not
37
+ fire, messages, ready and claimed mailbox rows, reminders, effects,
38
+ broadcasts, and dead letters all survived into the next test with an
39
+ `instance_id` pointing at nothing, so a test reading any of them saw another
40
+ test's rows and failed depending on order. Reported as reminders leaking,
41
+ which is where it surfaces first because reminders outlive the message that
42
+ created them.
43
+ - Document that a reminder is one named alarm per actor. `schedule` is keyed by
44
+ actor and reminder name, so scheduling a name that is already armed moves
45
+ that alarm rather than adding a second. The behaviour is deliberate and
46
+ matches Orleans and Durable Objects, but it was stated nowhere: an actor that
47
+ armed one reminder per queued item silently kept only the last, and the
48
+ earlier wake-ups never happened. The reminders guide now states the
49
+ uniqueness key and shows the one-alarm-many-items pattern to use instead.
50
+ - Add `solid_objects.reminder.replaced`, reported when a `schedule` call moves
51
+ an alarm already armed under the same name to a different time. It carries
52
+ the actor identity, reminder name, previous run time, and next run time, and
53
+ no arguments. Rescheduling to the same time reports nothing. The replacement
54
+ was previously indistinguishable from a first schedule.
55
+
3
56
  ## 0.10.2 - 2026-08-10
4
57
 
5
58
  - Load the mailbox when the gem is required. `SolidObjects::Mailbox` was
data/README.md CHANGED
@@ -458,6 +458,39 @@ Deploy and monitor that process before enabling any feature marked as requiring
458
458
  a runtime role. A missing worker never makes a durable `async` message
459
459
  disappear, but it leaves the message pending indefinitely.
460
460
 
461
+ ### Running an extension in the same process
462
+
463
+ An extension gem can register its own long-running component, and
464
+ `solid_objects start` runs it beside the built-in roles. The component joins the
465
+ same supervision, the same replacement after a crash, and the same shutdown
466
+ timeout, so an operator deploys and monitors one process instead of two:
467
+
468
+ ```ruby
469
+ SolidObjects.configure do |configuration|
470
+ configuration.register_component { MyExtension::FlushEngine.new }
471
+ end
472
+ ```
473
+
474
+ Pass `count:` for more than one instance. The block runs once for each instance,
475
+ and again when the supervisor replaces a crashed one, so no two components share
476
+ an object.
477
+
478
+ A registered component answers four methods, the contract the built-in roles
479
+ already keep:
480
+
481
+ | Method | Purpose |
482
+ | --- | --- |
483
+ | `run` | Runs the loop. The supervisor calls it in its own thread |
484
+ | `request_shutdown` | Asks the loop to finish. It must make `run` return |
485
+ | `stopped?` | Reports whether the component already finished |
486
+ | `stop` | Forces cleanup when the shutdown timeout expires first |
487
+
488
+ The supervisor checks that contract when it builds the component, and a missing
489
+ method raises `ArgumentError` as the supervisor starts, rather than hanging a
490
+ shutdown later. Registration itself never calls the block, so a component is
491
+ free to need a database connection that the application does not have while it
492
+ boots.
493
+
461
494
  ## Defining an actor
462
495
 
463
496
  The Durable Object class becomes an ordinary Ruby class:
@@ -805,6 +838,62 @@ end
805
838
  Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
806
839
  enqueue each one.
807
840
 
841
+ ### A reminder is one named alarm per actor
842
+
843
+ The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
844
+ already armed **moves the existing alarm** rather than adding a second one. The
845
+ database enforces this with a unique index on `(instance_id, name)`.
846
+
847
+ This is the same model as Orleans reminders and Durable Objects alarms, and it
848
+ is what makes a reminder safe to re-arm from a handler that may run more than
849
+ once. It also means this is a data-loss bug:
850
+
851
+ ```ruby
852
+ # Wrong. Every entry overwrites the previous entry's alarm.
853
+ def add(entry:)
854
+ self.entries = entries + [ entry ]
855
+ schedule :deliver, at: entry.fetch("wait_until"), arguments: {}
856
+ end
857
+ ```
858
+
859
+ Two entries leave one reminder. The earlier wake-up never happens, nothing
860
+ raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
861
+
862
+ Arm one alarm for the earliest item instead, and let the handler drain
863
+ everything now due before arming the next:
864
+
865
+ ```ruby
866
+ def add(entry:)
867
+ self.entries = (entries + [ entry ]).sort_by { |item| item.fetch("wait_until") }
868
+ arm_next
869
+ end
870
+
871
+ def deliver
872
+ now = Time.current.to_i
873
+ due, pending = entries.partition { |item| item.fetch("wait_until") <= now }
874
+ due.each { |item| emit :send_push, **item.symbolize_keys }
875
+ self.entries = pending
876
+ arm_next
877
+ end
878
+
879
+ private
880
+
881
+ def arm_next
882
+ earliest = entries.first
883
+ return unless earliest
884
+
885
+ schedule :deliver, at: Time.at(earliest.fetch("wait_until")), arguments: {}
886
+ end
887
+ ```
888
+
889
+ `deliver` drains every due item rather than one, so a single alarm serves a
890
+ whole queue and a missed or coalesced occurrence cannot strand an entry. Use a
891
+ distinct reminder name only when you genuinely need two independent alarms on
892
+ one actor, such as `:deliver` and `:sweep`.
893
+
894
+ Solid Objects has no `unschedule`. A reminder stops when its handler does not
895
+ re-arm it, and destroying an actor removes its reminders.
896
+
808
897
  Self-scheduling actors should also have a low-frequency application reconciler.
809
898
  It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
810
899
  `.orphaned`, but every repair must go through `async`. Never bulk-update actor
data/docs/architecture.md CHANGED
@@ -439,6 +439,8 @@ A reminder record contains actor identity, a reminder name, target message, JSON
439
439
  schedule :expire, at: 30.minutes.from_now, arguments: {}
440
440
  ```
441
441
 
442
+ Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](../README.md#a-reminder-is-one-named-alarm-per-actor) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
443
+
442
444
  When due, the scheduler locks the source instance and creates a normal mailbox
443
445
  row with an idempotency key derived from reminder ID and occurrence. The
444
446
  mailbox insert and reminder advancement commit atomically. The mailbox provides
@@ -77,7 +77,8 @@ Indexes:
77
77
  ### `reminders`
78
78
 
79
79
  Durable one-shot or recurring alarm definitions. Unique instance/name makes
80
- rescheduling an actor-owned reminder deterministic. Status/next-run/ID drives
80
+ rescheduling an actor-owned reminder deterministic, which also means a second
81
+ `schedule` under the same name moves that alarm rather than adding one. Status/next-run/ID drives
81
82
  the due scan. Claim ownership references the process registry, and constraints
82
83
  limit recurrence intervals, missed-work policies, and statuses.
83
84
 
data/docs/development.md CHANGED
@@ -66,7 +66,13 @@ Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
66
66
  reminders pending.
67
67
 
68
68
  `SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
69
- boundaries.
69
+ boundaries. It deletes every actor-owned row itself rather than deleting actor
70
+ instances and letting the database cascade remove the rest: SQLite has to be
71
+ asked for foreign keys, MySQL has to be on InnoDB, and a host application may
72
+ have stripped the constraints out of the copied migration. Where the cascade
73
+ does not fire, a row that survives a reset carries an `instance_id` pointing at
74
+ nothing, and the next test that reads reminders or dead letters sees another
75
+ test's data.
70
76
 
71
77
  ## Inline RBS
72
78
 
data/docs/operations.md CHANGED
@@ -155,6 +155,15 @@ transaction rejection, commit-action start/completion/failure, effect and
155
155
  broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
156
156
  retention pruning, process cleanup, and supervisor lifecycle.
157
157
 
158
+ `solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm
159
+ already armed under the same name on the same actor, carrying the actor
160
+ identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are
161
+ keyed by actor and name, so re-arming a name is an update rather than a second
162
+ alarm. That is deliberate, and it is silent: an actor that arms one reminder
163
+ per queued item keeps only the last, and the earlier wake-up never happens.
164
+ Watch this event if your actors schedule from a loop or from a handler that can
165
+ run more than once. Rescheduling to the same time reports nothing.
166
+
158
167
  `solid_objects.component.refreshed` covers every authorized component refresh
159
168
  request. Its payload carries the actor identity, `component_name`,
160
169
  `component_key`, declared `dependencies`, `refresh_method`, the rendered
data/docs/roadmap.md CHANGED
@@ -44,7 +44,8 @@
44
44
  result recovery
45
45
  - Bounded message/process pruning, actor-type opt-in instance expiration,
46
46
  graceful caller shutdown, committed state snapshots, and an opt-in Minitest
47
- helper
47
+ helper that clears every actor-owned table itself rather than relying on the
48
+ database cascade, which a host application may not enforce
48
49
  - Supervisor role replacement: a role whose thread dies is restarted until
49
50
  shutdown is requested, and dead process records plus expired message and
50
51
  process history are pruned on their own intervals without an application
@@ -92,6 +92,9 @@ module SolidObjects
92
92
  :authorize_subscription,
93
93
  :authorize_administration
94
94
 
95
+ # @rbs @additional_components: Array[untyped]
96
+ attr_reader :additional_components
97
+
95
98
  # @rbs () -> void
96
99
  def initialize
97
100
  @table_name_prefix = "solid_objects_"
@@ -142,6 +145,45 @@ module SolidObjects
142
145
  @authorize_destroy = ->(**) { false }
143
146
  @authorize_subscription = ->(**) { false }
144
147
  @authorize_administration = ->(**) { false }
148
+ @additional_components = []
149
+ end
150
+
151
+ # Registers a long running component that the supervisor runs beside its own
152
+ # workers. An extension gem uses this to share one process, rather than ask
153
+ # an operator to run and monitor a second one.
154
+ #
155
+ # The block must return an object that answers `run`, `request_shutdown`,
156
+ # `stopped?`, and `stop`, which is the contract the built in components
157
+ # already keep. The supervisor calls the block once for each supervisor it
158
+ # builds, and again when it replaces a crashed component, so two
159
+ # supervisors never share one component instance.
160
+ #
161
+ # @rbs (?count: Integer) { () -> untyped } -> void
162
+ def register_component(count: 1, &factory)
163
+ raise ArgumentError, "register_component requires a block" unless factory
164
+ raise ArgumentError, "count must be positive" unless count.positive?
165
+
166
+ count.times { @additional_components << factory }
167
+ end
168
+
169
+ # The supervisor checks the contract here rather than at registration,
170
+ # because a component often needs a database connection to exist, and
171
+ # registration happens while the application boots.
172
+ # @rbs () -> Array[untyped]
173
+ def build_additional_components
174
+ additional_components.map { |factory| factory.call.tap { |component| validate_component!(component) } }
175
+ end
176
+
177
+ # A component that misses part of the contract would hang the supervisor at
178
+ # shutdown, or crash the moment it starts. The build fails instead, where
179
+ # the caller can read the reason.
180
+ # @rbs (untyped) -> void
181
+ def validate_component!(component)
182
+ %i[run request_shutdown stopped? stop].each do |method_name|
183
+ next if component.respond_to?(method_name)
184
+
185
+ raise ArgumentError, "a registered component must respond to #{method_name}"
186
+ end
145
187
  end
146
188
 
147
189
  # @rbs () -> self
@@ -90,8 +90,14 @@ module SolidObjects
90
90
  reminder_intents = actor.drain_reminder_intents
91
91
  outbound_message_intents = actor.drain_outbound_message_intents
92
92
  enqueued_effects = []
93
+ moved_reminders = []
93
94
 
94
95
  activation.lease.fenced_transaction do |instance|
96
+ # A busy database makes the adapter retry this whole block, so an
97
+ # attempt that was rolled back must not leave its work in the lists the
98
+ # reporting below reads. Each attempt starts from empty.
99
+ enqueued_effects.clear
100
+ moved_reminders.clear
95
101
  claimed_message = matching_claim!
96
102
  locked_message = Message.lock.find(message.id)
97
103
  execute_commit_actions(commit_action_intents)
@@ -107,7 +113,7 @@ module SolidObjects
107
113
  completed_at: SolidObjects.database_adapter.database_now
108
114
  )
109
115
  enqueued_effects.concat(enqueue_effects(locked_message, instance, effect_intents))
110
- schedule_reminders(instance, reminder_intents)
116
+ moved_reminders.concat(schedule_reminders(instance, reminder_intents))
111
117
  enqueued_effects.concat(
112
118
  enqueue_actor_messages(locked_message, instance, outbound_message_intents)
113
119
  )
@@ -127,6 +133,9 @@ module SolidObjects
127
133
  observable_name:
128
134
  )
129
135
  end
136
+ moved_reminders.each do |moved|
137
+ SolidObjects.instrument(:"reminder.replaced", **moved)
138
+ end
130
139
  enqueued_effects.each do |effect|
131
140
  SolidObjects.instrument(
132
141
  :"effect.enqueued",
@@ -211,10 +220,20 @@ module SolidObjects
211
220
  end
212
221
  end
213
222
 
214
- # @rbs (Instance, Array[Actor::ReminderIntent]) -> void
223
+ # A reminder is one named alarm per actor, so scheduling a name that is
224
+ # already armed moves it rather than adding a second. An actor that arms a
225
+ # reminder per queued item therefore keeps only the last, and nothing else
226
+ # about that is visible: the write succeeds and the earlier wake-up simply
227
+ # never happens.
228
+ # Moves are returned rather than reported here, so the report happens after
229
+ # the turn commits. A rolled back turn would otherwise announce an alarm
230
+ # that never moved, which is the opposite of the visibility this event
231
+ # exists to provide.
232
+ # @rbs (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]]
215
233
  def schedule_reminders(instance, intents)
216
- intents.each do |intent|
234
+ intents.filter_map do |intent|
217
235
  reminder = Reminder.find_or_initialize_by(instance:, name: intent.name)
236
+ previous_run_at = reminder.next_run_at
218
237
  reminder.assign_attributes(
219
238
  actor_type: instance.actor_type,
220
239
  actor_id: instance.actor_id,
@@ -227,10 +246,28 @@ module SolidObjects
227
246
  claimed_by: nil,
228
247
  claimed_at: nil
229
248
  )
249
+ moved = moved_reminder_payload(reminder, previous_run_at)
230
250
  reminder.save!
251
+ moved
231
252
  end
232
253
  end
233
254
 
255
+ # Arguments are omitted deliberately: a reminder carries application data
256
+ # and this event exists to be logged.
257
+ # @rbs (Reminder, Time?) -> Hash[Symbol, untyped]?
258
+ def moved_reminder_payload(reminder, previous_run_at)
259
+ return nil unless previous_run_at
260
+ return nil if previous_run_at == reminder.next_run_at
261
+
262
+ {
263
+ actor_type: reminder.actor_type,
264
+ actor_id: reminder.actor_id,
265
+ name: reminder.name,
266
+ previous_run_at:,
267
+ next_run_at: reminder.next_run_at
268
+ }
269
+ end
270
+
234
271
  # @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
235
272
  def enqueue_actor_messages(locked_message, instance, intents)
236
273
  intents.map do |intent|
@@ -19,12 +19,13 @@ module SolidObjects
19
19
  broadcast_worker_count: SolidObjects.configuration.broadcast_worker_count,
20
20
  reminder_scheduler_count: SolidObjects.configuration.reminder_scheduler_count
21
21
  )
22
- @components = build_components(
22
+ @builders = component_builders(
23
23
  worker_count:,
24
24
  effect_worker_count:,
25
25
  broadcast_worker_count:,
26
26
  reminder_scheduler_count:
27
27
  )
28
+ @components = build_all(@builders)
28
29
  @threads = []
29
30
  @monitor = nil
30
31
  @started = false
@@ -77,7 +78,7 @@ module SolidObjects
77
78
 
78
79
  private
79
80
 
80
- attr_reader :components, :threads
81
+ attr_reader :components, :threads, :builders
81
82
 
82
83
  # A role that raises leaves its thread dead. Without replacement the
83
84
  # process keeps running while quietly doing less work, so the supervisor
@@ -116,7 +117,11 @@ module SolidObjects
116
117
  replaced = @lifecycle.synchronize do
117
118
  next false unless @started
118
119
 
119
- replacement = component.class.new
120
+ # A component built by this supervisor has a builder, which carries
121
+ # whatever the constructor was given. A component put in place by
122
+ # other means has none, so the class is the only thing left to go on.
123
+ builder = builders[index] || -> { component.class.new }
124
+ replacement = builder.call
120
125
  components[index] = replacement
121
126
  threads[index] = supervise(replacement)
122
127
  replacement
@@ -250,17 +255,53 @@ module SolidObjects
250
255
  nil
251
256
  end
252
257
 
253
- # @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
254
- def build_components(
258
+ # A constructor can take a resource, and a later builder can raise. Without
259
+ # this, the components built first would be dropped while still holding
260
+ # whatever they took, and nothing would ever give it back.
261
+ # @rbs (Array[^() -> untyped]) -> Array[untyped]
262
+ def build_all(builders)
263
+ built = []
264
+ builders.each do |builder|
265
+ # The component joins the list before the contract check, so a
266
+ # component that fails the check is stopped along with the rest.
267
+ built << (component = builder.call)
268
+ SolidObjects.configuration.validate_component!(component)
269
+ end
270
+ built
271
+ rescue Exception # rubocop:disable Lint/RescueException
272
+ built.each { |component| stop_after_failed_build(component) }
273
+ raise
274
+ end
275
+
276
+ # The failure that stopped the build is the one worth reporting, so a
277
+ # failure inside the cleanup never replaces it.
278
+ # @rbs (untyped) -> void
279
+ def stop_after_failed_build(component)
280
+ component.stop if component.respond_to?(:stop)
281
+ rescue Exception => error # rubocop:disable Lint/RescueException
282
+ SolidObjects.instrument(
283
+ :"supervisor.component_cleanup_failed",
284
+ role: component.class.name,
285
+ error_class: error.class.name
286
+ )
287
+ end
288
+
289
+ # Each component keeps the builder that made it, so a replacement after a
290
+ # crash is built the same way as the original. Components registered
291
+ # through the configuration run beside the built in ones, under the same
292
+ # supervision, restart, and shutdown timeout.
293
+ # @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[^() -> untyped]
294
+ def component_builders(
255
295
  worker_count:,
256
296
  effect_worker_count:,
257
297
  broadcast_worker_count:,
258
298
  reminder_scheduler_count:
259
299
  )
260
- Array.new(worker_count) { Worker.new } +
261
- Array.new(effect_worker_count) { EffectExecutor.new } +
262
- Array.new(broadcast_worker_count) { BroadcastExecutor.new } +
263
- Array.new(reminder_scheduler_count) { ReminderScheduler.new }
300
+ Array.new(worker_count) { -> { Worker.new } } +
301
+ Array.new(effect_worker_count) { -> { EffectExecutor.new } } +
302
+ Array.new(broadcast_worker_count) { -> { BroadcastExecutor.new } } +
303
+ Array.new(reminder_scheduler_count) { -> { ReminderScheduler.new } } +
304
+ SolidObjects.configuration.additional_components
264
305
  end
265
306
 
266
307
  # @rbs () -> void
@@ -12,12 +12,35 @@ module SolidObjects
12
12
  test_case.teardown { reset_actors! }
13
13
  end
14
14
 
15
+ # Deleting instances alone left every other actor-owned row to the
16
+ # database cascade. That cascade is not enforced everywhere: SQLite has
17
+ # to be asked for foreign keys, MySQL has to be on InnoDB, and a host
18
+ # application may have stripped the constraints out of the copied
19
+ # migration. Where it does not fire, rows survive into the next test with
20
+ # an instance_id pointing at nothing, and a test that reads them sees
21
+ # another test's data. Deleting each table costs nothing and does not
22
+ # depend on referential integrity.
15
23
  # @rbs () -> void
16
24
  def reset_actors!
17
25
  SolidObjects.reset_caller_process!
18
- Instance.delete_all
26
+ actor_owned_models.each(&:delete_all)
19
27
  Process.delete_all
20
28
  end
29
+
30
+ # Children first, so the order is safe whether or not the cascade fires.
31
+ # @rbs () -> Array[Class]
32
+ def actor_owned_models
33
+ [
34
+ DeadLetter,
35
+ ClaimedMessage,
36
+ ReadyMessage,
37
+ Broadcast,
38
+ Effect,
39
+ Reminder,
40
+ Message,
41
+ Instance
42
+ ]
43
+ end
21
44
  end
22
45
 
23
46
  # @rbs () -> void
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.10.2"
4
+ VERSION = "0.11.0"
5
5
  end
@@ -2,6 +2,10 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Configuration
5
+ @process_alive_threshold: Float
6
+
7
+ @shutdown_timeout: Float
8
+
5
9
  @supervisor_monitor_interval: Float
6
10
 
7
11
  @retention_interval: Float
@@ -86,10 +90,6 @@ module SolidObjects
86
90
 
87
91
  @process_heartbeat_interval: Float
88
92
 
89
- @process_alive_threshold: Float
90
-
91
- @shutdown_timeout: Float
92
-
93
93
  attr_accessor table_name_prefix: untyped
94
94
 
95
95
  attr_accessor polling_interval: untyped
@@ -178,9 +178,37 @@ module SolidObjects
178
178
 
179
179
  attr_accessor authorize_administration: untyped
180
180
 
181
+ # @rbs @additional_components: Array[untyped]
182
+ attr_reader additional_components: untyped
183
+
181
184
  # @rbs () -> void
182
185
  def initialize: () -> void
183
186
 
187
+ # Registers a long running component that the supervisor runs beside its own
188
+ # workers. An extension gem uses this to share one process, rather than ask
189
+ # an operator to run and monitor a second one.
190
+ #
191
+ # The block must return an object that answers `run`, `request_shutdown`,
192
+ # `stopped?`, and `stop`, which is the contract the built in components
193
+ # already keep. The supervisor calls the block once for each supervisor it
194
+ # builds, and again when it replaces a crashed component, so two
195
+ # supervisors never share one component instance.
196
+ #
197
+ # @rbs (?count: Integer) { () -> untyped } -> void
198
+ def register_component: (?count: Integer) { () -> untyped } -> void
199
+
200
+ # The supervisor checks the contract here rather than at registration,
201
+ # because a component often needs a database connection to exist, and
202
+ # registration happens while the application boots.
203
+ # @rbs () -> Array[untyped]
204
+ def build_additional_components: () -> Array[untyped]
205
+
206
+ # A component that misses part of the contract would hang the supervisor at
207
+ # shutdown, or crash the moment it starts. The build fails instead, where
208
+ # the caller can read the reason.
209
+ # @rbs (untyped) -> void
210
+ def validate_component!: (untyped) -> void
211
+
184
212
  # @rbs () -> self
185
213
  def validate!: () -> self
186
214
 
@@ -45,8 +45,22 @@ module SolidObjects
45
45
  # @rbs (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
46
46
  def enqueue_effects: (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
47
47
 
48
- # @rbs (Instance, Array[Actor::ReminderIntent]) -> void
49
- def schedule_reminders: (Instance, Array[Actor::ReminderIntent]) -> void
48
+ # A reminder is one named alarm per actor, so scheduling a name that is
49
+ # already armed moves it rather than adding a second. An actor that arms a
50
+ # reminder per queued item therefore keeps only the last, and nothing else
51
+ # about that is visible: the write succeeds and the earlier wake-up simply
52
+ # never happens.
53
+ # Moves are returned rather than reported here, so the report happens after
54
+ # the turn commits. A rolled back turn would otherwise announce an alarm
55
+ # that never moved, which is the opposite of the visibility this event
56
+ # exists to provide.
57
+ # @rbs (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]]
58
+ def schedule_reminders: (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]]
59
+
60
+ # Arguments are omitted deliberately: a reminder carries application data
61
+ # and this event exists to be logged.
62
+ # @rbs (Reminder, Time?) -> Hash[Symbol, untyped]?
63
+ def moved_reminder_payload: (Reminder, Time?) -> Hash[Symbol, untyped]?
50
64
 
51
65
  # @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
52
66
  def enqueue_actor_messages: (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
@@ -36,6 +36,8 @@ module SolidObjects
36
36
 
37
37
  attr_reader threads: untyped
38
38
 
39
+ attr_reader builders: untyped
40
+
39
41
  # A role that raises leaves its thread dead. Without replacement the
40
42
  # process keeps running while quietly doing less work, so the supervisor
41
43
  # watches its threads and restarts any that stopped before shutdown.
@@ -101,8 +103,23 @@ module SolidObjects
101
103
  # @rbs () -> void
102
104
  def release_wake_up: () -> void
103
105
 
104
- # @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
105
- def build_components: (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
106
+ # A constructor can take a resource, and a later builder can raise. Without
107
+ # this, the components built first would be dropped while still holding
108
+ # whatever they took, and nothing would ever give it back.
109
+ # @rbs (Array[^() -> untyped]) -> Array[untyped]
110
+ def build_all: (Array[^() -> untyped]) -> Array[untyped]
111
+
112
+ # The failure that stopped the build is the one worth reporting, so a
113
+ # failure inside the cleanup never replaces it.
114
+ # @rbs (untyped) -> void
115
+ def stop_after_failed_build: (untyped) -> void
116
+
117
+ # Each component keeps the builder that made it, so a replacement after a
118
+ # crash is built the same way as the original. Components registered
119
+ # through the configuration run beside the built in ones, under the same
120
+ # supervision, restart, and shutdown timeout.
121
+ # @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[^() -> untyped]
122
+ def component_builders: (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[^() -> untyped]
106
123
 
107
124
  # @rbs () -> void
108
125
  def join_until_timeout: () -> void
@@ -5,9 +5,21 @@ module SolidObjects
5
5
  # @rbs (Class) -> void
6
6
  def self.included: (Class) -> void
7
7
 
8
+ # Deleting instances alone left every other actor-owned row to the
9
+ # database cascade. That cascade is not enforced everywhere: SQLite has
10
+ # to be asked for foreign keys, MySQL has to be on InnoDB, and a host
11
+ # application may have stripped the constraints out of the copied
12
+ # migration. Where it does not fire, rows survive into the next test with
13
+ # an instance_id pointing at nothing, and a test that reads them sees
14
+ # another test's data. Deleting each table costs nothing and does not
15
+ # depend on referential integrity.
8
16
  # @rbs () -> void
9
17
  def self.reset_actors!: () -> void
10
18
 
19
+ # Children first, so the order is safe whether or not the cascade fires.
20
+ # @rbs () -> Array[Class]
21
+ def self.actor_owned_models: () -> Array[Class]
22
+
11
23
  # @rbs () -> void
12
24
  def reset_actors!: () -> void
13
25
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.2
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-10 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable