solid_objects 0.13.1 → 0.13.2

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: e5bd1724baaa40d79ec3693558ef7aa8e37830e4c5afd1da1d5774356d1e584e
4
- data.tar.gz: 1e7a2307cae51316b9b4c4f56ea6fc986a622e3665541514137d87bbf72b2e9a
3
+ metadata.gz: c1043a832a75d5e7d2c9e59b332c3c6bce85a322204fdc2a056dc5198018d34d
4
+ data.tar.gz: 4b1505d23bab1aeb57dc722a8a613fd3c39911d2756e24c37714466e4c821b22
5
5
  SHA512:
6
- metadata.gz: b1b5284ef616f306bea2136d7becf4a3c8e4aa2adbcc24eb5da08c3f33e85f34551f687ff7133cd3781abba79d4f0dac919657d9150bfe3ebd021417542d505b
7
- data.tar.gz: 6971c0ee78d9eab66776a5ba6d60dcc403d3346c57cffca795797f50048fc419b6473d3d620c4d025238916757f52ed79ed1ec153feaa3dda84744d740b08045
6
+ metadata.gz: 2d7c216e135b8e34c19b9130a0fcad0df6246191ee097f9a560497d0dacc47fc480e549c1ddd6ab2bc6fe9bc513245236aa47cc1650a08906a04367d0e6bd008
7
+ data.tar.gz: d1dc2001e0b43a2df5eb24c5cd79c6e1df99b2d6c46c90146a349dd09a557db9dcdaa7c51e51a47f5db1667b364c2dfe64d5ba73fe877248a0143e022045c764
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.2 - 2026-08-17
4
+
5
+ - Accept a `key:` on `schedule`, naming a reminder for the item it is waiting
6
+ on rather than for its operation, so one actor can hold an alarm per queued
7
+ item. Scheduling the same key again moves that item's alarm and leaves the
8
+ others alone. Without a key the name is still the operation, so existing
9
+ reminders keep their names and their coalescing behaviour. A reminder
10
+ operation may no longer hold the colon that separates a key, which keeps
11
+ keyed and unkeyed names disjoint, and the length is checked on the composed
12
+ name rather than the key alone.
13
+ - Add an authorized `SolidObjects.administration.processes` query for
14
+ inspecting live and stale process rows through the runtime database adapter.
15
+ - Document rolling-deployment overlap as a reason for the polling-only warning.
16
+
3
17
  ## 0.13.1 - 2026-08-16
4
18
 
5
19
  - Back idle actor, effect, reminder, and broadcast polling off exponentially
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml/badge.svg)](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml)
4
4
 
5
- **Cloudflare Durable Objects, ported to Rails.**
5
+ **Self-hosted, distributed Durable Objects in Rails without a daemon using your existing SQL database.**
6
6
 
7
7
  Solid Objects brings the Durable Objects programming model—addressable objects,
8
8
  durable state, serialized turns, alarms, and live clients—to ordinary Rails
@@ -891,7 +891,7 @@ database enforces this with a unique index on `(instance_id, name)`.
891
891
 
892
892
  This is the same model as Orleans reminders and Durable Objects alarms, and it
893
893
  is what makes a reminder safe to re-arm from a handler that may run more than
894
- once. It also means this is a data-loss bug:
894
+ once. Without a key the name is the operation, so this is a data-loss bug:
895
895
 
896
896
  ```ruby
897
897
  # Wrong. Every entry overwrites the previous entry's alarm.
@@ -904,8 +904,38 @@ end
904
904
  Two entries leave one reminder. The earlier wake-up never happens, nothing
905
905
  raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
906
906
 
907
- Arm one alarm for the earliest item instead, and let the handler drain
908
- everything now due before arming the next:
907
+ ### An alarm per item, with `key:`
908
+
909
+ Pass `key:` when an actor is waiting on several things at once. The key is your
910
+ own identifier for the item, and it names that item's alarm, so each item gets
911
+ one:
912
+
913
+ ```ruby
914
+ def add(entry:)
915
+ self.entries = entries + [ entry ]
916
+ schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
917
+ end
918
+ ```
919
+
920
+ Two entries now leave two reminders. Scheduling the same key again moves that
921
+ item's alarm and leaves the others alone, which is what makes a keyed reminder
922
+ as safe to re-arm as an unkeyed one. The operation still decides which handler
923
+ runs; the key only decides which alarm is which.
924
+
925
+ A key must be non-empty, and the name it becomes must fit the 191-character
926
+ column, which is checked on the composed name rather than the key alone so a
927
+ long operation and a short key are caught too.
928
+
929
+ The key is separated from the operation by a colon, so an operation may not hold
930
+ one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be
931
+ one name, and the second would silently take the first one's alarm. A key may
932
+ hold colons of its own, because the operation before the first one cannot.
933
+
934
+ ### One alarm for a whole queue
935
+
936
+ A key per item is not always what you want. An actor that only ever needs to
937
+ know "what is next" can keep one alarm and let the handler drain everything now
938
+ due before arming the next:
909
939
 
910
940
  ```ruby
