solid_objects 0.5.0 → 0.5.1

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: 4f761acc1e99fba4f747cddd191fc0547ee80c7a30f2e03859f24ff22ab0dd70
4
- data.tar.gz: 4ed7f4a52926a5276fa04804ce8fbc142a0c4d9b70641b23b27dc07175956b5e
3
+ metadata.gz: 9e251e3648db71bc5e325e1d8a20be55f927ca08208052acebd973eb78e15497
4
+ data.tar.gz: 9362cf7145cb58c02273411dc619a775d7693d238d3f2212d7c4f6336abc30cb
5
5
  SHA512:
6
- metadata.gz: 0c63ed0b033d8028041c51c08f4d0bdb8667ab8560d469907474327bbc1536ea561ce00b062ae38f56fc72ded1e9498ecffc55d77bdc4efeaad2d6ffa8953e25
7
- data.tar.gz: 9aaa0d27ab02b23c4aa95b05cec82ea4463851f51d2ae9d778058aa93fbaa3a7b6e0c66c3ba3b52efbd3ecefa5079f23bf9d37fe12a9481b0b8b65ecc6b2847b
6
+ metadata.gz: de728511cbd174ba841030f3e29e3dd4245ccd4dd8168523bf132100a8e4ee0c14f02781bf0b29c08a184f3688708f01d8227662068e6a25ebac1ea2b948c72e
7
+ data.tar.gz: a9d34e2ae1defd4288bd9322b3d717ae1fbcc99836f96d76ae2a09d62355e17349089e4b53d9febcefe98aa788c5753b5abcbeee1c9dce08e6968b38297f336e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 - 2026-08-07
4
+
5
+ - Restore the SQLite busy wait that a synchronous invocation suspends for its
6
+ deadline. Rails installs the busy wait as a Ruby busy handler through the
7
+ sqlite3 `timeout` configuration, which `PRAGMA busy_timeout` reports as zero
8
+ and silently replaces, so the previous save and restore left pooled
9
+ connections with no busy handler at all. Every later writer on that
10
+ connection, inside or outside Solid Objects, then failed immediately with
11
+ `SQLite3::BusyException` instead of waiting for the lock. Suspend the busy
12
+ wait only when the adapter can identify how to restore it, so an Active
13
+ Record release that stops exposing the configured timeout loosens
14
+ synchronous deadline bounds instead of stripping lock waiting from a shared
15
+ pooled connection.
16
+
17
+ - Run the doctor round-trip probe on a dedicated caller process, and accept an
18
+ explicit process registry in `SynchronousInvocation`, so the probe can no
19
+ longer stop and delete a shared application caller process, release its
20
+ activations, and unclaim its messages.
21
+ - Report doctor probe cleanup failures as a failed or warned check instead of
22
+ raising a database lock error out of the command and leaking the probe
23
+ caller process.
24
+ - Instrument component refreshes with actor identity, component name, key,
25
+ dependencies, refresh method, revision, and outcome, excluding locals.
26
+
3
27
  ## 0.5.0 - 2026-08-07
4
28
 
5
29
  - Add repeatable reactive components with signed string or integer keys and
@@ -8,12 +8,25 @@ module SolidObjects
8
8
 
9
9
  # @rbs () -> void
10
10
  def show
11
+ SolidObjects.instrument(:"component.refreshed") { |payload| refresh(payload) }
12
+ end
13
+
14
+ private
15
+
16
+ # @rbs (Hash[Symbol, untyped]) -> void
17
+ def refresh(payload)
11
18
  registration = ComponentRegistration.from_token(
12
19
  params.require(:token)
13
20
  )
21
+ payload.merge!(registration_payload(registration))
14
22
  requested_revision = requested_revision_key
15
23
  snapshot = ActorSnapshot.new(registration.reference)
