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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +6 -12
- data/docs/architecture.md +29 -2
- data/docs/correctness.md +24 -1
- data/lib/solid_objects/database_adapter.rb +4 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +4 -0
- data/lib/solid_objects/errors.rb +17 -0
- data/lib/solid_objects/executor.rb +9 -1
- data/lib/solid_objects/synchronous_invocation.rb +2 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/worker.rb +2 -0
- data/sig/generated/lib/solid_objects/errors.rbs +12 -0
- data/sig/generated/lib/solid_objects/executor.rbs +2 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0616851aa1c44a26313dac95fa3f868324910a64a7023435d2c2b6aed83b7610'
|
|
4
|
+
data.tar.gz: b54b99a40ee18b68f7d6b7f21eb9879c92a867b0715a52647f4f58b4f35997d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/solid_objects/errors.rb
CHANGED
|
@@ -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
|
data/lib/solid_objects/worker.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-09-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actioncable
|