Skip to content

Commit 9d8e156

Browse files
committed
fix: preserve committed turns in v0.14.6
Track SQL commitment before retry and failure handling so Rails callback errors cannot relabel a completed turn or mask its original exception. Carry the committed phase through coordination handlers and restore the original error at synchronous caller and worker boundaries. Cover real transaction callbacks, rollback, domain rejection, busy and deadline failures, waiting callers, and subsequent execution with strict red/green and implementation-reversal evidence. Document the error boundary and bump the version and generated lockfile to 0.14.6.
1 parent 9b70a1c commit 9d8e156

14 files changed

Lines changed: 483 additions & 7 deletions

File tree

‎CHANGELOG.md‎

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

3+
## 0.14.6 - 2026-09-12
4+
5+
- Preserve committed turns when an Active Record after-commit callback raises.
6+
Caller assistance and workers expose the original callback exception without
7+
restoring obsolete state, retrying/rejecting the completed message, or
8+
masking the error as a missing claim. Track actual SQL commitment before
9+
SQLite retries and deadline translation; retain pre-commit rollback, retry,
10+
and domain-rejection behavior.
11+
312
## 0.14.5 - 2026-09-03
413

514
- Split broadcast claiming into separate pending and stale-processing probes,

‎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.14.5)
4+
solid_objects (0.14.6)
55
actioncable (>= 7.1)
66
actionpack (>= 7.1)
77
actionview (>= 7.1)
@@ -384,7 +384,7 @@ CHECKSUMS
384384
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
385385
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
386386
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
387-
solid_objects (0.14.5)
387+
solid_objects (0.14.6)
388388
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
389389
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
390390
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

‎docs/architecture.md‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,9 +314,30 @@ Any lease or message predicate failure raises `LostActivation` and rolls back ev
314314

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

317+
Active Record may raise from an application `after_commit` callback while the
318+
transaction call unwinds, after SQL has committed. The adapter retains the
319+
actual transaction object and checks its fully committed state before any
320+
deadline conversion or SQLite retry. Reaching the end of the block, releasing
321+
a savepoint, or observing an in-memory message update is not proof of SQL
322+
commitment. A failing `before_commit` callback follows the rollback path.
323+
324+
An internal `CommittedTransactionError` carries the original error through
325+
executor, SQLite retry, and coordination rescue handlers. The synchronous
326+
invocation and worker boundaries re-raise the original exception with its
327+
original backtrace and cause. The wrapper prevents a callback's `Rejected`,
328+
`LostActivation`, or database error from being mistaken for a pre-commit
329+
outcome. It is not a durable message error or a new delivery mechanism.
330+
331+
After commitment, actor state, application writes, message completion/result,
332+
and outboxes remain durable. The executor preserves the committed activation
333+
state and does not retry, reject, or dead-letter that turn. Caller assistance
334+
still deactivates and releases its lease during cleanup; cached worker state
335+
can process later messages. A worker running its continuous loop exposes the
336+
error and runs its existing shutdown cleanup.
337+
317338
## Failure path
318339

319-
Actor exceptions roll back all in-memory changes by restoring the pre-turn state. A separate short transaction conditionally owned by the current generation:
340+
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:
320341

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

427+
An executing caller receives an inline after-commit callback error even though
428+
the turn committed. An independently waiting caller observes the durable
429+
result and may return before that callback raises in the worker. Completed
430+
history is not changed retroactively; callback failures must be observed in
431+
the executing process. Later Rails callbacks may not run after one raises.
432+
406433
## Domain rejection
407434

408-
Actor code can call `reject` for a validation or business-rule outcome that
435+
Before commitment, actor code can call `reject` for a validation or business-rule outcome that
409436
must not retry. The executor restores pre-turn state, discards staged intents,
410437
stores the structured rejection, completes the claimed membership, and
411438
continues with the next sequence in one fenced transaction. Synchronous callers

‎docs/correctness.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,29 @@ it is available only when Solid Objects and `ActiveRecord::Base` share one
125125
connection pool. Commit actions must contain only bounded database work.
126126
External I/O belongs in the effect outbox.
127127

128+
### Errors after SQL commit
129+
130+
Active Record `after_commit` callbacks registered by commit actions run after
131+
SQL commitment. If one raises, the application writes, actor state, message
132+
result/completion, and claimed-membership deletion remain committed. The
133+
executor does not restore the pre-turn snapshot, retry the business action,
134+
reject the message, or create a dead letter. A `before_commit` callback can
135+
still roll back everything even after the fenced transaction block finishes.
136+
137+
The executing synchronous caller or `Worker#run_once` receives the original
138+
callback exception, including its identity, backtrace, and cause. This also
139+
applies when the callback raises `Rejected`, `LostActivation`, or a database
140+
deadline/lock error: its class does not change a committed turn into a failed
141+
one. Synchronous cleanup releases the activation; a worker retains the
142+
committed state until normal deactivation or shutdown, so later messages can
143+
continue from that state.
144+
145+
A separate waiting caller can observe the durable result before the callback
146+
finishes. There is no retroactive failure delivery or durable callback-error
147+
result. Inspect and report errors in the executing process. Rails may skip
148+
later callbacks when one raises; these callbacks are not a durable delivery
149+
mechanism. Use an idempotent effect for work that needs independent retries.
150+
128151
## Reactive components
129152

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