16
- return head :conflict if newer_than_snapshot?(requested_revision, snapshot)
24
+ payload[:instance_id] = snapshot.instance_id
25
+ payload[:revision] = snapshot.revision
26
+ if newer_than_snapshot?(requested_revision, snapshot)
27
+ payload[:outcome] = "conflict"
28
+ return head :conflict
29
+ end
17
30
 
18
31
  authorization_context = SolidObjects
19
32
  .configuration
@@ -26,18 +39,32 @@ module SolidObjects
26
39
  authorization_context:
27
40
  ).call
28
41
  response.headers["Cache-Control"] = "private, no-store"
42
+ payload[:outcome] = "rendered"
29
43
  render html: component_frame(registration, snapshot, rendered)
30
44
  rescue Unauthorized
45
+ payload[:outcome] = "unauthorized"
31
46
  head :forbidden
32
47
  rescue UnknownComponent
48
+ payload[:outcome] = "unknown_component"
33
49
  head :not_found
34
50
  rescue ActionController::ParameterMissing,
35
51
  ArgumentError,
36
52
  InvalidComponentToken
53
+ payload[:outcome] = "invalid_token"
37
54
  head :bad_request
38
55
  end
39
56
 
40
- private
57
+ # @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
58
+ def registration_payload(registration)
59
+ {
60
+ actor_type: registration.reference.actor_type,
61
+ actor_id: registration.reference.actor_id,
62
+ component_name: registration.component_name,
63
+ component_key: registration.component_key,
64
+ dependencies: registration.dependencies,
65
+ refresh_method: registration.refresh_method
66
+ }
67
+ end
41
68
 
42
69
  # @rbs () -> Array[Integer]
43
70
  def requested_revision_key
data/docs/correctness.md CHANGED
@@ -167,7 +167,21 @@ result. Adapter lock/query deadlines cover the durable enqueue, caller-process
167
167
  registration and heartbeat, activation coordination, and result observation.
168
168
  SQLite retries busy coordination operations only within the original call
169
169
  deadline and reports `waiting_on=database_contention` when the database cannot
170
- be inspected at timeout. If enqueue cannot commit, `SyncEnqueueTimeout` is
170
+ be inspected at timeout. To keep those retries in Ruby, the SQLite adapter
171
+ suspends the connection's busy wait for the duration of each deadline-bound
172
+ transaction and restores it afterwards. Restoration reinstalls the Ruby busy
173
+ handler Rails configures from the sqlite3 `timeout` setting, which
174
+ `PRAGMA busy_timeout` neither reports nor preserves, so a synchronous call
175
+ leaves the connection's lock waiting behaviour exactly as it found it for
176
+ later writers inside and outside Solid Objects.
177
+
178
+ The adapter suspends the busy wait only when it can identify how to restore
179
+ it. When a future Active Record release stops exposing the configured
180
+ timeout, the adapter leaves the connection untouched: synchronous deadlines
181
+ lose their tight bound and wait as long as the configured busy wait allows,
182
+ rather than stripping lock waiting from a pooled connection the rest of the
183
+ application shares. A test asserts the timeout stays discoverable so the
184
+ looser bound cannot be adopted silently. If enqueue cannot commit, `SyncEnqueueTimeout` is
171
185
  raised and no message reference exists. MySQL lock waits have one-second InnoDB
172
186
  granularity. Ruby handlers that already started are not preempted.
173
187
 
data/docs/operations.md CHANGED
@@ -15,6 +15,14 @@ so the schema check compares the required shape instead of a fixed timestamp.
15
15
  Warnings such as an all-deny neutral policy do not fail the command because a
16
16
  context-aware production policy may correctly deny the probe.
17
17
 
18
+ The round-trip probe runs on its own dedicated caller process rather than the
19
+ shared application caller process, and removes that record together with its
20
+ temporary actor when it finishes. Running the doctor inside a process that
21
+ already serves synchronous calls therefore leaves the application caller
22
+ process, its activations, and its claimed messages untouched, including when an
23
+ application call overlaps the probe. A database busy enough to block cleanup
24
+ reports a failed or warned check rather than raising out of the command.
25
+
18
26
  ## Runtime
