solid_objects 0.14.5 → 0.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45c9deb9d06f62e056668c5d3b547786ba69f9d866c302d850650d4ddeac7f9d
4
- data.tar.gz: be67022b7559e87fa48adc91bc09466c005a77345e1631405cd4be76a038e8b5
3
+ metadata.gz: '0616851aa1c44a26313dac95fa3f868324910a64a7023435d2c2b6aed83b7610'
4
+ data.tar.gz: b54b99a40ee18b68f7d6b7f21eb9879c92a867b0715a52647f4f58b4f35997d9
5
5
  SHA512:
6
- metadata.gz: 43ad462709bbb061bc9aef103ce4bed2b9f5432c2efd507a743ccbdd33c255c7c4c8540d8d203baef62ef1c552ffd8e6978403c83a400fa99329be9cafbc44ed
7
- data.tar.gz: 909192b9a0ca8674439e4ade94c09b6d0cd03b6cddc1082d2f55d02e0de41b27b64688bd1cb8c8af77febb5dd3a78241b656fa8d77a12942b3e1678b61450e37
6
+ metadata.gz: 5a1a03e584ad3305114993f1898294ebcc719330d2ea6466aa29b38b218bae50fca92394cc57dac95dd8f584da6c506a57f7c145d4238ed2c06e0411a93fa4a8
7
+ data.tar.gz: 45b4818bc0beb1de4697155bd438c58bc3432e2d40e93c1deb6ff6db5c295d54fe9994e021ff5f7167564a3444fd2b8918b669f2dc550a88985b53e57f55d96c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
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
+
3
12
  ## 0.14.5 - 2026-09-03
4
13
 
5
14
  - Split broadcast claiming into separate pending and stale-processing probes,
