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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.14.6 - 2026-09-12

- Preserve committed turns when an Active Record after-commit callback raises.
Caller assistance and workers expose the original callback exception without
restoring obsolete state, retrying/rejecting the completed message, or
masking the error as a missing claim. Track actual SQL commitment before
SQLite retries and deadline translation; retain pre-commit rollback, retry,
and domain-rejection behavior.

## 0.14.5 - 2026-09-03

- Split broadcast claiming into separate pending and stale-processing probes,
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.14.5)
solid_objects (0.14.6)
actioncable (>= 7.1)
actionpack (>= 7.1)
actionview (>= 7.1)
Expand Down Expand Up @@ -384,7 +384,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.14.5)
solid_objects (0.14.6)
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
31 changes: 29 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,30 @@ Any lease or message predicate failure raises `LostActivation` and rolls back ev

The state version can advance because of state migration even when the message itself makes no state change.

Active Record may raise from an application `after_commit` callback while the
transaction call unwinds, after SQL has committed. The adapter retains the
actual transaction object and checks its fully committed state before any
deadline conversion or SQLite retry. Reaching the end of the block, releasing
a savepoint, or observing an in-memory message update is not proof of SQL
commitment. A failing `before_commit` callback follows the rollback path.

An internal `CommittedTransactionError` carries the original error through
executor, SQLite retry, and coordination rescue handlers. The synchronous
invocation and worker boundaries re-raise the original exception with its
original backtrace and cause. The wrapper prevents a callback's `Rejected`,
`LostActivation`, or database error from being mistaken for a pre-commit
outcome. It is not a durable message error or a new delivery mechanism.

After commitment, actor state, application writes, message completion/result,
and outboxes remain durable. The executor preserves the committed activation
state and does not retry, reject, or dead-letter that turn. Caller assistance
still deactivates and releases its lease during cleanup; cached worker state
can process later messages. A worker running its continuous loop exposes the
error and runs its existing shutdown cleanup.

## Failure path

Actor exceptions roll back all in-memory changes by restoring the pre-turn state. A separate short transaction conditionally owned by the current generation:
Before commitment, actor and commit-action exceptions roll back all in-memory changes by restoring the pre-turn state. A separate short transaction conditionally owned by the current generation:

- Stores a sanitized error
- Deletes claimed membership
Expand Down Expand Up @@ -403,9 +424,15 @@ outer commit, and callers timing out on work they indirectly block.
waiting and immediately returns a `MessageReference`. Runtime workers process
it normally.

An executing caller receives an inline after-commit callback error even though
the turn committed. An independently waiting caller observes the durable
result and may return before that callback raises in the worker. Completed
history is not changed retroactively; callback failures must be observed in
the executing process. Later Rails callbacks may not run after one raises.

## Domain rejection

Actor code can call `reject` for a validation or business-rule outcome that
Before commitment, actor code can call `reject` for a validation or business-rule outcome that
must not retry. The executor restores pre-turn state, discards staged intents,
stores the structured rejection, completes the claimed membership, and
continues with the next sequence in one fenced transaction. Synchronous callers
Expand Down
25 changes: 24 additions & 1 deletion docs/correctness.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,29 @@ it is available only when Solid Objects and `ActiveRecord::Base` share one
connection pool. Commit actions must contain only bounded database work.
External I/O belongs in the effect outbox.

### Errors after SQL commit

Active Record `after_commit` callbacks registered by commit actions run after
SQL commitment. If one raises, the application writes, actor state, message
result/completion, and claimed-membership deletion remain committed. The
executor does not restore the pre-turn snapshot, retry the business action,
reject the message, or create a dead letter. A `before_commit` callback can
still roll back everything even after the fenced transaction block finishes.

The executing synchronous caller or `Worker#run_once` receives the original
callback exception, including its identity, backtrace, and cause. This also
applies when the callback raises `Rejected`, `LostActivation`, or a database
deadline/lock error: its class does not change a committed turn into a failed
one. Synchronous cleanup releases the activation; a worker retains the
committed state until normal deactivation or shutdown, so later messages can
continue from that state.

A separate waiting caller can observe the durable result before the callback
finishes. There is no retroactive failure delivery or durable callback-error
result. Inspect and report errors in the executing process. Rails may skip
later callbacks when one raises; these callbacks are not a durable delivery
mechanism. Use an idempotent effect for work that needs independent retries.

## Reactive components

A successful fenced turn advances `instances.state_revision` to that message's
Expand Down Expand Up @@ -205,7 +228,7 @@ wakes the caller, and raises `SolidObjects::ActorDestroyed`.

## Domain rejection

`reject` is a terminal domain outcome, not an infrastructure failure. It rolls
`reject` before commitment is a terminal domain outcome, not an infrastructure failure. It rolls
back in-memory state and staged intents, stores a structured rejection on the
message, removes claimed membership, and lets the next sequence run. It is
never retried or dead-lettered. The synchronous caller receives
Expand Down
4 changes: 4 additions & 0 deletions lib/solid_objects/database_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,21 @@ def with_lock_probe

