solid_objects 0.10.2 → 0.10.3

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: 36d165ff933a151ee0cc9fbff1ae834333f9591d1ae71068a0127d198dc4d5d8
4
+ data.tar.gz: 1cfd279335dcf34f4cd2dd659412e04c5ac8d6ed98e1815cbd184bb38f67585f
5
5
  SHA512:
6
- metadata.gz: 470ca38a3faacd28a05a8f3cd020a353a1a20a9717aad29ec7c0f6cb9be6efbb6e44ef96abcc5a12c2b151b112cdbda47df73da361a94a26ff275bb0dd7d966b
7
- data.tar.gz: 874fa30b34175212a24ecdc115e4fd187c40f46c0e55ff62def214f5b350a54997b738f37a86991a9c032f7e1b7ec017613aa0cebaa6378c90aa0c02d6457fb5
6
+ metadata.gz: 9328e60fa9f4e08f17cfe4404fe117af3a2c8efef53ba379b5c824d73a71063ae7f3b8e438b177ec4fcacb1ca31fdb86686212a6ac6d0f972e4f337233d6dea2
7
+ data.tar.gz: b39f02c65284721d06c02eee9bbd678eb808770136847715f83525e3e43fe461f89e9837c0811c6730b50584b4c74c572dcf8e3de1cbbdae7fcfbf2dbc7f6708
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.3 - 2026-08-11
4
+
5
+ - Delete every actor-owned row in `SolidObjects::TestHelper#reset_actors!`. It
6
+ deleted actor instances and processes and left the other seven tables to the
7
+ database cascade. That cascade is not enforced everywhere: SQLite has to be
8
+ asked for foreign keys, MySQL has to be on InnoDB, and a host application may
9
+ have stripped the constraints out of the copied migration. Where it does not
10
+ fire, messages, ready and claimed mailbox rows, reminders, effects,
11
+ broadcasts, and dead letters all survived into the next test with an
12
+ `instance_id` pointing at nothing, so a test reading any of them saw another
13
+ test's rows and failed depending on order. Reported as reminders leaking,
14
+ which is where it surfaces first because reminders outlive the message that
15
+ created them.
16
+ - Document that a reminder is one named alarm per actor. `schedule` is keyed by
17
+ actor and reminder name, so scheduling a name that is already armed moves
18
+ that alarm rather than adding a second. The behaviour is deliberate and
19
+ matches Orleans and Durable Objects, but it was stated nowhere: an actor that
20
+ armed one reminder per queued item silently kept only the last, and the
21
+ earlier wake-ups never happened. The reminders guide now states the
22
+ uniqueness key and shows the one-alarm-many-items pattern to use instead.
23
+ - Add `solid_objects.reminder.replaced`, reported when a `schedule` call moves
24
+ an alarm already armed under the same name to a different time. It carries
25
+ the actor identity, reminder name, previous run time, and next run time, and
26
+ no arguments. Rescheduling to the same time reports nothing. The replacement
27
+ was previously indistinguishable from a first schedule.
28
+
3
29
  ## 0.10.2 - 2026-08-10
4
30
 
5
31
  - Load the mailbox when the gem is required. `SolidObjects::Mailbox` was
data/README.md CHANGED
@@ -805,6 +805,62 @@ end
805
805
  Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
806
806
  enqueue each one.
807
807
 
808
+ ### A reminder is one named alarm per actor
809
+
810
+ The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
811
+ already armed **moves the existing alarm** rather than adding a second one. The
812
+ database enforces this with a unique index on `(instance_id, name)`.
813
+
814
+ This is the same model as Orleans reminders and Durable Objects alarms, and it
815
+ is what makes a reminder safe to re-arm from a handler that may run more than
816
+ once. It also means this is a data-loss bug:
817
+
818
+ ```ruby
819
+ # Wrong. Every entry overwrites the previous entry's alarm.
820
+ def add(entry:)
821
+ self.entries = entries + [ entry ]
822
+ schedule :deliver, at: entry.fetch("wait_until"), arguments: {}
823
+ end
824
+ ```
825
+
826
+ Two entries leave one reminder. The earlier wake-up never happens, nothing
827
+ raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
828
+
829
+ Arm one alarm for the earliest item instead, and let the handler drain
830
+ everything now due before arming the next:
831
+
832
+ ```ruby
833
+ def add(entry:)
834
+ self.entries = (entries + [ entry ]).sort_by { |item| item.fetch("wait_until") }
835
+ arm_next
836
+ end
837
+
838
+ def deliver
839
+ now = Time.current.to_i
840
+ due, pending = entries.partition { |item| item.fetch("wait_until") <= now }
841
+ due.each { |item| emit :send_push, **item.symbolize_keys }
842
+ self.entries = pending
843
+ arm_next
844
+ end
845
+
846
+ private
847
+
848
+ def arm_next
849
+ earliest = entries.first
850
+ return unless earliest
851
+
852
+ schedule :deliver, at: Time.at(earliest.fetch("wait_until")), arguments: {}
853
+ end
854
+ ```
855
+
856
+ `deliver` drains every due item rather than one, so a single alarm serves a
857
+ whole queue and a missed or coalesced occurrence cannot strand an entry. Use a
858
+ distinct reminder name only when you genuinely need two independent alarms on
859
+ one actor, such as `:deliver` and `:sweep`.
860
+
861
+ Solid Objects has no `unschedule`. A reminder stops when its handler does not
862
+ re-arm it, and destroying an actor removes its reminders.
863
+
808
864
  Self-scheduling actors should also have a low-frequency application reconciler.
809
865
  It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
810
866
  `.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
@@ -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|
@@ -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.10.3"
5
5
  end
@@ -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]
@@ -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.10.3
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