Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.4.3 - 2026-08-07

- Bound SQLite caller-process registration, reuse, heartbeat, and synchronous
result observation retries by the original invocation deadline.
- Load host application actors from `app/actors` before CLI workers start,
including development environments with eager loading disabled.

## 0.4.2 - 2026-08-07

- Decode Action Cable broadcast payloads before parsing observable invalidations
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solid_objects (0.4.2)
solid_objects (0.4.3)
actioncable (>= 8.0)
actionpack (>= 8.0)
actionview (>= 8.0)
Expand Down Expand Up @@ -373,7 +373,7 @@ CHECKSUMS
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
solid_objects (0.4.2)
solid_objects (0.4.3)
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,9 +588,9 @@ polling as the fallback. A timeout never cancels the durable invocation.
durable status, mailbox blocker, and activation-owner diagnostics without
including message arguments. The configured timeout also bounds adapter
database lock waits from the enqueue attempt through result observation.
PostgreSQL uses transaction lock and statement timeouts, SQLite uses its busy
timeout, and MySQL uses its execution timeout plus InnoDB's one-second minimum
lock-wait granularity.
PostgreSQL uses transaction lock and statement timeouts, SQLite retries busy
coordination operations only until the original call deadline, and MySQL uses
its execution timeout plus InnoDB's one-second minimum lock-wait granularity.

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

Before any role starts, the CLI loads actors from the host application's
`app/actors` directories through Rails' main autoloader. This works when
development eager loading is disabled and does not require actor references in
an initializer.

See the [operations guide](docs/operations.md) for monitoring, reconciliation,
shutdown, retention, and backup guidance.

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

A synchronous call made while the Solid Objects connection already has an open
transaction raises `SolidObjects::SyncInsideTransaction` before the message is
Expand Down
6 changes: 6 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ Start all configured roles:
bundle exec solid_objects start
```

The command loads the host application's `app/actors` directories before
starting any runtime role, even when Rails eager loading is disabled. Actors in
the conventional directory do not need initializer references. The targeted
loader participates in Rails preparation callbacks so a development reload can
replace a registered actor class without loading unrelated application code.

Inspect process records and clean stale ownership:

```bash
Expand Down
14 changes: 9 additions & 5 deletions lib/solid_objects/activation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ class Activation
# @rbs (lease: Lease) -> void
def initialize(lease:)
@lease = lease
instance = Instance.find(lease.instance_id)
instance = SolidObjects.database_adapter.with_lock_retry do
Instance.find(lease.instance_id)
end
@actor_class = SolidObjects.registry.fetch(instance.actor_type)
@actor = build_actor(instance)
@last_used_at = monotonic_now
Expand Down Expand Up @@ -74,10 +76,12 @@ def lease_renewal_due?

# @rbs () -> void
def yield_ready_messages
now = SolidObjects.database_adapter.database_now
ReadyMessage
.where(instance_id: lease.instance_id, available_at: ..now)
.update_all(available_at: now)
SolidObjects.database_adapter.transaction do
now = SolidObjects.database_adapter.database_now
ReadyMessage
.where(instance_id: lease.instance_id, available_at: ..now)
.update_all(available_at: now)
end
end

# @rbs (Hash[String, untyped]) -> void
Expand Down
7 changes: 6 additions & 1 deletion lib/solid_objects/actor_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def register(type, actor_class)

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

Expand Down Expand Up @@ -61,5 +61,10 @@ def validate_actor_class!(actor_class)

raise InvalidActor, "registered actor must inherit from SolidObjects::Actor"
end

# @rbs (Class, Class) -> bool
def reload_of?(existing, candidate)
!existing.name.nil? && existing.name == candidate.name
end
end
end
53 changes: 53 additions & 0 deletions lib/solid_objects/application_actor_loader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# rbs_inline: enabled

module SolidObjects
class ApplicationActorLoader
# @rbs @application: untyped
# @rbs @autoloader: untyped

# @rbs (?application: untyped, ?autoloader: untyped) -> void
def initialize(application: Rails.application, autoloader: Rails.autoloaders.main)
@application = application
@autoloader = autoloader
end

# @rbs () -> void
def call
actor_directories.each { |directory| autoloader.eager_load_dir(directory) }
current_actor_classes.each(&:ensure_registered!)
end

# @rbs () -> void
def install
application.reloader.to_prepare { call }
call
end

private

attr_reader :application, :autoloader

# @rbs () -> Array[String]
def actor_directories
configured_directories = application.paths["app/actors"]&.existent || []
conventional_directories = application.paths["app"].existent.select do |application_directory|
File.basename(application_directory) == "actors"
end
managed_directories = autoloader.dirs.map { |directory| File.expand_path(directory) }

(configured_directories + conventional_directories)
.select { |directory| Dir.exist?(directory) }
.map { |directory| File.expand_path(directory) }
.select { |directory| managed_directories.include?(directory) }
.uniq
end

# @rbs () -> Array[Class]
def current_actor_classes
Actor.descendants.select do |actor_class|
actor_class.name &&
actor_class.name.safe_constantize.equal?(actor_class)
end
end
end
end
10 changes: 6 additions & 4 deletions lib/solid_objects/caller_process.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,21 @@ def reset_after_fork
def reusable_registry?
return false unless registry&.process_record

registry.process_record.reload.shutdown_state == "running"
SolidObjects.database_adapter.with_lock_retry do
registry.process_record.reload.shutdown_state == "running"
end
rescue ActiveRecord::RecordNotFound
false
end