# @rbs () { () -> untyped } -> untyped
def transaction(&block)
active_transaction = nil
raise DatabaseDeadlineExceeded, "synchronous invocation deadline expired" if SyncDeadline.expired?

with_connection do |connection|
with_transaction_deadline(connection) do
connection.transaction(requires_new: true) do
active_transaction = connection.current_transaction
configure_transaction_deadline(connection)
with_transaction_clock { block.call }
end
end
end
rescue => error
raise CommittedTransactionError.new(error) if active_transaction&.state&.fully_committed?

raise unless deadline_error?(error)

raise DatabaseDeadlineExceeded,
Expand Down
4 changes: 4 additions & 0 deletions lib/solid_objects/database_adapters/sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ def with_busy_retry
attempts = 0
begin
yield
rescue CommittedTransactionError
raise
rescue => error
raise unless busy_error?(error)

Expand All @@ -53,6 +55,8 @@ def with_lock_retry
with_connection do |connection|
with_transaction_deadline(connection) { yield }
end
rescue CommittedTransactionError
raise
rescue DatabaseDeadlineExceeded
raise if SyncDeadline.expired?

Expand Down
17 changes: 17 additions & 0 deletions lib/solid_objects/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ class ActorDestroyed < LostActivation
class DatabaseDeadlineExceeded < Error
end

class CommittedTransactionError < Error
# @rbs @original_error: StandardError

attr_reader :original_error

# @rbs (StandardError) -> void
def initialize(original_error)
@original_error = original_error
super(original_error.message)
end

# @rbs () -> bot
def reraise
raise original_error, cause: original_error.cause
end
end

class SyncEnqueueTimeout < Error
# @rbs @timeout: Numeric
# @rbs @actor_type: String
Expand Down
10 changes: 9 additions & 1 deletion lib/solid_objects/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module SolidObjects
class Executor
# @rbs @activation: Activation
# @rbs @message: Message
# @rbs @completion_transaction: untyped

# @rbs (activation: Activation, message: Message) -> void
def initialize(activation:, message:)
Expand Down Expand Up @@ -35,7 +36,7 @@ def call
state_changed: state_after.value != state_before
)
true
rescue LostActivation
rescue CommittedTransactionError, LostActivation
raise
rescue Rejected => rejection
activation.restore_state(state_before) if state_before
Expand Down Expand Up @@ -96,6 +97,7 @@ def complete(result, observable_changes, state_after:, state_changed:)
moved_reminders = []

activation.lease.fenced_transaction do |instance|
@completion_transaction = Record.connection.current_transaction
# A busy database makes the adapter retry this whole block, so an
# attempt that was rolled back must not leave its work in the lists the
# reporting below reads. Each attempt starts from empty.
Expand Down Expand Up @@ -154,6 +156,12 @@ def complete(result, observable_changes, state_after:, state_changed:)
report_large_state(state_after.byte_size)
SolidObjects.instrument_after_commit(:"message.completed", **instrumentation_payload)
SolidObjects.wake_up.signal
rescue CommittedTransactionError
raise
rescue => error
raise unless @completion_transaction&.state&.fully_committed?

raise CommittedTransactionError.new(error)
end

# @rbs (Integer) -> void
Expand Down
2 changes: 2 additions & 0 deletions lib/solid_objects/synchronous_invocation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def call(message_reference, timeout:)
SyncDeadline.with(timeout:) do
call_before_deadline(message_reference, timeout:)
end
rescue CommittedTransactionError => error
error.reraise
rescue ActiveRecord::RecordNotFound
raise ActorDestroyed, "actor was destroyed while waiting for its result"
end
Expand Down
2 changes: 1 addition & 1 deletion lib/solid_objects/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# rbs_inline: enabled

module SolidObjects
VERSION = "0.14.5"
VERSION = "0.14.6"
end
2 changes: 2 additions & 0 deletions lib/solid_objects/worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ def run_once
).around { activation.drain }
release_activation(activation) if activation.pass_exhausted?
processed
rescue CommittedTransactionError => error
error.reraise
rescue ActorDestroyed
release_activation(activation) if activation
0
Expand Down
12 changes: 12 additions & 0 deletions sig/generated/lib/solid_objects/errors.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ module SolidObjects
class DatabaseDeadlineExceeded < Error
end

class CommittedTransactionError < Error
@original_error: StandardError

attr_reader original_error: untyped

# @rbs (StandardError) -> void
def initialize: (StandardError) -> void

# @rbs () -> bot
def reraise: () -> bot
end

class SyncEnqueueTimeout < Error
@timeout: Numeric

Expand Down
2 changes: 2 additions & 0 deletions sig/generated/lib/solid_objects/executor.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module SolidObjects
class Executor
@completion_transaction: untyped

@message: Message

@activation: Activation
Expand Down
Loading
Loading