19
27
 
20
28
  Start all configured roles:
@@ -147,10 +155,20 @@ transaction rejection, commit-action start/completion/failure, effect and
147
155
  broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
148
156
  retention pruning, process cleanup, and supervisor lifecycle.
149
157
 
158
+ `solid_objects.component.refreshed` covers every authorized component refresh
159
+ request. Its payload carries the actor identity, `component_name`,
160
+ `component_key`, declared `dependencies`, `refresh_method`, the rendered
161
+ `instance_id` and `revision`, and an `outcome` of `rendered`, `conflict`,
162
+ `unauthorized`, `unknown_component`, or `invalid_token`. Use it to watch
163
+ refresh rate per key, authorization denials, superseded requests, and render
164
+ duration. A rejected token reports only the outcome, since no signed identity
165
+ was recovered.
166
+
150
167
  Payloads contain stable runtime identifiers, actor identity, sequence,
151
168
  attempts, ownership generations, and safe exception summaries where relevant.
152
- Arguments, actor state, results, and outbox payloads are excluded. The bundled
153
- log subscriber turns the same notifications into structured logger hashes.
169
+ Arguments, component locals, actor state, results, and outbox payloads are
170
+ excluded. The bundled log subscriber turns the same notifications into
171
+ structured logger hashes.
154
172
 
155
173
  ## Retention and backups
156
174
 
@@ -67,11 +67,49 @@ module SolidObjects
67
67
  def with_transaction_deadline(connection)
68
68
  return yield unless SyncDeadline.active?
69
69
 
70
- previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i
71
- connection.execute("PRAGMA busy_timeout = 0")
72
- yield
73
- ensure
74
- connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout
70
+ busy_wait = restorable_busy_wait(connection)
71
+ return yield unless busy_wait
72
+
73
+ begin
74
+ connection.execute("PRAGMA busy_timeout = 0")
75
+ yield
76
+ ensure
77
+ restore_busy_wait(connection, busy_wait)
78
+ end
79
+ end
80
+
81
+ # @rbs (untyped) -> Hash[Symbol, untyped]?
82
+ def restorable_busy_wait(connection)
83
+ pragma_timeout = connection.select_value("PRAGMA busy_timeout").to_i
84
+ return { pragma_timeout: } if pragma_timeout.positive?
85
+
86
+ handler_timeout = configured_busy_handler_timeout(connection)
87
+ return nil unless handler_timeout
88
+
89
+ { pragma_timeout:, handler_timeout: }
90
+ end
91
+
92
+ # @rbs (untyped, Hash[Symbol, untyped]) -> void
93
+ def restore_busy_wait(connection, busy_wait)
94
+ handler_timeout = busy_wait[:handler_timeout]
95
+ if handler_timeout
96
+ connection.raw_connection.busy_handler_timeout = handler_timeout
97
+ return
98
+ end
99
+
100
+ connection.execute("PRAGMA busy_timeout = #{busy_wait.fetch(:pragma_timeout)}")
101
+ end
102
+
103
+ # @rbs (untyped) -> Integer?
104
+ def configured_busy_handler_timeout(connection)
105
+ return nil unless connection.respond_to?(:raw_connection)
106
+ return nil unless connection.raw_connection.respond_to?(:busy_handler_timeout=)
107
+
108
+ pool = connection.respond_to?(:pool) ? connection.pool : nil
109
+ return nil unless pool.respond_to?(:db_config)
110
+
111
+ timeout = pool.db_config.configuration_hash[:timeout]
112
+ timeout&.to_i
75
113
  end
76
114
 
77
115
  # @rbs (Exception) -> bool
@@ -215,25 +215,63 @@ module SolidObjects
215
215
  # @rbs () -> Check
216
216
  def check_sync_round_trip
