Skip to content

Commit 6cdbac8

Browse files
committed
fix: bound sync retries and load actors
Keep SQLite caller bookkeeping and result observation inside the original synchronous deadline without retrying actor behavior. Load and register host app actors before CLI workers start, including when development eager loading is disabled.
1 parent 925e351 commit 6cdbac8

30 files changed

Lines changed: 645 additions & 79 deletions

CHANGELOG.md

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

3+
## 0.4.3 - 2026-08-07
4+
5+
- Bound SQLite caller-process registration, reuse, heartbeat, and synchronous
6+
result observation retries by the original invocation deadline.
7+
- Load host application actors from `app/actors` before CLI workers start,
8+
including development environments with eager loading disabled.
9+
310
## 0.4.2 - 2026-08-07
411

512
- Decode Action Cable broadcast payloads before parsing observable invalidations

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.4.2)
4+
solid_objects (0.4.3)
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.4.2)
376+
solid_objects (0.4.3)
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

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -588,9 +588,9 @@ polling as the fallback. A timeout never cancels the durable invocation.
588588
durable status, mailbox blocker, and activation-owner diagnostics without
589589
including message arguments. The configured timeout also bounds adapter
590590
database lock waits from the enqueue attempt through result observation.
591-
PostgreSQL uses transaction lock and statement timeouts, SQLite uses its busy
592-
timeout, and MySQL uses its execution timeout plus InnoDB's one-second minimum
593-
lock-wait granularity.
591+
PostgreSQL uses transaction lock and statement timeouts, SQLite retries busy
592+
coordination operations only until the original call deadline, and MySQL uses
593+
its execution timeout plus InnoDB's one-second minimum lock-wait granularity.
594594

595595
The durable call can finish after its original caller gives up. Reauthorize and
596596
recover its eventual result through the durable message identity:
@@ -896,6 +896,11 @@ and marks process rows stopped on graceful shutdown. A hard-killed worker's
896896
claimed turn is recovered after its process heartbeat or activation lease
897897
becomes stale.
898898

899+
Before any role starts, the CLI loads actors from the host application's
900+
`app/actors` directories through Rails' main autoloader. This works when
901+
development eager loading is disabled and does not require actor references in
902+
an initializer.
903+
899904
See the [operations guide](docs/operations.md) for monitoring, reconciliation,
900905
shutdown, retention, and backup guidance.
901906

docs/correctness.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,13 @@ Timeout raises `SolidObjects::SyncTimeout` but does not cancel the message.
155155
The exception reports actor identity, message ID and sequence, durable status,
156156
an earlier mailbox blocker, and activation-owner metadata without exposing
157157
arguments. Its `message_reference` can reauthorize and wait for the eventual
158-
result. Adapter lock/query deadlines cover the durable enqueue and coordination
159-
transactions. If enqueue cannot commit, `SyncEnqueueTimeout` is raised and no
160-
message reference exists. MySQL lock waits have one-second InnoDB granularity.
161-
Ruby handlers that already started are not preempted.
158+
result. Adapter lock/query deadlines cover the durable enqueue, caller-process
159+
registration and heartbeat, activation coordination, and result observation.
160+
SQLite retries busy coordination operations only within the original call
161+
deadline and reports `waiting_on=database_contention` when the database cannot
162+
be inspected at timeout. If enqueue cannot commit, `SyncEnqueueTimeout` is
163+
raised and no message reference exists. MySQL lock waits have one-second InnoDB
164+
granularity. Ruby handlers that already started are not preempted.
162165

163166
A synchronous call made while the Solid Objects connection already has an open
164167
transaction raises `SolidObjects::SyncInsideTransaction` before the message is

docs/operations.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ Start all configured roles:
2323
bundle exec solid_objects start
2424
```
2525

26+
The command loads the host application's `app/actors` directories before
27+
starting any runtime role, even when Rails eager loading is disabled. Actors in
28+
the conventional directory do not need initializer references. The targeted
29+
loader participates in Rails preparation callbacks so a development reload can
30+
replace a registered actor class without loading unrelated application code.
31+
2632
Inspect process records and clean stale ownership:
2733

2834
```bash

lib/solid_objects/activation.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ class Activation
1313
# @rbs (lease: Lease) -> void
1414
def initialize(lease:)
1515
@lease = lease
16-
instance = Instance.find(lease.instance_id)
16+
instance = SolidObjects.database_adapter.with_lock_retry do
17+
Instance.find(lease.instance_id)
18+
end
1719
@actor_class = SolidObjects.registry.fetch(instance.actor_type)
1820
@actor = build_actor(instance)
1921
@last_used_at = monotonic_now
@@ -74,10 +76,12 @@ def lease_renewal_due?
7476

