Skip to content

Commit c6873d7

Browse files
authored
Merge pull request #6 from cardmagic/agent/doctor-cleanup-and-component-instrumentation
fix: scope doctor probe cleanup and instrument component refreshes
2 parents 5dd77ef + 16d4950 commit c6873d7

17 files changed

Lines changed: 501 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
# Changelog
22

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+
327
## 0.5.0 - 2026-08-07
428

529
- Add repeatable reactive components with signed string or integer keys and

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.5.0)
4+
solid_objects (0.5.1)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -373,7 +373,7 @@ CHECKSUMS
373373
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
374374
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
375375
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
376-
solid_objects (0.5.0)
376+
solid_objects (0.5.1)
377377
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
378378
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
379379
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

app/controllers/solid_objects/components_controller.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,25 @@ class ComponentsController < ActionController::Base
88

99
# @rbs () -> void
1010
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)
1118
registration = ComponentRegistration.from_token(
1219
params.require(:token)
1320
)
21+
payload.merge!(registration_payload(registration))
1422
requested_revision = requested_revision_key
1523
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
1730

1831
authorization_context = SolidObjects
1932
.configuration
@@ -26,18 +39,32 @@ def show
2639
authorization_context:
2740
).call
2841
response.headers["Cache-Control"] = "private, no-store"
42+
payload[:outcome] = "rendered"
2943
render html: component_frame(registration, snapshot, rendered)
3044
rescue Unauthorized
45+
payload[:outcome] = "unauthorized"
3146
head :forbidden
3247
rescue UnknownComponent
48+
payload[:outcome] = "unknown_component"
3349
head :not_found
3450
rescue ActionController::ParameterMissing,
3551
ArgumentError,
3652
InvalidComponentToken
53+
payload[:outcome] = "invalid_token"
3754
head :bad_request
3855
end
3956

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
4168

4269
# @rbs () -> Array[Integer]
4370
def requested_revision_key

docs/correctness.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,21 @@ result. Adapter lock/query deadlines cover the durable enqueue, caller-process
167167
registration and heartbeat, activation coordination, and result observation.
168168
SQLite retries busy coordination operations only within the original call
169169
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
171185
raised and no message reference exists. MySQL lock waits have one-second InnoDB
172186
granularity. Ruby handlers that already started are not preempted.
173187

docs/operations.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ so the schema check compares the required shape instead of a fixed timestamp.
1515
Warnings such as an all-deny neutral policy do not fail the command because a
1616
context-aware production policy may correctly deny the probe.
1717

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+
1826
## Runtime
1927

2028
Start all configured roles:
@@ -147,10 +155,20 @@ transaction rejection, commit-action start/completion/failure, effect and
147155
broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
148156
retention pruning, process cleanup, and supervisor lifecycle.
149157

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+
150167
Payloads contain stable runtime identifiers, actor identity, sequence,
151168
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.
154172

155173
## Retention and backups
156174

lib/solid_objects/database_adapters/sqlite.rb

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,49 @@ def with_lock_probe
6767
def with_transaction_deadline(connection)
6868
return yield unless SyncDeadline.active?
6969

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
75113
end
76114

77115
# @rbs (Exception) -> bool

lib/solid_objects/doctor.rb

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -215,25 +215,63 @@ def check_runtime
215215
# @rbs () -> Check
216216
def check_sync_round_trip
217217
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" })
218232
value = SecureRandom.hex(8)
219-
process_registry = SolidObjects.caller_process.process_registry
220-
reference = ProbeActor.ref(actor_id)
221233
message_reference = Mailbox.new.enqueue(
222-
reference,
234+
ProbeActor.ref(actor_id),
223235
:ping,
224236
{ value: },
225237
kind: "sync"
226238
)
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)
228242
raise Error, "unexpected round-trip result" unless result == value
229243

230244
pass(:sync_round_trip, "durable synchronous actor call completed without a worker")
231245
rescue => error
232246
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
237275
end
238276

239277
# @rbs (Check, Check) -> bool

lib/solid_objects/synchronous_invocation.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
module SolidObjects
88
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+
916
# @rbs (MessageReference, timeout: Numeric) -> untyped
1017
def call(message_reference, timeout:)
1118
return call_before_deadline(message_reference, timeout:) if SyncDeadline.active?
@@ -92,9 +99,17 @@ def raise_rejection(message)
9299
)
93100
end
94101

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+
95110
# @rbs (Message, deadline: Float) -> Integer
96111
def assist(message, deadline:)
97-
process_registry = SolidObjects.caller_process.process_registry
112+
process_registry = self.process_registry
98113
activation = ActivationManager
99114
.new(owner_id: process_registry.process_record.id)
100115
.claim(instance_id: message.instance_id)

lib/solid_objects/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.5.0"
4+
VERSION = "0.5.1"
55
end

sig/generated/controllers/solid_objects/components_controller.rbs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ module SolidObjects
77

88
private
99

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+
1016
# @rbs () -> Array[Integer]
1117
def requested_revision_key: () -> Array[Integer]
1218

0 commit comments

Comments
 (0)