217
217
  actor_id = SecureRandom.uuid
218
+ probe_registry = ProcessRegistry.new
219
+ check = run_sync_probe(actor_id, probe_registry)
220
+ leftovers = remove_probe_records(actor_id:, probe_registry:)
221
+ return check if leftovers.empty? || check.failed?
222
+
223
+ warn_check(
224
+ :sync_round_trip,
225
+ "#{check.message}; could not remove the #{leftovers.join(" and ")}"
226
+ )
227
+ end
228
+
229
+ # @rbs (String, ProcessRegistry) -> Check
230
+ def run_sync_probe(actor_id, probe_registry)
231
+ probe_registry.register(kind: "caller", metadata: { execution: "doctor" })
218
232
  value = SecureRandom.hex(8)
219
- process_registry = SolidObjects.caller_process.process_registry
220
- reference = ProbeActor.ref(actor_id)
221
233
  message_reference = Mailbox.new.enqueue(
222
- reference,
234
+ ProbeActor.ref(actor_id),
223
235
  :ping,
224
236
  { value: },
225
237
  kind: "sync"
226
238
  )
227
- result = SynchronousInvocation.new.call(message_reference, timeout: 5.seconds)
239
+ result = SynchronousInvocation
240
+ .new(process_registry: probe_registry)
241
+ .call(message_reference, timeout: 5.seconds)
228
242
  raise Error, "unexpected round-trip result" unless result == value
229
243
 
230
244
  pass(:sync_round_trip, "durable synchronous actor call completed without a worker")
231
245
  rescue => error
232
246
  fail_check(:sync_round_trip, "#{error.class}: #{error.message}")
233
- ensure
234
- Instance.where(actor_type: ProbeActor.actor_type, actor_id:).delete_all if actor_id
235
- process_registry&.stop
236
- process_registry&.process_record&.delete
247
+ end
248
+
249
+ # @rbs (actor_id: String, probe_registry: ProcessRegistry) -> Array[String]
250
+ def remove_probe_records(actor_id:, probe_registry:)
251
+ leftovers = []
252
+ leftovers << "probe actor" unless delete_probe_actor(actor_id)
253
+ leftovers << "probe caller process" unless delete_probe_caller_process(probe_registry)
254
+ leftovers
255
+ end
256
+
257
+ # @rbs (String) -> bool
258
+ def delete_probe_actor(actor_id)
259
+ Instance.where(actor_type: ProbeActor.actor_type, actor_id:).delete_all
260
+ true
261
+ rescue
262
+ false
263
+ end
264
+
265
+ # @rbs (ProcessRegistry) -> bool
266
+ def delete_probe_caller_process(probe_registry)
267
+ process_record = probe_registry.process_record
268
+ return true unless process_record
269
+
270
+ probe_registry.stop
271
+ process_record.delete
272
+ true
273
+ rescue
274
+ false
237
275
  end
238
276
 
239
277
  # @rbs (Check, Check) -> bool
@@ -6,6 +6,13 @@ require "solid_objects/sync_diagnostics"
6
6
 
7
7
  module SolidObjects
8
8
  class SynchronousInvocation
9
+ # @rbs @dedicated_process_registry: ProcessRegistry?
10
+
11
+ # @rbs (?process_registry: ProcessRegistry?) -> void
12
+ def initialize(process_registry: nil)
13
+ @dedicated_process_registry = process_registry
14
+ end
15
+
9
16
  # @rbs (MessageReference, timeout: Numeric) -> untyped
10
17
  def call(message_reference, timeout:)
11
18
  return call_before_deadline(message_reference, timeout:) if SyncDeadline.active?
@@ -92,9 +99,17 @@ module SolidObjects
92
99
  )
93
100
  end
94
101
 