7577
# @rbs () -> void
7678
def yield_ready_messages
77-
now = SolidObjects.database_adapter.database_now
78-
ReadyMessage
79-
.where(instance_id: lease.instance_id, available_at: ..now)
80-
.update_all(available_at: now)
79+
SolidObjects.database_adapter.transaction do
80+
now = SolidObjects.database_adapter.database_now
81+
ReadyMessage
82+
.where(instance_id: lease.instance_id, available_at: ..now)
83+
.update_all(available_at: now)
84+
end
8185
end
8286

8387
# @rbs (Hash[String, untyped]) -> void

lib/solid_objects/actor_registry.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def register(type, actor_class)
1818

1919
mutex.synchronize do
2020
existing = actors[actor_type]
21-
if existing && existing != actor_class
21+
if existing && existing != actor_class && !reload_of?(existing, actor_class)
2222
raise InvalidActor, "#{actor_type.inspect} is already registered by #{existing.name}"
2323
end
2424

@@ -61,5 +61,10 @@ def validate_actor_class!(actor_class)
6161

6262
raise InvalidActor, "registered actor must inherit from SolidObjects::Actor"
6363
end
64+
65+
# @rbs (Class, Class) -> bool
66+
def reload_of?(existing, candidate)
67+
!existing.name.nil? && existing.name == candidate.name
68+
end
6469
end
6570
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# rbs_inline: enabled
2+
3+
module SolidObjects
4+
class ApplicationActorLoader
5+
# @rbs @application: untyped
6+
# @rbs @autoloader: untyped
7+
8+
# @rbs (?application: untyped, ?autoloader: untyped) -> void
9+
def initialize(application: Rails.application, autoloader: Rails.autoloaders.main)
10+
@application = application
11+
@autoloader = autoloader
12+
end
13+
14+
# @rbs () -> void
15+
def call
16+
actor_directories.each { |directory| autoloader.eager_load_dir(directory) }
17+
current_actor_classes.each(&:ensure_registered!)
18+
end
19+
20+
# @rbs () -> void
21+
def install
22+
application.reloader.to_prepare { call }
23+
call
24+
end
25+
26+
private
27+
28+
attr_reader :application, :autoloader
29+
30+
# @rbs () -> Array[String]
31+
def actor_directories
32+
configured_directories = application.paths["app/actors"]&.existent || []
33+
conventional_directories = application.paths["app"].existent.select do |application_directory|
34+
File.basename(application_directory) == "actors"
35+
end
36+
managed_directories = autoloader.dirs.map { |directory| File.expand_path(directory) }
37+
38+
(configured_directories + conventional_directories)
39+
.select { |directory| Dir.exist?(directory) }
40+
.map { |directory| File.expand_path(directory) }
41+
.select { |directory| managed_directories.include?(directory) }
42+
.uniq
43+
end
44+
45+
# @rbs () -> Array[Class]
46+
def current_actor_classes
47+
Actor.descendants.select do |actor_class|
48+
actor_class.name &&
49+
actor_class.name.safe_constantize.equal?(actor_class)
50+
end
51+
end
52+
end
53+
end

lib/solid_objects/caller_process.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,21 @@ def reset_after_fork
5252
def reusable_registry?
5353
return false unless registry&.process_record
5454

55-
registry.process_record.reload.shutdown_state == "running"
55+
SolidObjects.database_adapter.with_lock_retry do
56+
registry.process_record.reload.shutdown_state == "running"
57+
end
5658
rescue ActiveRecord::RecordNotFound
5759
false
5860
end
5961

6062
# @rbs () -> ProcessRegistry
6163
def register
62-
@registry = ProcessRegistry.new
63-
registry.register(
64+
process_registry = ProcessRegistry.new
65+
process_registry.register(
6466
kind: "caller",
6567
metadata: { execution: "synchronous" }
6668
)
67-
registry
69+
@registry = process_registry
6870
end
6971

7072
# @rbs () -> void

lib/solid_objects/cli.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# rbs_inline: enabled
22

33
require "thor"
4+
require "solid_objects/application_actor_loader"
45

56
module SolidObjects
67
class CLI < Thor
@@ -133,6 +134,7 @@ def boot_application
133134
end
134135

135136
require path
137+
ApplicationActorLoader.new.install
136138
end
137139

138140
# @rbs (Symbol, Integer) -> Integer

0 commit comments

Comments
 (0)