data/README.md CHANGED
@@ -110,15 +110,13 @@ bundle exec solid_objects start
110
110
  ```
111
111
 
112
112
  Stop that process before the deadline and restart it afterwards. The reminder
113
- is still in the Rails database and runs when the process returns. We have given
114
- `self.available += 1` a supervisor and excellent posture.
113
+ is still in the Rails database and runs when the process returns. The reminder survived the restart.
115
114
 
116
115
  ## Why this exists
117
116
 
118
117
  The handwritten Rails version usually starts with `with_lock`. Then it gains an
119
118
  `expires_at` column, a cron job, an Active Job retry policy, and an Action Cable
120
- broadcast that must agree with the write. A small invariant has become a rich
121
- tapestry of callbacks and scheduled cleanup.
119
+ broadcast that must agree with the write. A small invariant has spread across callbacks and scheduled cleanup.
122
120
 
123
121
  This is complicated, hard to test, fragile and unnecessary.
124
122
 
@@ -133,14 +131,12 @@ PostgreSQL, or MySQL. Redis and a separate actor service are not required.
133
131
  - Account, device, assessment, and approval workflows that survive deploys.
134
132
  - Reactive ERB views that must follow committed actor revisions.
135
133
 
136
- Different identities can run concurrently. Put the whole application behind
137
- one actor ID and Rails will faithfully operate your new bottleneck.
134
+ Different identities can run concurrently. One actor ID for the whole application is a bottleneck.
138
135
 
139
136
  ## When a transaction is better
140
137
 
141
138
  Often. If the entire invariant fits inside one request, use `with_lock`, a
142
- database constraint, or a short transaction. A row lock does not need a
143
- personal brand, and it is usually the clearest answer.
139
+ database constraint, or a short transaction. A row lock is usually the clearest answer.
144
140
 
145
141
  Use Solid Objects when work must happen later, survive a restart, or stay
146
142
  ordered across several requests or jobs. A plain counter remains one line of
@@ -171,15 +167,13 @@ Exactly once is not hiding in a more advanced configuration. Read the
171
167
  - [Detailed documentation](docs/)
172
168
 
173
169
  The dashboard, benchmarks, migration cookbook, schema, and exhaustive API
174
- explanations remain in `docs/`. The README is stopping before it develops a
175
- robust interplay with its own table of contents.
170
+ explanations remain in `docs/`. The README stops here.
176
171
 
177
172
  ## Status and license
178
173
 
179
174
  Solid Objects Ruby is a pre-1.0 early release. Its correctness core is tested
180
175
  against SQLite, PostgreSQL, and MySQL, but the project makes no production-ready
181
- claim. That requires more hardening and operational soak evidence. Pre-1.0 is
182
- not decorative punctuation.
176
+ claim. That requires more hardening and operational soak evidence. Expect breaking changes.
183
177
 
184
178
  Solid Objects is released under the [MIT License](MIT-LICENSE). It is an
185
179
  independent project and is not affiliated with, sponsored by, or endorsed by
data/docs/architecture.md CHANGED
@@ -314,9 +314,30 @@ Any lease or message predicate failure raises `LostActivation` and rolls back ev
314
314
 
315
315
  The state version can advance because of state migration even when the message itself makes no state change.
316
316
 
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
+
317
338
  ## Failure path
318
339
 
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:
320
341
 
321
342
  - Stores a sanitized error
322
343
  - Deletes claimed membership
@@ -403,9 +424,15 @@ outer commit, and callers timing out on work they indirectly block.
403
424
  waiting and immediately returns a `MessageReference`. Runtime workers process
404
425
  it normally.
405
426
 
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
+
406
433
  ## Domain rejection
407
434
 
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
409
436
  must not retry. The executor restores pre-turn state, discards staged intents,
410
437
  stores the structured rejection, completes the claimed membership, and
411
438
  continues with the next sequence in one fenced transaction. Synchronous callers
data/docs/correctness.md CHANGED
@@ -125,6 +125,29 @@ it is available only when Solid Objects and `ActiveRecord::Base` share one
125
125
  connection pool. Commit actions must contain only bounded database work.
126
126
  External I/O belongs in the effect outbox.
127
127
 
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
+
128
151
  ## Reactive components
129
152
 
130
153
  A successful fenced turn advances `instances.state_revision` to that message's
@@ -205,7 +228,7 @@ wakes the caller, and raises `SolidObjects::ActorDestroyed`.
205
228
 
206
229
  ## Domain rejection
207
230
 
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
209
232
  back in-memory state and staged intents, stores a structured rejection on the
210
233
  message, removes claimed membership, and lets the next sequence run. It is
211
234
  never retried or dead-lettered. The synchronous caller receives
@@ -123,17 +123,21 @@ module SolidObjects
123
123
 
124
124
  # @rbs () { () -> untyped } -> untyped
125
125
  def transaction(&block)
126
+ active_transaction = nil
126
127
  raise DatabaseDeadlineExceeded, "synchronous invocation deadline expired" if SyncDeadline.expired?
127
128
 
128
129
  with_connection do |connection|
129
130
  with_transaction_deadline(connection) do
130
131
  connection.transaction(requires_new: true) do
132
+ active_transaction = connection.current_transaction
131
133
  configure_transaction_deadline(connection)
132
134
  with_transaction_clock { block.call }
133
135
  end
134
136
  end
135
137
  end
136
138
  rescue => error
139
+ raise CommittedTransactionError.new(error) if active_transaction&.state&.fully_committed?
140
+
137
141
  raise unless deadline_error?(error)
138
142
 
139
143
  raise DatabaseDeadlineExceeded,
@@ -33,6 +33,8 @@ module SolidObjects
33
33
  attempts = 0
34
34
  begin
35
35
  yield
36
+ rescue CommittedTransactionError
37
+ raise
36
38
  rescue => error
37
39
  raise unless busy_error?(error)
38
40
 
@@ -53,6 +55,8 @@ module SolidObjects
53
55
  with_connection do |connection|
54
56
  with_transaction_deadline(connection) { yield }
55
57
  end
58
+ rescue CommittedTransactionError
59
+ raise
56
60
  rescue DatabaseDeadlineExceeded
57
61
  raise if SyncDeadline.expired?
58
62
 
@@ -67,6 +67,23 @@ module SolidObjects
67
67
  class DatabaseDeadlineExceeded < Error
68
68
  end
69
69
 
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
+
70
87
  class SyncEnqueueTimeout < Error
71
88
  # @rbs @timeout: Numeric
72
89
  # @rbs @actor_type: String
@@ -4,6 +4,7 @@ module SolidObjects
4
4
  class Executor
5
5
  # @rbs @activation: Activation
6
6
  # @rbs @message: Message
7
+ # @rbs @completion_transaction: untyped
7
8
 
8
9
  # @rbs (activation: Activation, message: Message) -> void
9
10
  def initialize(activation:, message:)
@@ -35,7 +36,7 @@ module SolidObjects
35
36
  state_changed: state_after.value != state_before
36
37
  )
37
38
  true
38
- rescue LostActivation
39
+ rescue CommittedTransactionError, LostActivation
39
40
  raise
40
41
  rescue Rejected => rejection
41
42
  activation.restore_state(state_before) if state_before
@@ -96,6 +97,7 @@ module SolidObjects
96
97
  moved_reminders = []
97
98
 
98
99
  activation.lease.fenced_transaction do |instance|
100
+ @completion_transaction = Record.connection.current_transaction
99
101
  # A busy database makes the adapter retry this whole block, so an
100
102
  # attempt that was rolled back must not leave its work in the lists the
101
103
  # reporting below reads. Each attempt starts from empty.
@@ -154,6 +156,12 @@ module SolidObjects
154
156
  report_large_state(state_after.byte_size)
155
157
  SolidObjects.instrument_after_commit(:"message.completed", **instrumentation_payload)
156
158
  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)
157
165
  end
158
166
 
159
167
  # @rbs (Integer) -> void
@@ -20,6 +20,8 @@ module SolidObjects
20
20
  SyncDeadline.with(timeout:) do
21
21
  call_before_deadline(message_reference, timeout:)
22
22
  end
23
+ rescue CommittedTransactionError => error
24
+ error.reraise
23
25
  rescue ActiveRecord::RecordNotFound
24
26
  raise ActorDestroyed, "actor was destroyed while waiting for its result"
25
27
  end
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.14.5"
4
+ VERSION = "0.14.6"
5
5
  end
@@ -59,6 +59,8 @@ module SolidObjects
59
59
  ).around { activation.drain }
60
60
  release_activation(activation) if activation.pass_exhausted?
61
61
  processed
62
+ rescue CommittedTransactionError => error
63
+ error.reraise
62
64
  rescue ActorDestroyed
63
65
  release_activation(activation) if activation
64
66
  0
@@ -67,6 +67,18 @@ module SolidObjects
67
67
  class DatabaseDeadlineExceeded < Error
68
68
  end
69
69
 
70
+ class CommittedTransactionError < Error
71
+ @original_error: StandardError
72
+
73
+ attr_reader original_error: untyped
74
+
75
+ # @rbs (StandardError) -> void
76
+ def initialize: (StandardError) -> void
77
+
78
+ # @rbs () -> bot
79
+ def reraise: () -> bot
80
+ end
81
+
70
82
  class SyncEnqueueTimeout < Error
71
83
  @timeout: Numeric
72
84
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Executor
5
+ @completion_transaction: untyped
6
+
5
7
  @message: Message
6
8
 
7
9
  @activation: Activation
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.5
4
+ version: 0.14.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-03 00:00:00.000000000 Z
11
+ date: 2026-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable