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 +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +89 -0
- data/docs/architecture.md +2 -0
- data/docs/database-schema.md +2 -1
- data/docs/development.md +7 -1
- data/docs/operations.md +9 -0
- data/docs/roadmap.md +2 -1
- data/lib/solid_objects/configuration.rb +42 -0
- data/lib/solid_objects/executor.rb +40 -3
- data/lib/solid_objects/supervisor.rb +50 -9
- data/lib/solid_objects/test_helper.rb +24 -1
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/configuration.rbs +32 -4
- data/sig/generated/lib/solid_objects/executor.rbs +16 -2
- data/sig/generated/lib/solid_objects/supervisor.rbs +19 -2
- data/sig/generated/lib/solid_objects/test_helper.rbs +12 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 530d40a11cbf715cc3657191124d336798d4e8efdccb129d8ee0431400ec36ff
|
|
4
|
+
data.tar.gz: 39abe950312fa41ab57aa55e7e01a088a01eb63dcfd535e0ada712ae75c03b01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/database-schema.md
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
#
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
#
|
|
49
|
-
|
|
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
|
-
#
|
|
105
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-08-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actioncable
|