# @rbs () -> ProcessRegistry
def register
@registry = ProcessRegistry.new
registry.register(
process_registry = ProcessRegistry.new
process_registry.register(
kind: "caller",
metadata: { execution: "synchronous" }
)
registry
@registry = process_registry
end

# @rbs () -> void
Expand Down
2 changes: 2 additions & 0 deletions lib/solid_objects/cli.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# rbs_inline: enabled

require "thor"
require "solid_objects/application_actor_loader"

module SolidObjects
class CLI < Thor
Expand Down Expand Up @@ -133,6 +134,7 @@ def boot_application
end

require path
ApplicationActorLoader.new.install
end

# @rbs (Symbol, Integer) -> Integer
Expand Down
46 changes: 26 additions & 20 deletions lib/solid_objects/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,27 +65,33 @@ def sync(reference, message_name, arguments, timeout:, idempotency_key: nil, aut

# @rbs (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped
def wait(message_reference, timeout:, authorization_context: nil)
message = Message.find(message_reference.id)
validate_message_reference!(message_reference, message)
reference = Reference.new(
actor_type: message.actor_type,
actor_id: message.actor_id
)
actor_class = SolidObjects.registry.fetch(reference.actor_type)
query = actor_class.definition.queries.key?(message.message_name.to_sym)
actor_message = actor_class.definition.messages.key?(message.message_name.to_sym)
unless query || actor_message
raise UnknownMessage, "unknown message #{message.message_name.inspect}"
SyncDeadline.with(timeout:) do
message = SolidObjects.database_adapter.with_lock_retry do
Message.find(message_reference.id)
end
validate_message_reference!(message_reference, message)
reference = Reference.new(
actor_type: message.actor_type,
actor_id: message.actor_id
)
actor_class = SolidObjects.registry.fetch(reference.actor_type)
query = actor_class.definition.queries.key?(message.message_name.to_sym)
actor_message = actor_class.definition.messages.key?(message.message_name.to_sym)
unless query || actor_message
raise UnknownMessage, "unknown message #{message.message_name.inspect}"
end
authorize!(
query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
reference,
message.message_name,
message.arguments,
authorization_context:
)
reject_sync_inside_transaction!(reference, message.message_name)
SynchronousInvocation.new.call(message_reference, timeout:)
end
authorize!(
query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
reference,
message.message_name,
message.arguments,
authorization_context:
)
reject_sync_inside_transaction!(reference, message.message_name)
SynchronousInvocation.new.call(message_reference, timeout:)
rescue DatabaseDeadlineExceeded
raise SyncDiagnostics.new.database_contention_for(message_reference, timeout:)
end

# @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot
Expand Down
10 changes: 10 additions & 0 deletions lib/solid_objects/database_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ def database_now
value.is_a?(Time) ? value.utc : Time.parse("#{value} UTC").utc
end

# @rbs () { () -> untyped } -> untyped
def with_lock_retry
yield
end

# @rbs () { () -> untyped } -> untyped
def with_lock_probe
yield
end

# @rbs () { () -> untyped } -> untyped
def transaction(&block)
raise DatabaseDeadlineExceeded, "synchronous invocation deadline expired" if SyncDeadline.expired?
Expand Down
53 changes: 51 additions & 2 deletions lib/solid_objects/database_adapters/sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module SolidObjects
module DatabaseAdapters
class Sqlite < DatabaseAdapter
LOCK_RETRY_INTERVAL = 0.001
LOCK_RETRY_MUTEX = Thread::Mutex.new
LOCK_RETRY_CONDITION = Thread::ConditionVariable.new

# @rbs () -> String
def current_time_expression
Expand All @@ -14,14 +16,51 @@ def current_time_expression
def transaction(&block)
return super unless SyncDeadline.active?

super
with_lock_retry { super }
end

# @rbs () { () -> untyped } -> untyped
def with_lock_retry
return yield unless SyncDeadline.active?

raise DatabaseDeadlineExceeded, "synchronous invocation deadline expired" if SyncDeadline.expired?

with_connection do |connection|
with_transaction_deadline(connection) { yield }
end
rescue DatabaseDeadlineExceeded
raise if SyncDeadline.expired?

sleep [ LOCK_RETRY_INTERVAL, SyncDeadline.remaining ].min
wait_before_retry
retry
rescue => error
raise unless deadline_error?(error)

if SyncDeadline.expired?
raise DatabaseDeadlineExceeded,
"database lock wait exceeded the synchronous invocation deadline",
cause: error
end

wait_before_retry
retry
end

# @rbs () { () -> untyped } -> untyped
def with_lock_probe
return yield unless SyncDeadline.active?

with_connection do |connection|
with_transaction_deadline(connection) { yield }
end
rescue => error
raise unless deadline_error?(error)

raise DatabaseDeadlineExceeded,
"database remained locked at the synchronous invocation deadline",
cause: error
end

private

# @rbs (untyped) { () -> untyped } -> untyped
Expand All @@ -47,6 +86,16 @@ def deadline_error?(error)
end
false
end

# @rbs () -> void
def wait_before_retry
LOCK_RETRY_MUTEX.synchronize do
LOCK_RETRY_CONDITION.wait(
LOCK_RETRY_MUTEX,
[ LOCK_RETRY_INTERVAL, SyncDeadline.remaining ].min
)
end
end
end
end
end
Loading