206229
## Domain rejection
207230

208-
`reject` is a terminal domain outcome, not an infrastructure failure. It rolls
231+
`reject` before commitment is a terminal domain outcome, not an infrastructure failure. It rolls
209232
back in-memory state and staged intents, stores a structured rejection on the
210233
message, removes claimed membership, and lets the next sequence run. It is
211234
never retried or dead-lettered. The synchronous caller receives

‎lib/solid_objects/database_adapter.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,17 +123,21 @@ def with_lock_probe
123123

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

128129
with_connection do |connection|
129130
with_transaction_deadline(connection) do
130131
connection.transaction(requires_new: true) do
132+
active_transaction = connection.current_transaction
131133
configure_transaction_deadline(connection)
132134
with_transaction_clock { block.call }
133135
end
134136
end
135137
end
136138
rescue => error
139+
raise CommittedTransactionError.new(error) if active_transaction&.state&.fully_committed?
140+
137141
raise unless deadline_error?(error)
138142

139143
raise DatabaseDeadlineExceeded,

‎lib/solid_objects/database_adapters/sqlite.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ def with_busy_retry
3333
attempts = 0
3434
begin
3535
yield
36+
rescue CommittedTransactionError
37+
raise
3638
rescue => error
3739
raise unless busy_error?(error)
3840

@@ -53,6 +55,8 @@ def with_lock_retry
5355
with_connection do |connection|
5456
with_transaction_deadline(connection) { yield }
5557
end
58+
rescue CommittedTransactionError
59+
raise
5660
rescue DatabaseDeadlineExceeded
5761
raise if SyncDeadline.expired?
5862

‎lib/solid_objects/errors.rb‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ class ActorDestroyed < LostActivation
6767
class DatabaseDeadlineExceeded < Error
6868
end
6969

70+
class CommittedTransactionError < Error
71+
# @rbs @original_error: StandardError
72+
73+
attr_reader :original_error
74+
75+
# @rbs (StandardError) -> void
76+
def initialize(original_error)
77+
@original_error = original_error
78+
super(original_error.message)
79+
end
80+
81+
# @rbs () -> bot
82+
def reraise
83+
raise original_error, cause: original_error.cause
84+
end
85+
end
86+
7087
class SyncEnqueueTimeout < Error
7188
# @rbs @timeout: Numeric
7289
# @rbs @actor_type: String

‎lib/solid_objects/executor.rb‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module SolidObjects
44
class Executor
55
# @rbs @activation: Activation
66
# @rbs @message: Message
7+
# @rbs @completion_transaction: untyped
78

89
# @rbs (activation: Activation, message: Message) -> void
910
def initialize(activation:, message:)
@@ -35,7 +36,7 @@ def call
3536
state_changed: state_after.value != state_before
3637
)
3738
true
38-
rescue LostActivation
39+
rescue CommittedTransactionError, LostActivation
3940
raise
4041
rescue Rejected => rejection
4142
activation.restore_state(state_before) if state_before
@@ -96,6 +97,7 @@ def complete(result, observable_changes, state_after:, state_changed:)
9697
moved_reminders = []
9798

9899
activation.lease.fenced_transaction do |instance|
100+
@completion_transaction = Record.connection.current_transaction
99101
# A busy database makes the adapter retry this whole block, so an
100102
# attempt that was rolled back must not leave its work in the lists the
101103
# reporting below reads. Each attempt starts from empty.
@@ -154,6 +156,12 @@ def complete(result, observable_changes, state_after:, state_changed:)
154156
report_large_state(state_after.byte_size)
155157
SolidObjects.instrument_after_commit(:"message.completed", **instrumentation_payload)
156158
SolidObjects.wake_up.signal
159+
rescue CommittedTransactionError
160+
raise
161+
rescue => error
162+
raise unless @completion_transaction&.state&.fully_committed?
163+
164+
raise CommittedTransactionError.new(error)
157165
end
158166

159167
# @rbs (Integer) -> void

‎lib/solid_objects/synchronous_invocation.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ def call(message_reference, timeout:)
2020
SyncDeadline.with(timeout:) do
2121
call_before_deadline(message_reference, timeout:)
2222
end
23+
rescue CommittedTransactionError => error
24+
error.reraise
2325
rescue ActiveRecord::RecordNotFound
2426
raise ActorDestroyed, "actor was destroyed while waiting for its result"
2527
end

‎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.14.5"
4+
VERSION = "0.14.6"
55
end

0 commit comments

Comments
 (0)