102
+ # @rbs () -> ProcessRegistry
103
+ def process_registry
104
+ dedicated_registry = @dedicated_process_registry
105
+ return SolidObjects.caller_process.process_registry unless dedicated_registry
106
+
107
+ dedicated_registry.tap(&:heartbeat)
108
+ end
109
+
95
110
  # @rbs (Message, deadline: Float) -> Integer
96
111
  def assist(message, deadline:)
97
- process_registry = SolidObjects.caller_process.process_registry
112
+ process_registry = self.process_registry
98
113
  activation = ActivationManager
99
114
  .new(owner_id: process_registry.process_record.id)
100
115
  .claim(instance_id: message.instance_id)
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -7,6 +7,12 @@ module SolidObjects
7
7
 
8
8
  private
9
9
 
10
+ # @rbs (Hash[Symbol, untyped]) -> void
11
+ def refresh: (Hash[Symbol, untyped]) -> void
12
+
13
+ # @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
14
+ def registration_payload: (ComponentRegistration) -> Hash[Symbol, untyped]
15
+
10
16
  # @rbs () -> Array[Integer]
11
17
  def requested_revision_key: () -> Array[Integer]
12
18
 
@@ -26,6 +26,15 @@ module SolidObjects
26
26
  # @rbs (untyped) { () -> untyped } -> untyped
27
27
  def with_transaction_deadline: (untyped) { () -> untyped } -> untyped
28
28
 
29
+ # @rbs (untyped) -> Hash[Symbol, untyped]?
30
+ def restorable_busy_wait: (untyped) -> Hash[Symbol, untyped]?
31
+
32
+ # @rbs (untyped, Hash[Symbol, untyped]) -> void
33
+ def restore_busy_wait: (untyped, Hash[Symbol, untyped]) -> void
34
+
35
+ # @rbs (untyped) -> Integer?
36
+ def configured_busy_handler_timeout: (untyped) -> Integer?
37
+
29
38
  # @rbs (Exception) -> bool
30
39
  def deadline_error?: (Exception) -> bool
31
40
 
@@ -78,6 +78,18 @@ module SolidObjects
78
78
  # @rbs () -> Check
79
79
  def check_sync_round_trip: () -> Check
80
80
 
81
+ # @rbs (String, ProcessRegistry) -> Check
82
+ def run_sync_probe: (String, ProcessRegistry) -> Check
83
+
84
+ # @rbs (actor_id: String, probe_registry: ProcessRegistry) -> Array[String]
85
+ def remove_probe_records: (actor_id: String, probe_registry: ProcessRegistry) -> Array[String]
86
+
87
+ # @rbs (String) -> bool
88
+ def delete_probe_actor: (String) -> bool
89
+
90
+ # @rbs (ProcessRegistry) -> bool
91
+ def delete_probe_caller_process: (ProcessRegistry) -> bool
92
+
81
93
  # @rbs (Check, Check) -> bool
82
94
  def ready_for_round_trip?: (Check, Check) -> bool
83
95
 
@@ -2,6 +2,11 @@
2
2
 
3
3
  module SolidObjects
4
4
  class SynchronousInvocation
5
+ @dedicated_process_registry: ProcessRegistry?
6
+
7
+ # @rbs (?process_registry: ProcessRegistry?) -> void
8
+ def initialize: (?process_registry: ProcessRegistry?) -> void
9
+
5
10
  # @rbs (MessageReference, timeout: Numeric) -> untyped
6
11
  def call: (MessageReference, timeout: Numeric) -> untyped
7
12
 
@@ -22,6 +27,9 @@ module SolidObjects
22
27
  # @rbs (Message) -> bot
23
28
  def raise_rejection: (Message) -> bot
24
29
 
30
+ # @rbs () -> ProcessRegistry
31
+ def process_registry: () -> ProcessRegistry
32
+
25
33
  # @rbs (Message, deadline: Float) -> Integer
26
34
  def assist: (Message, deadline: Float) -> Integer
27
35
 
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.5.0
4
+ version: 0.5.1
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-07 00:00:00.000000000 Z
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable