Skip to content

Commit d3bd7cf

Browse files
committed
fix: scope doctor probe cleanup
Stop the round-trip probe from deregistering and deleting a caller process the application registered, which released its activations and unclaimed its messages. Report cleanup failures as a failed or warned check instead of raising a database lock error out of the command. Instrument component refreshes with identity, key, dependencies, revision, and outcome.
1 parent 5dd77ef commit d3bd7cf

12 files changed

Lines changed: 306 additions & 14 deletions

File tree

CHANGELOG.md

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

3+
## 0.5.1 - 2026-08-07
4+
5+
- Keep the doctor round-trip probe from stopping and deleting a caller process
6+
the application registered, which released its activations and unclaimed its
7+
messages.
8+
- Report doctor probe cleanup failures as a failed or warned check instead of
9+
raising a database lock error out of the command and leaking the probe
10+
caller process.
11+
- Instrument component refreshes with actor identity, component name, key,
12+
dependencies, refresh method, revision, and outcome, excluding locals.
13+
314
## 0.5.0 - 2026-08-07
415

516
- 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/operations.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ 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 cleans up after itself. It removes its temporary actor,
19+
and removes the caller process record only when the probe registered it, so
20+
running the doctor inside a process that already serves synchronous calls
21+
leaves that caller process and its activations untouched. A database busy
22+
enough to block cleanup reports a failed or warned check rather than raising
23+
out of the command.
24+
1825
## Runtime
1926

2027
Start all configured roles:
@@ -147,10 +154,20 @@ transaction rejection, commit-action start/completion/failure, effect and
147154
broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
148155
retention pruning, process cleanup, and supervisor lifecycle.
149156

157+
`solid_objects.component.refreshed` covers every authorized component refresh
158+
request. Its payload carries the actor identity, `component_name`,
159+
`component_key`, declared `dependencies`, `refresh_method`, the rendered
160+
`instance_id` and `revision`, and an `outcome` of `rendered`, `conflict`,
161+
`unauthorized`, `unknown_component`, or `invalid_token`. Use it to watch
162+
refresh rate per key, authorization denials, superseded requests, and render
163+
duration. A rejected token reports only the outcome, since no signed identity
164+
was recovered.
165+
150166
Payloads contain stable runtime identifiers, actor identity, sequence,
151167
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.
168+
Arguments, component locals, actor state, results, and outbox payloads are
169+
excluded. The bundled log subscriber turns the same notifications into
170+
structured logger hashes.
154171

155172
## Retention and backups
156173

lib/solid_objects/caller_process.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ def process_registry
2626
end
2727
end
2828

29+
# @rbs () -> String?
30+
def process_record_id
31+
mutex.synchronize do
32+
reset_after_fork
33+
registry&.process_record&.id
34+
end
35+
end
36+
2937
# @rbs () -> bool
3038
def stop
3139
mutex.synchronize do
@@ -36,6 +44,20 @@ def stop
3644
end
3745
end
3846

47+
# @rbs (String) -> bool
48+
def delete_process_record(process_id)
49+
mutex.synchronize do
50+
reset_after_fork
51+
process_record = registry&.process_record
52+
return false unless process_record&.id == process_id
53+
54+
registry.stop
55+
process_record.delete
56+
@registry = nil
57+
true
58+
end
59+
end
60+
3961
private
4062

4163
attr_reader :mutex, :registry

lib/solid_objects/doctor.rb

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,22 @@ def check_runtime
215215
# @rbs () -> Check
216216
def check_sync_round_trip
217217
actor_id = SecureRandom.uuid
218+
caller_process_id = SolidObjects.caller_process.process_record_id
219+
check = run_sync_probe(actor_id)
220+
leftovers = remove_probe_records(actor_id:, caller_process_id:)
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) -> Check
230+
def run_sync_probe(actor_id)
218231
value = SecureRandom.hex(8)
219-
process_registry = SolidObjects.caller_process.process_registry
220-
reference = ProbeActor.ref(actor_id)
221232
message_reference = Mailbox.new.enqueue(
222-
reference,
233+
ProbeActor.ref(actor_id),
223234
:ping,
224235
{ value: },
225236
kind: "sync"
@@ -230,10 +241,33 @@ def check_sync_round_trip
230241
pass(:sync_round_trip, "durable synchronous actor call completed without a worker")
231242
rescue => error
232243
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
244+
end
245+
246+
# @rbs (actor_id: String, caller_process_id: String?) -> Array[String]
247+
def remove_probe_records(actor_id:, caller_process_id:)
248+
leftovers = []
249+
leftovers << "probe actor" unless delete_probe_actor(actor_id)
250+
probe_process_id = SolidObjects.caller_process.process_record_id
251+
return leftovers if probe_process_id.nil? || probe_process_id == caller_process_id
252+
253+
leftovers << "probe caller process" unless delete_probe_caller_process(probe_process_id)
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 (String) -> bool
266+
def delete_probe_caller_process(process_id)
267+
SolidObjects.caller_process.delete_process_record(process_id)
268+
true
269+
rescue
270+
false
237271
end
238272

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

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

sig/generated/lib/solid_objects/caller_process.rbs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@ module SolidObjects
1616
# @rbs () -> ProcessRegistry
1717
def process_registry: () -> ProcessRegistry
1818

19+
# @rbs () -> String?
20+
def process_record_id: () -> String?
21+
1922
# @rbs () -> bool
2023
def stop: () -> bool
2124

25+
# @rbs (String) -> bool
26+
def delete_process_record: (String) -> bool
27+
2228
private
2329

2430
attr_reader mutex: untyped

sig/generated/lib/solid_objects/doctor.rbs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ module SolidObjects
7878
# @rbs () -> Check
7979
def check_sync_round_trip: () -> Check
8080

81+
# @rbs (String) -> Check
82+
def run_sync_probe: (String) -> Check
83+
84+
# @rbs (actor_id: String, caller_process_id: String?) -> Array[String]
85+
def remove_probe_records: (actor_id: String, caller_process_id: String?) -> Array[String]
86+
87+
# @rbs (String) -> bool
88+
def delete_probe_actor: (String) -> bool
89+
90+
# @rbs (String) -> bool
91+
def delete_probe_caller_process: (String) -> bool
92+
8193
# @rbs (Check, Check) -> bool
8294
def ready_for_round_trip?: (Check, Check) -> bool
8395

0 commit comments

Comments
 (0)