911
941
  def add(entry:)
@@ -931,10 +961,10 @@ def arm_next
931
961
  end
932
962
  ```
933
963
 
934
- `deliver` drains every due item rather than one, so a single alarm serves a
935
- whole queue and a missed or coalesced occurrence cannot strand an entry. Use a
936
- distinct reminder name only when you genuinely need two independent alarms on
937
- one actor, such as `:deliver` and `:sweep`.
964
+ That costs one reminder row instead of one per item, and a coalesced occurrence
965
+ cannot strand an entry because the handler drains by time rather than by alarm.
966
+ Prefer it when the queue is large and the items are interchangeable; prefer
967
+ `key:` when an item needs its own alarm that can be moved on its own.
938
968
 
939
969
  Solid Objects has no `unschedule`. A reminder stops when its handler does not
940
970
  re-arm it, and destroying an actor removes its reminders.
data/docs/operations.md CHANGED
@@ -98,6 +98,14 @@ adapter is configured, the runtime logs
98
98
  need prompt delivery. Without one, newly committed work can wait up to the
99
99
  current idle polling interval.
100
100
 
101
+ The warning excludes process rows with the current hostname and PID. It can
102
+ therefore appear during a rolling deployment or restart overlap when an older
103
+ and newer process briefly share the database. A process that stopped without
104
+ graceful cleanup remains live until its heartbeat exceeds
105
+ `process_alive_threshold`; inspect `SolidObjects.administration.processes` to
106
+ distinguish a live overlap from a stale row without opening a second SQLite
107
+ connection.
108
+
101
109
  Each role exposes `current_polling_interval`.
102
110
  `solid_objects.polling.interval_changed` reports the role, reason, previous
103
111
  interval, and current interval. The polling-only warning is also emitted as
@@ -4,7 +4,11 @@ module SolidObjects
4
4
  class Actor
5
5
  EffectIntent = Data.define(:name, :arguments, :success_operation, :failure_operation)
6
6
  CommitActionIntent = Data.define(:name, :arguments)
7
- ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
7
+ # The reminders table holds a name in 191 characters.
8
+ REMINDER_NAME_LIMIT = 191
9
+ REMINDER_KEY_SEPARATOR = ":"
10
+
11
+ ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy)
8
12
  OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)
9
13
 
10
14
  class << self
@@ -197,8 +201,13 @@ module SolidObjects
197
201
  nil
198
202
  end
199
203
 
200
- # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
201
- def schedule(at:, every: nil, missed: :latest)
204
+ # A reminder is identified by its name, and without a key that name is the
205
+ # operation, so one actor holds one alarm per operation. A key gives an actor
206
+ # an alarm per item it is waiting on, which is what an actor holding a queue
207
+ # of scheduled work needs; the key is the caller's own identifier for the
208
+ # item, and scheduling the same key again moves that item's alarm.
209
+ # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
210
+ def schedule(at:, every: nil, missed: :latest, key: nil)
202
211
  interval_seconds = every&.to_f
203
212
  if interval_seconds && !interval_seconds.positive?
204
213
  raise ArgumentError, "reminder interval must be positive"
@@ -207,13 +216,15 @@ module SolidObjects
207
216
  unless %w[all latest].include?(missed_policy)
208
217
  raise ArgumentError, "missed reminder policy must be all or latest"
209
218
  end
219
+ reminder_key = validated_reminder_key(key)
210
220
 
211
221
  OperationDispatcher.new(
212
222
  actor_type: self.class.actor_type,
213
223
  handlers: self.class.definition.messages
214
224
  ) do |operation, arguments|
215
225
  ReminderIntent.new(
216
- name: operation.to_s,
226
+ name: reminder_name(operation:, key: reminder_key),
227
+ operation: operation.to_s,
217
228
  at:,
218
229
  arguments: Serialization.dump(arguments),
219
230
  interval_seconds:,
@@ -225,6 +236,45 @@ module SolidObjects
225
236
  end
226
237
  end
227
238
 
239
+ # @rbs ((String | Symbol | Integer)?) -> String?
240
+ def validated_reminder_key(key)
241
+ return nil if key.nil?
242
+
243
+ reminder_key = key.to_s
244
+ raise ArgumentError, "reminder key must not be empty" if reminder_key.empty?
245
+
246
+ reminder_key
247
+ end
248
+
249
+ # A keyed name is the operation, a colon, and the key, so an operation
250
+ # holding a colon of its own would make two different schedules produce one
251
+ # name: an unkeyed "deliver:item" and a "deliver" keyed "item" would share a
252
+ # row, and the second would silently take the first one's alarm. Refusing a
253
+ # colon in the operation keeps unkeyed names free of colons, which leaves
254
+ # the two kinds of name disjoint and lets a key hold colons of its own.
255
+ #
256
+ # The length is checked on the composed name rather than the key alone,
257
+ # because a long operation and a short key can exceed the column just as
258
+ # easily as the reverse. Both are refused here rather than at the insert,
259
+ # once the turn is already doing work.
260
+ # @rbs (operation: Symbol | String, key: String?) -> String
261
+ def reminder_name(operation:, key:)
262
+ operation_name = operation.to_s
263
+ if operation_name.include?(REMINDER_KEY_SEPARATOR)
264
+ raise ArgumentError,
265
+ "reminder operation #{operation_name.inspect} must not contain #{REMINDER_KEY_SEPARATOR.inspect}"
266
+ end
267
+ return operation_name if key.nil?
268
+
269
+ name = "#{operation_name}#{REMINDER_KEY_SEPARATOR}#{key}"
270
+ if name.length > REMINDER_NAME_LIMIT
271
+ raise ArgumentError,
272
+ "reminder name #{name.length} characters exceeds the #{REMINDER_NAME_LIMIT} the database holds"
273
+ end
274
+
275
+ name
276
+ end
277
+
228
278
  # @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
229
279
  def send_to(reference, available_at: nil, idempotency_key: nil)
230
280
  actor_class = SolidObjects.registry.fetch(reference.actor_type)
@@ -0,0 +1,44 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Administration
5
+ # @rbs (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]]
6
+ def processes(authorization_context: nil)
7
+ authorize!(authorization_context:)
8
+ now = SolidObjects.database_adapter.database_now
9
+ stale_at = now - SolidObjects.configuration.process_alive_threshold
10
+
11
+ Process.order(:kind, :started_at).map do |process_record|
12
+ {
13
+ id: process_record.id,
14
+ kind: process_record.kind,
15
+ hostname: process_record.hostname,
16
+ pid: process_record.pid,
17
+ metadata: Serialization.readonly_copy(process_record.metadata),
18
+ shutdown_state: process_record.shutdown_state,
19
+ shutdown_requested_at: process_record.shutdown_requested_at,
20
+ started_at: process_record.started_at,
21
+ last_heartbeat_at: process_record.last_heartbeat_at,
22
+ stopped_at: process_record.stopped_at,
23
+ stale: process_record.shutdown_state != "stopped" &&
24
+ process_record.last_heartbeat_at <= stale_at
25
+ }.freeze
26
+ end.freeze
27
+ end
28
+
29
+ private
30
+
31
+ # @rbs (?authorization_context: untyped) -> void
32
+ def authorize!(authorization_context: nil)
33
+ authorized = SolidObjects.configuration.authorize_administration.call(
34
+ action: :inspect,
35
+ resource: "processes",
36
+ resource_id: nil,
37
+ authorization_context:
38
+ )
39
+ return if authorized
40
+
41
+ raise Unauthorized, "actor administration is not authorized"
42
+ end
43
+ end
44
+ end
@@ -223,10 +223,10 @@ module SolidObjects
223
223
  end
224
224
 
225
225
  # A reminder is one named alarm per actor, so scheduling a name that is
226
- # already armed moves it rather than adding a second. An actor that arms a
227
- # reminder per queued item therefore keeps only the last, and nothing else
228
- # about that is visible: the write succeeds and the earlier wake-up simply
229
- # never happens.
226
+ # already armed moves it rather than adding a second. Without a key that
227
+ # name is the operation, so an actor arming a reminder per queued item keeps
228
+ # only the last; passing schedule a key gives each item its own name and so
229
+ # its own alarm.
230
230
  # Moves are returned rather than reported here, so the report happens after
231
231
  # the turn commits. A rolled back turn would otherwise announce an alarm
232
232
  # that never moved, which is the opposite of the visibility this event
@@ -239,7 +239,7 @@ module SolidObjects
239
239
  reminder.assign_attributes(
240
240
  actor_type: instance.actor_type,
241
241
  actor_id: instance.actor_id,
242
- operation: intent.name,
242
+ operation: intent.operation,
243
243
  arguments: intent.arguments,
244
244
  next_run_at: intent.at,
245
245
  interval_seconds: intent.interval_seconds,
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.13.1"
4
+ VERSION = "0.13.2"
5
5
  end
data/lib/solid_objects.rb CHANGED
@@ -31,6 +31,7 @@ require "solid_objects/dead_letter_manager"
31
31
  require "solid_objects/message_pruner"
32
32
  require "solid_objects/instance_pruner"
33
33
  require "solid_objects/process_pruner"
34
+ require "solid_objects/administration"
34
35
  require "solid_objects/stream_name"
35
36
  require "solid_objects/dom_identity"
36
37
  require "solid_objects/stream_token"
@@ -137,6 +138,11 @@ module SolidObjects
137
138
  @dead_letters ||= DeadLetterManager.new
138
139
  end
139
140
 
141
+ # @rbs () -> Administration
142
+ def administration
143
+ @administration ||= Administration.new
144
+ end
145
+
140
146
  # @rbs (String | Symbol) -> String
141
147
  def table_name(name)
142
148
  "#{configuration.table_name_prefix}#{name}"
@@ -164,6 +170,7 @@ module SolidObjects
164
170
  @effect_registry = EffectRegistry.new
165
171
  @commit_action_registry = CommitActionRegistry.new
166
172
  @dead_letters = nil
173
+ @administration = nil
167
174
  end
168
175
 
169
176
  # @rbs () -> DatabaseAdapter
@@ -32,9 +32,16 @@ module SolidObjects
32
32
  def members: () -> [ :name, :arguments ]
33
33
  end
34
34
 
35
+ # The reminders table holds a name in 191 characters.
36
+ REMINDER_NAME_LIMIT: ::Integer
37
+
38
+ REMINDER_KEY_SEPARATOR: ::String
39
+
35
40
  class ReminderIntent < Data
36
41
  attr_reader name(): untyped
37
42
 
43
+ attr_reader operation(): untyped
44
+
38
45
  attr_reader at(): untyped
39
46
 
40
47
  attr_reader arguments(): untyped
@@ -43,12 +50,12 @@ module SolidObjects
43
50
 
44
51
  attr_reader missed_policy(): untyped
45
52
 
46
- def self.new: (untyped name, untyped at, untyped arguments, untyped interval_seconds, untyped missed_policy) -> instance
47
- | (name: untyped, at: untyped, arguments: untyped, interval_seconds: untyped, missed_policy: untyped) -> instance
53
+ def self.new: (untyped name, untyped operation, untyped at, untyped arguments, untyped interval_seconds, untyped missed_policy) -> instance
54
+ | (name: untyped, operation: untyped, at: untyped, arguments: untyped, interval_seconds: untyped, missed_policy: untyped) -> instance
48
55
 
49
- def self.members: () -> [ :name, :at, :arguments, :interval_seconds, :missed_policy ]
56
+ def self.members: () -> [ :name, :operation, :at, :arguments, :interval_seconds, :missed_policy ]
50
57
 
51
- def members: () -> [ :name, :at, :arguments, :interval_seconds, :missed_policy ]
58
+ def members: () -> [ :name, :operation, :at, :arguments, :interval_seconds, :missed_policy ]
52
59
  end
53
60
 
54
61
  class OutboundMessageIntent < Data
@@ -157,8 +164,30 @@ module SolidObjects
157
164
  # @rbs (Symbol | String, **untyped) -> nil
158
165
  def commit_action: (Symbol | String, **untyped) -> nil
159
166
 
160
- # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
161
- def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
167
+ # A reminder is identified by its name, and without a key that name is the
168
+ # operation, so one actor holds one alarm per operation. A key gives an actor
169
+ # an alarm per item it is waiting on, which is what an actor holding a queue
170
+ # of scheduled work needs; the key is the caller's own identifier for the
171
+ # item, and scheduling the same key again moves that item's alarm.
172
+ # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
173
+ def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
174
+
175
+ # @rbs ((String | Symbol | Integer)?) -> String?
176
+ def validated_reminder_key: ((String | Symbol | Integer)?) -> String?
177
+
178
+ # A keyed name is the operation, a colon, and the key, so an operation
179
+ # holding a colon of its own would make two different schedules produce one
180
+ # name: an unkeyed "deliver:item" and a "deliver" keyed "item" would share a
181
+ # row, and the second would silently take the first one's alarm. Refusing a
182
+ # colon in the operation keeps unkeyed names free of colons, which leaves
183
+ # the two kinds of name disjoint and lets a key hold colons of its own.
184
+ #
185
+ # The length is checked on the composed name rather than the key alone,
186
+ # because a long operation and a short key can exceed the column just as
187
+ # easily as the reverse. Both are refused here rather than at the insert,
188
+ # once the turn is already doing work.
189
+ # @rbs (operation: Symbol | String, key: String?) -> String
190
+ def reminder_name: (operation: Symbol | String, key: String?) -> String
162
191
 
163
192
  # @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
164
193
  def send_to: (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
@@ -0,0 +1,13 @@
1
+ # Generated from lib/solid_objects/administration.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ class Administration
5
+ # @rbs (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]]
6
+ def processes: (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]]
7
+
8
+ private
9
+
10
+ # @rbs (?authorization_context: untyped) -> void
11
+ def authorize!: (?authorization_context: untyped) -> void
12
+ end
13
+ end
@@ -46,10 +46,10 @@ module SolidObjects
46
46
  def enqueue_effects: (message: Message, instance: Instance, intents: Array[Actor::EffectIntent]) -> Array[Effect]
47
47
 
48
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.
49
+ # already armed moves it rather than adding a second. Without a key that
50
+ # name is the operation, so an actor arming a reminder per queued item keeps
51
+ # only the last; passing schedule a key gives each item its own name and so
52
+ # its own alarm.
53
53
  # Moves are returned rather than reported here, so the report happens after
54
54
  # the turn commits. A rolled back turn would otherwise announce an alarm
55
55
  # that never moved, which is the opposite of the visibility this event
@@ -36,6 +36,9 @@ module SolidObjects
36
36
  # @rbs () -> DeadLetterManager
37
37
  def self.dead_letters: () -> DeadLetterManager
38
38
 
39
+ # @rbs () -> Administration
40
+ def self.administration: () -> Administration
41
+
39
42
  # @rbs (String | Symbol) -> String
40
43
  def self.table_name: (String | Symbol) -> String
41
44
 
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.13.1
4
+ version: 0.13.2
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-16 00:00:00.000000000 Z
11
+ date: 2026-08-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -382,6 +382,7 @@ files:
382
382
  - lib/solid_objects/actor_registry.rb
383
383
  - lib/solid_objects/actor_snapshot.rb
384
384
  - lib/solid_objects/actor_view.rb
385
+ - lib/solid_objects/administration.rb
385
386
  - lib/solid_objects/application_actor_loader.rb
386
387
  - lib/solid_objects/application_write_guard.rb
387
388
  - lib/solid_objects/broadcast_executor.rb
@@ -468,6 +469,7 @@ files:
468
469
  - sig/generated/lib/solid_objects/actor_registry.rbs
469
470
  - sig/generated/lib/solid_objects/actor_snapshot.rbs
470
471
  - sig/generated/lib/solid_objects/actor_view.rbs
472
+ - sig/generated/lib/solid_objects/administration.rbs
471
473
  - sig/generated/lib/solid_objects/application_actor_loader.rbs
472
474
  - sig/generated/lib/solid_objects/application_write_guard.rbs
473
475
  - sig/generated/lib/solid_objects/broadcast_executor.rbs