solid_objects 0.14.1 → 0.14.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.
data/docs/reminders.md ADDED
@@ -0,0 +1,112 @@
1
+ # Reminders
2
+
3
+ Reminders are Solid Objects' durable equivalent of the Durable Objects Alarms
4
+ API. One-shot and recurring alarms are actor-owned database records:
5
+
6
+ ```ruby
7
+ def schedule_evaluation
8
+ schedule(
9
+ at: 1.hour.from_now,
10
+ every: 1.hour,
11
+ missed: :latest
12
+ ).evaluate(account_id:)
13
+ end
14
+ ```
15
+
16
+ Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
17
+ enqueue each one.
18
+
19
+ ## A reminder is one named alarm per actor
20
+
21
+ The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
22
+ already armed **moves the existing alarm** rather than adding a second one. The
23
+ database enforces this with a unique index on `(instance_id, name)`.
24
+
25
+ This is the same model as Orleans reminders and Durable Objects alarms. It
26
+ makes a reminder safe to re-arm from a handler that may run more than once.
27
+ Without a key the name is the operation, so this is a data-loss bug:
28
+
29
+ ```ruby
30
+ # Wrong. Every entry overwrites the previous entry's alarm.
31
+ def add(entry:)
32
+ self.entries = entries + [ entry ]
33
+ schedule(at: entry.fetch("wait_until")).deliver
34
+ end
35
+ ```
36
+
37
+ Two entries leave one reminder. The earlier wake-up never happens, nothing
38
+ raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
39
+
40
+ ## An alarm per item, with `key:`
41
+
42
+ Pass `key:` when an actor is waiting on several things at once. The key is your
43
+ own identifier for the item, and it names that item's alarm, so each item gets
44
+ one:
45
+
46
+ ```ruby
47
+ def add(entry:)
48
+ self.entries = entries + [ entry ]
49
+ schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
50
+ end
51
+ ```
52
+
53
+ Two entries now leave two reminders. Scheduling the same key again moves that
54
+ item's alarm and leaves the others alone, so a keyed reminder is as safe to
55
+ re-arm as an unkeyed one. The operation still decides which handler runs; the
56
+ key only decides which alarm is which.
57
+
58
+ A key must be non-empty, and the name it becomes must fit the 191-character
59
+ column, which is checked on the composed name rather than the key alone so a
60
+ long operation and a short key are caught too.
61
+
62
+ The key is separated from the operation by a colon, so an operation may not hold
63
+ one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be
64
+ one name, and the second would silently take the first one's alarm. A key may
65
+ hold colons of its own, because the operation before the first one cannot.
66
+
67
+ ## One alarm for a whole queue
68
+
69
+ A key per item is not always what you want. An actor that only ever needs to
70
+ know "what is next" can keep one alarm and let the handler drain everything now
71
+ due before arming the next:
72
+
73
+ ```ruby
74
+ def add(entry:)
75
+ self.entries = (entries + [ entry ]).sort_by { |item| item.fetch("wait_until") }
76
+ arm_next
77
+ end
78
+
79
+ def deliver
80
+ now = Time.current.to_i
81
+ due, pending = entries.partition { |item| item.fetch("wait_until") <= now }
82
+ due.each { |item| emit :send_push, **item.symbolize_keys }
83
+ self.entries = pending
84
+ arm_next
85
+ end
86
+
87
+ private
88
+
89
+ def arm_next
90
+ earliest = entries.first
91
+ return unless earliest
92
+
93
+ schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
94
+ end
95
+ ```
96
+
97
+ That costs one reminder row instead of one per item, and a coalesced occurrence
98
+ cannot strand an entry because the handler drains by time rather than by alarm.
99
+ Prefer it when the queue is large and the items are interchangeable; prefer
100
+ `key:` when an item needs its own alarm that can be moved on its own.
101
+
102
+ Solid Objects has no `unschedule`. A reminder stops when its handler does not
103
+ re-arm it, and destroying an actor removes its reminders.
104
+
105
+ Self-scheduling actors should also have a low-frequency application reconciler.
106
+ It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
107
+ `.orphaned`, but every repair must go through `async`. Never bulk-update actor
108
+ state around the lease and fencing checks.
109
+
110
+ Suspended actors should be reported rather than silently resumed. Spread large
111
+ repair batches with `available_at:` so reconciliation cannot stampede one
112
+ mailbox or the worker fleet.
@@ -472,7 +472,7 @@ The current `cardmagic/classifier` source uses RBS::Inline directly in Ruby file
472
472
  - [`.github/workflows/ruby.yml`](https://github.com/cardmagic/classifier/blob/48cdfa63f3efdba8149c8f47dd053ceebce5dfc1/.github/workflows/ruby.yml) generates signatures, validates them with RBS, and runs Steep.
473
473
  - [`Steepfile`](https://github.com/cardmagic/classifier/blob/48cdfa63f3efdba8149c8f47dd053ceebce5dfc1/Steepfile) enables strict diagnostics for the typed library while explicitly isolating incompatible extension files.
474
474
 
475
- Solid Objects will use the same source-adjacent convention. Every owned Ruby source file starts with `# rbs_inline: enabled`, declares its instance variables, and annotates public and private methods. Generated signatures are checked rather than hand-maintained as a competing source of truth.
475
+ Solid Objects will use the same source-adjacent convention. Every owned Ruby source file starts with `# rbs_inline: enabled`, declares its instance variables, and annotates public and private methods. The build checks the generated signatures. Nobody maintains them by hand as a second authority.
476
476
 
477
477
  ## Related primary-source findings
478
478
 
data/docs/roadmap.md CHANGED
@@ -134,7 +134,15 @@
134
134
  loads them in every process, and a rejected subscription reports which
135
135
  condition caused it instead of closing the socket silently.
136
136
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
137
- distributed per-actor rate limits and global admission control do not.
137
+ distributed per-actor rate limits and global admission control do not. The
138
+ state cap is a limit rather than an operating point. `max_state_bytes`
139
+ defaults to 5 MB, and committed throughput measured on SQLite falls about 53
140
+ times between an empty state and 1 MB of state, which `docs/benchmarks.md`
141
+ records. A soft `warn_state_bytes` threshold, 64 KB by default, now
142
+ reports each commit above it as `solid_objects.state.large`. The hard default
143
+ stays at 5 MB, because lowering it would break an application whose actors
144
+ already exceed a lower value; a major release can lower it from the measured
145
+ curve.
138
146
  - Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
139
147
  instances, mailbox, reminders, effects, broadcasts, dead letters, and
140
148
  processes, with actor-type and actor-id filtering, status filters, paging, a
@@ -15,6 +15,7 @@ module SolidObjects
15
15
  # @rbs @claim_scan_limit: Integer
16
16
  # @rbs @max_payload_bytes: Integer
17
17
  # @rbs @max_state_bytes: Integer
18
+ # @rbs @warn_state_bytes: Integer
18
19
  # @rbs @max_result_bytes: Integer
19
20
  # @rbs @max_attempts: Integer
20
21
  # @rbs @retry_delay: Proc
@@ -63,6 +64,7 @@ module SolidObjects
63
64
  :claim_scan_limit,
64
65
  :max_payload_bytes,
65
66
  :max_state_bytes,
67
+ :warn_state_bytes,
66
68
  :max_result_bytes,
67
69
  :max_attempts,
68
70
  :retry_delay,
@@ -116,6 +118,7 @@ module SolidObjects
116
118
  @claim_scan_limit = 100
117
119
  @max_payload_bytes = 1.megabyte
118
120
  @max_state_bytes = 5.megabytes
121
+ @warn_state_bytes = 64.kilobytes
119
122
  @max_result_bytes = 1.megabyte
120
123
  @max_attempts = 5
121
124
  @retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
@@ -223,6 +226,9 @@ module SolidObjects
223
226
  positive_values.each do |name, value|
224
227
  raise ArgumentError, "#{name} must be positive" unless value.positive?
225
228
  end
229
+ if warn_state_bytes > max_state_bytes
230
+ raise ArgumentError, "warn_state_bytes must not exceed max_state_bytes"
231
+ end
226
232
  message_retention_by_actor_type.each do |actor_type, retention|
227
233
  raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
228
234
  raise ArgumentError, "message retention must be positive" unless retention.positive?
@@ -260,6 +266,7 @@ module SolidObjects
260
266
  claim_scan_limit:,
261
267
  max_payload_bytes:,
262
268
  max_state_bytes:,
269
+ warn_state_bytes:,
263
270
  max_result_bytes:,
264
271
  max_attempts:,
265
272
  process_heartbeat_interval:,
@@ -25,9 +25,15 @@ module SolidObjects
25
25
 
26
26
  SolidObjects.instrument(:"message.started", **instrumentation_payload)
27
27
  result = invoke_actor(message_context)
28
- ensure_query_did_not_mutate_state!(state_before)
29
28
  observable_changes = changed_observables(observables_before, actor.observable_values)
30
- complete(result, observable_changes, state_changed: actor.state.to_h != state_before)
29
+ state_after = actor.state.to_h
30
+ ensure_query_did_not_mutate_state!(state_before, state_after)
31
+ complete(
32
+ result,
33
+ observable_changes,
34
+ state_after:,
35
+ state_changed: state_after != state_before
36
+ )
31
37
  true
32
38
  rescue LostActivation
33
39
  raise
@@ -59,11 +65,11 @@ module SolidObjects
59
65
  end
60
66
  end
61
67
 
62
- # @rbs (Hash[String, untyped]) -> void
63
- def ensure_query_did_not_mutate_state!(state_before)
68
+ # @rbs (Hash[String, untyped], Hash[String, untyped]) -> void
69
+ def ensure_query_did_not_mutate_state!(state_before, state_after)
64
70
  return unless message.delivery_mode == "sync"
65
71
  return unless actor.class.definition.queries.key?(message.operation.to_sym)
66
- return if actor.state.to_h == state_before
72
+ return if state_after == state_before
67
73
 
68
74
  raise InvalidActor, "query #{message.operation.inspect} mutated actor state"
69
75
  end
@@ -75,10 +81,10 @@ module SolidObjects
75
81
  end
76
82
  end
77
83
 
78
- # @rbs (untyped, Hash[String, untyped], state_changed: bool) -> void
79
- def complete(result, observable_changes, state_changed:)
80
- serialized_state = Serialization.dump(
81
- actor.state.to_h,
84
+ # @rbs (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
85
+ def complete(result, observable_changes, state_after:, state_changed:)
86
+ dumped_state = Serialization.dump_with_byte_size(
87
+ state_after,
82
88
  max_bytes: SolidObjects.configuration.max_state_bytes
83
89
  )
84
90
  serialized_result = Serialization.dump(
@@ -102,7 +108,7 @@ module SolidObjects
102
108
  locked_message = Message.lock.find(message.id)
103
109
  execute_commit_actions(commit_action_intents)
104
110
  instance.update!(
105
- state: serialized_state,
111
+ state: dumped_state.value,
106
112
  state_version: actor.class.state_version,
107
113
  state_revision: locked_message.sequence,
108
114
  last_used_at: SolidObjects.database_adapter.database_now
@@ -129,17 +135,17 @@ module SolidObjects
129
135
  end
130
136
 
131
137
  observable_changes.each_key do |observable_name|
132
- SolidObjects.instrument(
138
+ SolidObjects.instrument_after_commit(
133
139
  :"broadcast.enqueued",
134
140
  **instrumentation_payload,
135
141
  observable_name:
136
142
  )
137
143
  end
138
144
  moved_reminders.each do |moved|
139
- SolidObjects.instrument(:"reminder.replaced", **moved)
145
+ SolidObjects.instrument_after_commit(:"reminder.replaced", **moved)
140
146
  end
141
147
  enqueued_effects.each do |effect|
142
- SolidObjects.instrument(
148
+ SolidObjects.instrument_after_commit(
143
149
  :"effect.enqueued",
144
150
  effect_id: effect.effect_id,
145
151
  effect_name: effect.name,
@@ -148,10 +154,25 @@ module SolidObjects
148
154
  actor_id: message.actor_id
149
155
  )
150
156
  end
151
- SolidObjects.instrument(:"message.completed", **instrumentation_payload)
157
+ report_large_state(dumped_state.byte_size)
158
+ SolidObjects.instrument_after_commit(:"message.completed", **instrumentation_payload)
152
159
  SolidObjects.wake_up.signal
153
160
  end
154
161
 
162
+ # @rbs (Integer) -> void
163
+ def report_large_state(byte_count)
164
+ threshold = SolidObjects.configuration.warn_state_bytes
165
+ return if byte_count <= threshold
166
+
167
+ SolidObjects.instrument_after_commit(
168
+ :"state.large",
169
+ actor_type: message.actor_type,
170
+ actor_id: message.actor_id,
171
+ byte_count:,
172
+ threshold_bytes: threshold
173
+ )
174
+ end
175
+
155
176
  # @rbs (Array[Actor::CommitActionIntent]) -> void
156
177
  def execute_commit_actions(intents)
157
178
  ensure_application_database_is_shared! if intents.any?
@@ -352,7 +373,7 @@ module SolidObjects
352
373
  end
353
374
  end
354
375
 
355
- SolidObjects.instrument(
376
+ SolidObjects.instrument_after_commit(
356
377
  :"message.failed",
357
378
  **instrumentation_payload,
358
379
  error_class: error.class.name,
@@ -387,7 +408,7 @@ module SolidObjects
387
408
  claimed_message.destroy!
388
409
  end
389
410
 
390
- SolidObjects.instrument(
411
+ SolidObjects.instrument_after_commit(
391
412
  :"message.rejected",
392
413
  **instrumentation_payload,
393
414
  code: rejection.code
@@ -6,5 +6,38 @@ module SolidObjects
6
6
  def instrument(event, **payload, &block)
7
7
  ActiveSupport::Notifications.instrument("solid_objects.#{event}", payload, &block)
8
8
  end
9
+
10
+ # @rbs (Symbol, **untyped) -> void
11
+ def instrument_after_commit(event, **payload)
12
+ instrument(event, **payload)
13
+ rescue => error
14
+ report_instrumentation_failure(event, error)
15
+ end
16
+
17
+ private
18
+
19
+ # @rbs (Symbol, Exception) -> void
20
+ def report_instrumentation_failure(event, error)
21
+ instrument(
22
+ :"instrumentation.failed",
23
+ instrumentation_event: "solid_objects.#{event}",
24
+ error_class: error.class.name
25
+ )
26
+ rescue => failure
27
+ log_instrumentation_failure(event, failure)
28
+ end
29
+
30
+ # @rbs (Symbol, Exception) -> void
31
+ def log_instrumentation_failure(event, error)
32
+ SolidObjects.configuration.logger.error(
33
+ {
34
+ event: "solid_objects.instrumentation.failed",
35
+ instrumentation_event: "solid_objects.#{event}",
36
+ error_class: error.class.name
37
+ }
38
+ )
39
+ rescue
40
+ nil
41
+ end
9
42
  end
10
43
  end
@@ -4,17 +4,26 @@ module SolidObjects
4
4
  module Serialization
5
5
  MAX_NESTING = 100
6
6
 
7
+ Dumped = Data.define(:value, :byte_size)
8
+
7
9
  class << self
8
10
  # @rbs (untyped, ?max_bytes: Integer?) -> untyped
9
11
  def dump(value, max_bytes: nil)
12
+ return normalize(value) unless max_bytes
13
+
14
+ dump_with_byte_size(value, max_bytes:).value
15
+ end
16
+
17
+ # @rbs (untyped, ?max_bytes: Integer?) -> Dumped
18
+ def dump_with_byte_size(value, max_bytes: nil)
10
19
  normalized = normalize(value)
11
- encoded = JSON.generate(normalized, max_nesting: MAX_NESTING)
20
+ byte_size = JSON.generate(normalized, max_nesting: MAX_NESTING).bytesize
12
21
 
13
- if max_bytes && encoded.bytesize > max_bytes
22
+ if max_bytes && byte_size > max_bytes
14
23
  raise PayloadTooLarge, "serialized value exceeds #{max_bytes} bytes"
15
24
  end
16
25
 
17
- normalized
26
+ Dumped.new(value: normalized, byte_size:)
18
27
  rescue JSON::GeneratorError, EncodingError => error
19
28
  raise InvalidPayload, error.message
20
29
  end
@@ -60,7 +69,11 @@ module SolidObjects
60
69
  raise InvalidPayload, "serialized value is nested too deeply" if depth > MAX_NESTING
61
70
 
62
71
  case value
63
- when nil, true, false, String, Integer
72
+ when nil, true, false, Integer
73
+ value
74
+ when String
75
+ raise InvalidPayload, "strings must hold valid #{value.encoding} bytes" unless value.valid_encoding?
76
+
64
77
  value
65
78
  when Float
66
79
  raise InvalidPayload, "non-finite numbers are not supported" unless value.finite?
@@ -89,7 +102,7 @@ module SolidObjects
89
102
 
90
103
  # @rbs (untyped) -> String
91
104
  def normalize_key(key)
92
- return key if key.is_a?(String)
105
+ return normalize(key) if key.is_a?(String)
93
106
  return key.to_s if key.is_a?(Symbol)
94
107
 
95
108
  raise InvalidPayload, "JSON object keys must be strings or symbols"
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.14.1"
4
+ VERSION = "0.14.3"
5
5
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Configuration
5
- @table_name_prefix: String
5
+ @process_alive_threshold: Float
6
6
 
7
7
  @shutdown_timeout: Float
8
8
 
@@ -60,6 +60,8 @@ module SolidObjects
60
60
 
61
61
  @transmission_actor_type_resolver: Proc
62
62
 
63
+ @table_name_prefix: String
64
+
63
65
  @polling_interval: Float
64
66
 
65
67
  @idle_polling_interval: Float
@@ -84,6 +86,8 @@ module SolidObjects
84
86
 
85
87
  @max_state_bytes: Integer
86
88
 
89
+ @warn_state_bytes: Integer
90
+
87
91
  @max_result_bytes: Integer
88
92
 
89
93
  @max_attempts: Integer
@@ -94,8 +98,6 @@ module SolidObjects
94
98
 
95
99
  @process_heartbeat_interval: Float
96
100
 
97
- @process_alive_threshold: Float
98
-
99
101
  attr_accessor table_name_prefix: untyped
100
102
 
101
103
  attr_accessor polling_interval: untyped
@@ -122,6 +124,8 @@ module SolidObjects
122
124
 
123
125
  attr_accessor max_state_bytes: untyped
124
126
 
127
+ attr_accessor warn_state_bytes: untyped
128
+
125
129
  attr_accessor max_result_bytes: untyped
126
130
 
127
131
  attr_accessor max_attempts: untyped
@@ -24,14 +24,17 @@ module SolidObjects
24
24
  # @rbs (MessageContext) -> untyped
25
25
  def invoke_actor: (MessageContext) -> untyped
26
26
 
27
- # @rbs (Hash[String, untyped]) -> void
28
- def ensure_query_did_not_mutate_state!: (Hash[String, untyped]) -> void
27
+ # @rbs (Hash[String, untyped], Hash[String, untyped]) -> void
28
+ def ensure_query_did_not_mutate_state!: (Hash[String, untyped], Hash[String, untyped]) -> void
29
29
 
30
30
  # @rbs (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
31
31
  def changed_observables: (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
32
32
 
33
- # @rbs (untyped, Hash[String, untyped], state_changed: bool) -> void
34
- def complete: (untyped, Hash[String, untyped], state_changed: bool) -> void
33
+ # @rbs (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
34
+ def complete: (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
35
+
36
+ # @rbs (Integer) -> void
37
+ def report_large_state: (Integer) -> void
35
38
 
36
39
  # @rbs (Array[Actor::CommitActionIntent]) -> void
37
40
  def execute_commit_actions: (Array[Actor::CommitActionIntent]) -> void
@@ -4,5 +4,16 @@ module SolidObjects
4
4
  module Instrumentation
5
5
  # @rbs (Symbol, **untyped) { (Hash[Symbol, untyped]) -> untyped } -> untyped
6
6
  def instrument: (Symbol, **untyped) { (Hash[Symbol, untyped]) -> untyped } -> untyped
7
+
8
+ # @rbs (Symbol, **untyped) -> void
9
+ def instrument_after_commit: (Symbol, **untyped) -> void
10
+
11
+ private
12
+
13
+ # @rbs (Symbol, Exception) -> void
14
+ def report_instrumentation_failure: (Symbol, Exception) -> void
15
+
16
+ # @rbs (Symbol, Exception) -> void
17
+ def log_instrumentation_failure: (Symbol, Exception) -> void
7
18
  end
8
19
  end
@@ -4,9 +4,25 @@ module SolidObjects
4
4
  module Serialization
5
5
  MAX_NESTING: ::Integer
6
6
 
7
+ class Dumped < Data
8
+ attr_reader value(): untyped
9
+
10
+ attr_reader byte_size(): untyped
11
+
12
+ def self.new: (untyped value, untyped byte_size) -> instance
13
+ | (value: untyped, byte_size: untyped) -> instance
14
+
15
+ def self.members: () -> [ :value, :byte_size ]
16
+
17
+ def members: () -> [ :value, :byte_size ]
18
+ end
19
+
7
20
  # @rbs (untyped, ?max_bytes: Integer?) -> untyped
8
21
  def self.dump: (untyped, ?max_bytes: Integer?) -> untyped
9
22
 
23
+ # @rbs (untyped, ?max_bytes: Integer?) -> Dumped
24
+ def self.dump_with_byte_size: (untyped, ?max_bytes: Integer?) -> Dumped
25
+
10
26
  # @rbs (untyped) -> untyped
11
27
  def self.load: (untyped) -> untyped
12
28
 
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.14.1
4
+ version: 0.14.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-24 00:00:00.000000000 Z
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -323,6 +323,7 @@ files:
323
323
  - benchmark/idle_polling.rb
324
324
  - benchmark/processing.rb
325
325
  - benchmark/query_count.rb
326
+ - benchmark/state_size.rb
326
327
  - benchmark/support.rb
327
328
  - benchmark/sync_latency.rb
328
329
  - config/routes.rb
@@ -355,6 +356,7 @@ files:
355
356
  - docs/migrating-existing-state.md
356
357
  - docs/operations.md
357
358
  - docs/realtime.md
359
+ - docs/reminders.md
358
360
  - docs/research/solid_queue.md
359
361
  - docs/roadmap.md
360
362
  - docs/security.md