solid_objects 0.10.3 → 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: 36d165ff933a151ee0cc9fbff1ae834333f9591d1ae71068a0127d198dc4d5d8
4
- data.tar.gz: 1cfd279335dcf34f4cd2dd659412e04c5ac8d6ed98e1815cbd184bb38f67585f
3
+ metadata.gz: 530d40a11cbf715cc3657191124d336798d4e8efdccb129d8ee0431400ec36ff
4
+ data.tar.gz: 39abe950312fa41ab57aa55e7e01a088a01eb63dcfd535e0ada712ae75c03b01
5
5
  SHA512:
6
- metadata.gz: 9328e60fa9f4e08f17cfe4404fe117af3a2c8efef53ba379b5c824d73a71063ae7f3b8e438b177ec4fcacb1ca31fdb86686212a6ac6d0f972e4f337233d6dea2
7
- data.tar.gz: b39f02c65284721d06c02eee9bbd678eb808770136847715f83525e3e43fe461f89e9837c0811c6730b50584b4c74c572dcf8e3de1cbbdae7fcfbf2dbc7f6708
6
+ metadata.gz: edd288ba3cc57f8ac7c241eda9e3e320385f26071a5dc1e11e3676af1106dc1ddadddfa520ff08f4ce61a0b2f73c23d1a2418199c8f6f98ebe85fe2b51b07426
7
+ data.tar.gz: 38e5561275048f00dcbd5e363b0d3da6a00242348094776c63da44df7e2ca694292e7bc0546d44ac5da7a8883eb70d3e237d7495c888d6d3e2dd9d286d1754f0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
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
+
3
30
  ## 0.10.3 - 2026-08-11
4
31
 
5
32
  - Delete every actor-owned row in `SolidObjects::TestHelper#reset_actors!`. It
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:
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.10.3"
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
 
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.3
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson