solid_objects 1.0.1 → 1.0.2
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 +13 -0
- data/README.md +1 -0
- data/docs/compare/good-job.md +245 -0
- data/docs/compare/solid-queue.md +239 -0
- data/docs/comparisons.md +26 -0
- data/examples/compare/customer_billing_sync/customer_billing_sync.rb +61 -0
- data/examples/compare/customer_billing_sync/solid_objects.rb +10 -0
- data/lib/solid_objects/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 556d59f2ef4f4862b2d8a851f19ce31b2eca80b6ebb1ea45a8f1dce9c7c1e122
|
|
4
|
+
data.tar.gz: b9c1539d5cff7ea9a75e24268771cabe1571a5b0688fec187bfddc255189c4e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc9a3b2e02e0c769d459eb9fbab94d1f86553a2ea4841356b32a2288dee8a6294cd4db285fa5ccf730e7ab329d1189c11f91736825d27411caa63befc94d0f48
|
|
7
|
+
data.tar.gz: 6becd234cc81dd86b766b8be4c793e31b852093f4a4ec31b6548c6cd61b7b5ef2a103a0fc5f713d52b03349eab547726f1a00fe074c7885a5a17ad3ac6b9b753
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.2 - 2026-10-15
|
|
4
|
+
|
|
5
|
+
- Add two comparison pages in `docs/compare/`: Solid Queue
|
|
6
|
+
`limits_concurrency` and GoodJob concurrency controls. Each page compares an
|
|
7
|
+
ordered billing sync for each customer, says when the job queue is the
|
|
8
|
+
better choice, and links the primary sources that were checked on October
|
|
9
|
+
15, 2026. The billing sync actor is in `examples/compare/` and runs in the
|
|
10
|
+
test suite. `docs/comparisons.md` lists the pages, and the README links it.
|
|
11
|
+
An evaluation on October 15, 2026 found that Claude Code, with web search,
|
|
12
|
+
searched for these names and never found Solid Objects.
|
|
13
|
+
- The guide document test now covers the comparison pages: embedded examples,
|
|
14
|
+
links, dashes, and the list in `docs/comparisons.md`.
|
|
15
|
+
|
|
3
16
|
## 1.0.1 - 2026-10-14
|
|
4
17
|
|
|
5
18
|
- Correct `docs/reminders.md`, which said that Solid Objects has no
|
data/README.md
CHANGED
|
@@ -193,6 +193,7 @@ every claim in [`test/guides/`](test/guides/):
|
|
|
193
193
|
- [Virtual actors in Ruby on Rails](docs/virtual-actors.md)
|
|
194
194
|
- [Guide for coding agents](docs/agents.md)
|
|
195
195
|
- [Choosing Solid Objects](docs/fit.md)
|
|
196
|
+
- [Comparisons with Solid Queue and GoodJob](docs/comparisons.md)
|
|
196
197
|
- [Operations and recovery](docs/operations.md)
|
|
197
198
|
- [Observability and diagnostics](docs/observability.md)
|
|
198
199
|
- [Reminders](docs/reminders.md)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# GoodJob concurrency controls and Solid Objects: ordered work for each customer in Rails
|
|
2
|
+
|
|
3
|
+
GoodJob is a PostgreSQL-backed Active Job backend. Its concurrency controls limit how many jobs with the same label run at a time, but they do not guarantee first-in, first-out order. Solid Objects (the `solid_objects` gem) gives each customer one actor with an ordered mailbox, its own durable state, and reminders, in the SQL database that the app already uses. Use GoodJob when jobs are independent or order does not matter. Use Solid Objects when the updates for one customer must apply in the order they arrived, and each customer has state that a retry must keep.
|
|
4
|
+
|
|
5
|
+
Solid Objects is an independent project. It is not affiliated with GoodJob or Rails.
|
|
6
|
+
|
|
7
|
+
## The short answer
|
|
8
|
+
|
|
9
|
+
| Question | GoodJob with a concurrency rule | Solid Objects |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| What runs one at a time | Jobs with the same label, with `perform_limit: 1` | Calls to the same actor ID |
|
|
12
|
+
| How a second job waits | It raises `ConcurrencyExceededError` and retries with backoff. With the `good_job_concurrency_claims` table, the longest-waiting job is rescheduled to run at once when the key becomes free | It waits in the actor mailbox |
|
|
13
|
+
| Order for each key | Not guaranteed. The README says "First-in-first-out job execution order is not strictly preserved" | Kept. Messages for one actor run in the order that the runtime stores them |
|
|
14
|
+
| State for each key | Your own table | Actor attributes, stored by the gem |
|
|
15
|
+
| Later work | `set(wait:)`, `retry_on`, cron, and batches | Reminders with `schedule(at:, key:)` |
|
|
16
|
+
| A worker stops in the middle of a run | GoodJob retries the job automatically | The lease expires and the turn runs again. Delivery is at least once |
|
|
17
|
+
| Process to run | `bundle exec good_job start`, or async mode in the web process | `bundle exec solid_objects start` |
|
|
18
|
+
| Databases | PostgreSQL only | SQLite, PostgreSQL, MySQL |
|
|
19
|
+
| Licence | MIT | MIT |
|
|
20
|
+
|
|
21
|
+
The GoodJob facts come from the [GoodJob 4.22.1 README](https://github.com/bensheldon/good_job/blob/v4.22.1/README.md) (checked October 15, 2026).
|
|
22
|
+
|
|
23
|
+
## Choose GoodJob when
|
|
24
|
+
|
|
25
|
+
- The jobs are independent.
|
|
26
|
+
- You need a limit on jobs at a time for each label, and order does not matter.
|
|
27
|
+
- The job keeps no state of its own between runs.
|
|
28
|
+
- You need cron for the whole app, batches, or a job dashboard. Solid Objects has no batches and no app-wide cron. Its reminders can repeat with `every:` for one actor, and its dashboard shows actors, not jobs.
|
|
29
|
+
|
|
30
|
+
## Choose Solid Objects when
|
|
31
|
+
|
|
32
|
+
- The updates for one customer must apply in the order they arrived.
|
|
33
|
+
- Each customer has state that the updates read and change, for example the last synced version.
|
|
34
|
+
- A failed update must wait with backoff, keep its place in the order, and survive a restart.
|
|
35
|
+
- You run MySQL or SQLite, or you want the same model on PostgreSQL.
|
|
36
|
+
|
|
37
|
+
## The same terms in each
|
|
38
|
+
|
|
39
|
+
| GoodJob and Active Job | Solid Objects |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| The `label:` of `good_job_concurrency_rule` | The actor ID |
|
|
42
|
+
| A job class with `perform` | An actor method (an operation) |
|
|
43
|
+
| `perform_later` | `ref(id).async.method(...)` |
|
|
44
|
+
| `retry_on` with `wait:` | Effect retries (`max_attempts`, `retry_delay`), then `on_failure` and a reminder |
|
|
45
|
+
| `set(wait:)` | `ref(id).async(available_at:)`, or `schedule(at:, key:)` inside the actor |
|
|
46
|
+
| A row in your own table | `attribute` on the actor |
|
|
47
|
+
| Cron and batches | No equivalent. Keep them in GoodJob |
|
|
48
|
+
|
|
49
|
+
GoodJob 4.21.0 deprecated rules with both `label:` and `key:`. GoodJob 4.22.0 deprecated `good_job_control_concurrency_with` and rules with `key:` alone. The README says that they "will raise in GoodJob v5". The current form is `good_job_concurrency_rule` with `label:`.
|
|
50
|
+
|
|
51
|
+
## One billing sync for each customer
|
|
52
|
+
|
|
53
|
+
The task: a Rails 8 app on PostgreSQL syncs each customer's subscription with an external billing API. The updates for one customer must run one at a time and in the order they arrived. Different customers run in parallel. Each customer has state: the last synced version, a retry count, and the next retry time. A failed sync must retry with backoff and survive a restart.
|
|
54
|
+
|
|
55
|
+
### With GoodJob
|
|
56
|
+
|
|
57
|
+
This job follows the GoodJob and Active Job documentation. It was not run by the Solid Objects test suite.
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
class SyncSubscriptionJob < ApplicationJob
|
|
61
|
+
include GoodJob::ActiveJobExtensions::Concurrency
|
|
62
|
+
|
|
63
|
+
BACKOFF = ->(executions) { (executions**4) + 2 }
|
|
64
|
+
|
|
65
|
+
good_job_concurrency_rule(label: -> { "customer-#{arguments.first}" }, apply_label: true, perform_limit: 1)
|
|
66
|
+
retry_on BillingApi::TransientError, wait: BACKOFF, attempts: 10
|
|
67
|
+
|
|
68
|
+
def perform(customer_id, version)
|
|
69
|
+
sync = BillingSyncState.find_or_create_by!(customer_id:)
|
|
70
|
+
return if version <= sync.last_synced_version
|
|
71
|
+
|
|
72
|
+
BillingApi.push_subscription(customer_id, version)
|
|
73
|
+
sync.update!(last_synced_version: version, retry_count: 0, next_retry_at: nil)
|
|
74
|
+
rescue BillingApi::TransientError
|
|
75
|
+
failures = exception_executions["[BillingApi::TransientError]"].to_i + 1
|
|
76
|
+
sync.update!(retry_count: sync.retry_count + 1, next_retry_at: BACKOFF.call(failures).seconds.from_now)
|
|
77
|
+
raise
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`BillingSyncState` is a plain Active Record model. Its `last_synced_version` and `retry_count` columns are integers with a default of 0, and `next_retry_at` is a timestamp. `exception_executions` counts the earlier failures for one error class, and `retry_on` uses the same count for its delay.
|
|
83
|
+
|
|
84
|
+
What this job gets from GoodJob:
|
|
85
|
+
|
|
86
|
+
- At most one `SyncSubscriptionJob` runs at a time for each customer.
|
|
87
|
+
- A job that was running when its process stopped runs again.
|
|
88
|
+
- The `version` check skips an update that is older than the last synced one.
|
|
89
|
+
|
|
90
|
+
What it does not get:
|
|
91
|
+
|
|
92
|
+
- Order. GoodJob says that "a waiting job that is re-scheduled can be overtaken by a newly enqueued job, and jobs retried with incremental back-off run in backoff order". When version 2 fails, version 3 can run first, and the retry of version 2 skips. That is correct only if each update carries the full state. If each update is a change that must apply in order, the job needs its own sequence check.
|
|
93
|
+
- A quiet wait. A second job for the same customer raises `ConcurrencyExceededError` and retries with backoff. With the `good_job_concurrency_claims` table, GoodJob reschedules the longest-waiting job to run at once when the key becomes free. The backoff stays as a fallback.
|
|
94
|
+
|
|
95
|
+
GoodJob also says that its strategy "assumes that collisions are atypical". The updates for one busy customer collide often.
|
|
96
|
+
|
|
97
|
+
### With Solid Objects
|
|
98
|
+
|
|
99
|
+
There is one `CustomerBillingSync` actor for each customer ID.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
class CustomerBillingSync < SolidObjects::Actor
|
|
103
|
+
MAXIMUM_BACKOFF_MINUTES = 60
|
|
104
|
+
|
|
105
|
+
attribute :last_synced_version, default: 0
|
|
106
|
+
attribute :pending, default: -> { [] }
|
|
107
|
+
attribute :pushing, default: false
|
|
108
|
+
attribute :retry_count, default: 0
|
|
109
|
+
attribute :next_retry_at, default: nil
|
|
110
|
+
|
|
111
|
+
def update_subscription(version:, plan:)
|
|
112
|
+
return newest_version if version <= newest_version
|
|
113
|
+
|
|
114
|
+
self.pending = pending + [ { "version" => version, "plan" => plan, "key" => "billing-#{actor_id}-v#{version}" } ]
|
|
115
|
+
push_next unless pushing || next_retry_at
|
|
116
|
+
version
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def pushed(effect_id:, arguments:, result:)
|
|
120
|
+
self.pushing = false
|
|
121
|
+
self.last_synced_version = arguments.fetch("version")
|
|
122
|
+
self.pending = pending.drop(1)
|
|
123
|
+
self.retry_count = 0
|
|
124
|
+
self.next_retry_at = nil
|
|
125
|
+
push_next
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def push_failed(effect_id:, arguments:, error:)
|
|
129
|
+
self.pushing = false
|
|
130
|
+
self.retry_count += 1
|
|
131
|
+
retry_at = [ 2**retry_count, MAXIMUM_BACKOFF_MINUTES ].min.minutes.from_now
|
|
132
|
+
self.next_retry_at = retry_at.iso8601
|
|
133
|
+
schedule(at: retry_at, key: "retry").retry_push
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def retry_push
|
|
137
|
+
self.next_retry_at = nil
|
|
138
|
+
push_next unless pushing
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def newest_version
|
|
144
|
+
pending.last&.fetch("version") || last_synced_version
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def push_next
|
|
148
|
+
update = pending.first
|
|
149
|
+
return unless update
|
|
150
|
+
|
|
151
|
+
self.pushing = true
|
|
152
|
+
emit :push_subscription,
|
|
153
|
+
idempotency_key: update.fetch("key"),
|
|
154
|
+
customer_id: actor_id,
|
|
155
|
+
version: update.fetch("version"),
|
|
156
|
+
plan: update.fetch("plan"),
|
|
157
|
+
on_success: :pushed,
|
|
158
|
+
on_failure: :push_failed
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The initializer registers the effect that calls the billing API:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
SolidObjects.register_effect(:push_subscription) do |arguments, _context|
|
|
167
|
+
BillingApi.push_subscription(
|
|
168
|
+
idempotency_key: arguments.fetch("idempotency_key"),
|
|
169
|
+
customer_id: arguments.fetch("customer_id"),
|
|
170
|
+
version: arguments.fetch("version"),
|
|
171
|
+
plan: arguments.fetch("plan")
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The app sends each update with `async`:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
CustomerBillingSync.ref(customer.id.to_s)
|
|
180
|
+
.async(idempotency_key: "subscription-#{subscription.id}-v#{subscription.version}", authorization_context: Current.user)
|
|
181
|
+
.update_subscription(version: subscription.version, plan: subscription.plan)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The generated policies deny every call. Allow this call in your `authorize_message` policy. See [authorization policies](../authorization.md).
|
|
185
|
+
|
|
186
|
+
How it works:
|
|
187
|
+
|
|
188
|
+
- The runtime stores each message in SQL before `async` returns. Messages for one customer run one at a time, in the order that the runtime stores them. A second message waits in the mailbox. It does not fail and retry.
|
|
189
|
+
- `update_subscription` appends the update to `pending`. It ignores a version that is not newer than the newest one it knows, so a repeated delivery changes nothing.
|
|
190
|
+
- `push_next` stages one effect for the oldest pending update. `pushing` stays true until that effect succeeds or fails. So the billing API never gets two pushes for one customer at the same time, and it gets the versions in order.
|
|
191
|
+
- Each pending update carries its own idempotency key, made from the customer ID and the version. The effect handler sends that key. So the billing API applies an update once, even when a crash runs the effect again or a backoff reminder stages a new effect.
|
|
192
|
+
- The runtime retries a failed effect with a short delay (5 attempts by default). If every attempt fails, `push_failed` counts the failure and schedules `retry_push` with backoff: 2, 4, 8 minutes, up to 60 minutes. The update stays first in `pending`, so it keeps its place. A newer update waits for the retry too.
|
|
193
|
+
- The reminder is a row in the database. It runs after a deploy or a restart.
|
|
194
|
+
|
|
195
|
+
### What the tests prove
|
|
196
|
+
|
|
197
|
+
[The tests](../../test/compare/customer_billing_sync_test.rb) show these results:
|
|
198
|
+
|
|
199
|
+
- Three updates for one customer reach the billing API one at a time, as versions 1, 2, and 3.
|
|
200
|
+
- Two customers each have a staged push at the same time. One customer does not wait for the other.
|
|
201
|
+
- A push that runs again after a lost response uses the same idempotency key, and the billing API applies it once.
|
|
202
|
+
- A push that runs again after the backoff also uses the same idempotency key, and the billing API applies it once.
|
|
203
|
+
- A repeated or older update sends no push to the billing API.
|
|
204
|
+
- A newer update that arrives during the backoff waits for the retry.
|
|
205
|
+
- A failed push records the retry count and the next retry time. After the test resets the caller process and moves the clock 3 minutes ahead, the reminder runs, the push succeeds, and the retry count goes back to 0.
|
|
206
|
+
|
|
207
|
+
## Questions that teams ask
|
|
208
|
+
|
|
209
|
+
**Is Solid Objects mature enough?** Here are the facts:
|
|
210
|
+
|
|
211
|
+
- First public release: August 6, 2026.
|
|
212
|
+
- Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md).
|
|
213
|
+
- One maintainer.
|
|
214
|
+
- One first-party application with more than 100,000 users runs it in production. That is a count of users, not a measure of load. No production use by other people is known.
|
|
215
|
+
- The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work.
|
|
216
|
+
|
|
217
|
+
GoodJob is older and has more users. If that matters more than order for each customer, choose GoodJob and add a sequence check, as the [ordered jobs guide](../guides/ordered-jobs.md) shows.
|
|
218
|
+
|
|
219
|
+
**Does it need another process?** Yes. `bundle exec solid_objects start` runs async messages, effects, and reminders. GoodJob in external mode also needs a process: `bundle exec good_job start`. While the Solid Objects process is down, pending work stays in SQL.
|
|
220
|
+
|
|
221
|
+
**Is delivery exactly once?** No. Delivery is at least once. A turn can run again after a crash. Give each external call a stable idempotency key. The effect ID (`context.id`) is stable across the retries of one effect. The example stores a key with each update, so the key also survives a backoff retry. See [correctness and delivery semantics](../correctness.md).
|
|
222
|
+
|
|
223
|
+
**Can actor code write my tables?** Not directly. An actor reads application records. To write a row in the same transaction as the actor state, use a commit action. The [ordered jobs guide](../guides/ordered-jobs.md) shows one.
|
|
224
|
+
|
|
225
|
+
**What if one customer is very busy?** One actor runs one turn at a time. If one customer has independent streams of work, give each stream its own actor ID, for example one for each subscription.
|
|
226
|
+
|
|
227
|
+
**Can I use both?** Yes. Keep your other jobs in GoodJob. The two use different tables in the same database.
|
|
228
|
+
|
|
229
|
+
## Sources
|
|
230
|
+
|
|
231
|
+
Checked October 15, 2026:
|
|
232
|
+
|
|
233
|
+
- [GoodJob 4.22.1 README: concurrency controls](https://github.com/bensheldon/good_job/blob/v4.22.1/README.md#concurrency-controls)
|
|
234
|
+
- [GoodJob 4.22.1 README: how concurrency controls work](https://github.com/bensheldon/good_job/blob/v4.22.1/README.md#how-concurrency-controls-work)
|
|
235
|
+
- [GoodJob 4.22.1 README: migrating from concurrency keys to labels](https://github.com/bensheldon/good_job/blob/v4.22.1/README.md#migrating-from-concurrency-keys-to-labels)
|
|
236
|
+
- [GoodJob 4.22.1 README: interrupts, graceful shutdown, and SIGKILL](https://github.com/bensheldon/good_job/blob/v4.22.1/README.md#interrupts-graceful-shutdown-and-sigkill)
|
|
237
|
+
- [GoodJob changelog](https://github.com/bensheldon/good_job/blob/v4.22.1/CHANGELOG.md)
|
|
238
|
+
- [Active Job `retry_on`](https://api.rubyonrails.org/classes/ActiveJob/Exceptions/ClassMethods.html)
|
|
239
|
+
|
|
240
|
+
## More information
|
|
241
|
+
|
|
242
|
+
- [All comparisons](../comparisons.md)
|
|
243
|
+
- [Solid Queue limits_concurrency and Solid Objects](solid-queue.md)
|
|
244
|
+
- [Run jobs in order for each customer in Rails](../guides/ordered-jobs.md)
|
|
245
|
+
- [Guide for coding agents](../agents.md)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Solid Queue limits_concurrency and Solid Objects: ordered work for each customer in Rails
|
|
2
|
+
|
|
3
|
+
Solid Queue is the default Active Job backend in Rails 8. Its `limits_concurrency` option runs at most a set number of jobs at a time for each key, but it does not guarantee their order. Solid Objects (the `solid_objects` gem) gives each customer one actor with an ordered mailbox, its own durable state, and reminders, in the SQL database that the app already uses. Use Solid Queue when jobs are independent or order does not matter. Use Solid Objects when the updates for one customer must apply in the order they arrived, and each customer has state that a retry must keep.
|
|
4
|
+
|
|
5
|
+
Solid Objects is an independent project. It is not affiliated with Rails, 37signals, or Solid Queue.
|
|
6
|
+
|
|
7
|
+
## The short answer
|
|
8
|
+
|
|
9
|
+
| Question | Solid Queue with `limits_concurrency` | Solid Objects |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| What runs one at a time | Jobs with the same concurrency key | Calls to the same actor ID |
|
|
12
|
+
| Order for each key | Not guaranteed. The README says "there's no guarantee about _the order of execution_" | Kept. Messages for one actor run in the order that the runtime stores them |
|
|
13
|
+
| State for each key | Your own table | Actor attributes, stored by the gem |
|
|
14
|
+
| Later work | `set(wait_until:)`, `retry_on`, and recurring tasks | Reminders with `schedule(at:, key:)` |
|
|
15
|
+
| A worker stops in the middle of a run | After a graceful stop, the job goes back to the queue. After a process dies, the job is marked failed, and Solid Queue does not retry it | The lease expires and the turn runs again. Delivery is at least once |
|
|
16
|
+
| Process to run | `bin/jobs`, or the Puma plugin | `bundle exec solid_objects start` |
|
|
17
|
+
| Databases | MySQL, PostgreSQL, SQLite | SQLite, PostgreSQL, MySQL |
|
|
18
|
+
| Licence | MIT | MIT |
|
|
19
|
+
|
|
20
|
+
The Solid Queue facts come from the [Solid Queue 1.7.0 README](https://github.com/rails/solid_queue/blob/v1.7.0/README.md) (checked October 15, 2026).
|
|
21
|
+
|
|
22
|
+
## Choose Solid Queue when
|
|
23
|
+
|
|
24
|
+
- The jobs are independent.
|
|
25
|
+
- You need at most a set number of jobs at a time for each key, and order does not matter.
|
|
26
|
+
- The job keeps no state of its own between runs.
|
|
27
|
+
- You need recurring tasks for the whole app, batches, or a job dashboard. Solid Objects has no batches and no app-wide recurring tasks. Its reminders can repeat with `every:` for one actor, and its dashboard shows actors, not jobs.
|
|
28
|
+
|
|
29
|
+
## Choose Solid Objects when
|
|
30
|
+
|
|
31
|
+
- The updates for one customer must apply in the order they arrived.
|
|
32
|
+
- Each customer has state that the updates read and change, for example the last synced version.
|
|
33
|
+
- A failed update must wait with backoff, keep its place in the order, and survive a restart.
|
|
34
|
+
|
|
35
|
+
## The same terms in each
|
|
36
|
+
|
|
37
|
+
| Solid Queue and Active Job | Solid Objects |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| The `key:` of `limits_concurrency` | The actor ID |
|
|
40
|
+
| A job class with `perform` | An actor method (an operation) |
|
|
41
|
+
| `perform_later` | `ref(id).async.method(...)` |
|
|
42
|
+
| `retry_on` with `wait:` | Effect retries (`max_attempts`, `retry_delay`), then `on_failure` and a reminder |
|
|
43
|
+
| `set(wait_until:)` | `ref(id).async(available_at:)`, or `schedule(at:, key:)` inside the actor |
|
|
44
|
+
| A row in your own table | `attribute` on the actor |
|
|
45
|
+
| `config/recurring.yml` | No equivalent. Keep recurring tasks in Solid Queue |
|
|
46
|
+
|
|
47
|
+
## One billing sync for each customer
|
|
48
|
+
|
|
49
|
+
The task: a Rails 8 app on PostgreSQL syncs each customer's subscription with an external billing API. The updates for one customer must run one at a time and in the order they arrived. Different customers run in parallel. Each customer has state: the last synced version, a retry count, and the next retry time. A failed sync must retry with backoff and survive a restart.
|
|
50
|
+
|
|
51
|
+
### With Solid Queue
|
|
52
|
+
|
|
53
|
+
This job follows the Solid Queue and Active Job documentation. It was not run by the Solid Objects test suite.
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class SyncSubscriptionJob < ApplicationJob
|
|
57
|
+
BACKOFF = ->(executions) { (executions**4) + 2 }
|
|
58
|
+
|
|
59
|
+
limits_concurrency to: 1, key: ->(customer_id, *) { customer_id }, duration: 15.minutes
|
|
60
|
+
retry_on BillingApi::TransientError, wait: BACKOFF, attempts: 10
|
|
61
|
+
|
|
62
|
+
def perform(customer_id, version)
|
|
63
|
+
sync = BillingSyncState.find_or_create_by!(customer_id:)
|
|
64
|
+
return if version <= sync.last_synced_version
|
|
65
|
+
|
|
66
|
+
BillingApi.push_subscription(customer_id, version)
|
|
67
|
+
sync.update!(last_synced_version: version, retry_count: 0, next_retry_at: nil)
|
|
68
|
+
rescue BillingApi::TransientError
|
|
69
|
+
failures = exception_executions["[BillingApi::TransientError]"].to_i + 1
|
|
70
|
+
sync.update!(retry_count: sync.retry_count + 1, next_retry_at: BACKOFF.call(failures).seconds.from_now)
|
|
71
|
+
raise
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`BillingSyncState` is a plain Active Record model. Its `last_synced_version` and `retry_count` columns are integers with a default of 0, and `next_retry_at` is a timestamp. `exception_executions` counts the earlier failures for one error class, and `retry_on` uses the same count for its delay.
|
|
77
|
+
|
|
78
|
+
What this job gets from Solid Queue:
|
|
79
|
+
|
|
80
|
+
- At most one `SyncSubscriptionJob` runs at a time for each customer.
|
|
81
|
+
- The `version` check skips an update that is older than the last synced one.
|
|
82
|
+
|
|
83
|
+
What it does not get:
|
|
84
|
+
|
|
85
|
+
- Order. Solid Queue says that there's "no guarantee about _the order of execution_". When version 2 fails, `retry_on` enqueues it again with a delay. Version 3 can then run first, and the retry of version 2 skips. That is correct only if each update carries the full state. If each update is a change that must apply in order, the job needs its own sequence check.
|
|
86
|
+
- A retry that keeps its place. Solid Queue says that a retried job gets in line for the semaphore "in the same way as new jobs".
|
|
87
|
+
- Recovery after a crash. After a graceful stop, in-flight jobs go back to the queue. If a process dies without a clean stop, its jobs are marked failed (`ProcessPrunedError` or `ProcessExitError`), and Solid Queue "deliberately doesn't retry these automatically". Retry them from the Mission Control Jobs dashboard (a separate gem) or from a `fail_many_claimed.solid_queue` subscriber.
|
|
88
|
+
- A long run inside `duration`. If a job holds the semaphore for longer than `duration`, "a new job with the same key might gain the lock".
|
|
89
|
+
|
|
90
|
+
Solid Queue also advises against mixing concurrency controls with delayed jobs, and it says that concurrency controls "introduce significant overhead".
|
|
91
|
+
|
|
92
|
+
### With Solid Objects
|
|
93
|
+
|
|
94
|
+
There is one `CustomerBillingSync` actor for each customer ID.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
class CustomerBillingSync < SolidObjects::Actor
|
|
98
|
+
MAXIMUM_BACKOFF_MINUTES = 60
|
|
99
|
+
|
|
100
|
+
attribute :last_synced_version, default: 0
|
|
101
|
+
attribute :pending, default: -> { [] }
|
|
102
|
+
attribute :pushing, default: false
|
|
103
|
+
attribute :retry_count, default: 0
|
|
104
|
+
attribute :next_retry_at, default: nil
|
|
105
|
+
|
|
106
|
+
def update_subscription(version:, plan:)
|
|
107
|
+
return newest_version if version <= newest_version
|
|
108
|
+
|
|
109
|
+
self.pending = pending + [ { "version" => version, "plan" => plan, "key" => "billing-#{actor_id}-v#{version}" } ]
|
|
110
|
+
push_next unless pushing || next_retry_at
|
|
111
|
+
version
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def pushed(effect_id:, arguments:, result:)
|
|
115
|
+
self.pushing = false
|
|
116
|
+
self.last_synced_version = arguments.fetch("version")
|
|
117
|
+
self.pending = pending.drop(1)
|
|
118
|
+
self.retry_count = 0
|
|
119
|
+
self.next_retry_at = nil
|
|
120
|
+
push_next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def push_failed(effect_id:, arguments:, error:)
|
|
124
|
+
self.pushing = false
|
|
125
|
+
self.retry_count += 1
|
|
126
|
+
retry_at = [ 2**retry_count, MAXIMUM_BACKOFF_MINUTES ].min.minutes.from_now
|
|
127
|
+
self.next_retry_at = retry_at.iso8601
|
|
128
|
+
schedule(at: retry_at, key: "retry").retry_push
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def retry_push
|
|
132
|
+
self.next_retry_at = nil
|
|
133
|
+
push_next unless pushing
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def newest_version
|
|
139
|
+
pending.last&.fetch("version") || last_synced_version
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def push_next
|
|
143
|
+
update = pending.first
|
|
144
|
+
return unless update
|
|
145
|
+
|
|
146
|
+
self.pushing = true
|
|
147
|
+
emit :push_subscription,
|
|
148
|
+
idempotency_key: update.fetch("key"),
|
|
149
|
+
customer_id: actor_id,
|
|
150
|
+
version: update.fetch("version"),
|
|
151
|
+
plan: update.fetch("plan"),
|
|
152
|
+
on_success: :pushed,
|
|
153
|
+
on_failure: :push_failed
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The initializer registers the effect that calls the billing API:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
SolidObjects.register_effect(:push_subscription) do |arguments, _context|
|
|
162
|
+
BillingApi.push_subscription(
|
|
163
|
+
idempotency_key: arguments.fetch("idempotency_key"),
|
|
164
|
+
customer_id: arguments.fetch("customer_id"),
|
|
165
|
+
version: arguments.fetch("version"),
|
|
166
|
+
plan: arguments.fetch("plan")
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The app sends each update with `async`:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
CustomerBillingSync.ref(customer.id.to_s)
|
|
175
|
+
.async(idempotency_key: "subscription-#{subscription.id}-v#{subscription.version}", authorization_context: Current.user)
|
|
176
|
+
.update_subscription(version: subscription.version, plan: subscription.plan)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The generated policies deny every call. Allow this call in your `authorize_message` policy. See [authorization policies](../authorization.md).
|
|
180
|
+
|
|
181
|
+
How it works:
|
|
182
|
+
|
|
183
|
+
- The runtime stores each message in SQL before `async` returns. Messages for one customer run one at a time, in the order that the runtime stores them.
|
|
184
|
+
- `update_subscription` appends the update to `pending`. It ignores a version that is not newer than the newest one it knows, so a repeated delivery changes nothing.
|
|
185
|
+
- `push_next` stages one effect for the oldest pending update. `pushing` stays true until that effect succeeds or fails. So the billing API never gets two pushes for one customer at the same time, and it gets the versions in order.
|
|
186
|
+
- Each pending update carries its own idempotency key, made from the customer ID and the version. The effect handler sends that key. So the billing API applies an update once, even when a crash runs the effect again or a backoff reminder stages a new effect.
|
|
187
|
+
- The runtime retries a failed effect with a short delay (5 attempts by default). If every attempt fails, `push_failed` counts the failure and schedules `retry_push` with backoff: 2, 4, 8 minutes, up to 60 minutes. The update stays first in `pending`, so it keeps its place. A newer update waits for the retry too.
|
|
188
|
+
- The reminder is a row in the database. It runs after a deploy or a restart.
|
|
189
|
+
|
|
190
|
+
### What the tests prove
|
|
191
|
+
|
|
192
|
+
[The tests](../../test/compare/customer_billing_sync_test.rb) show these results:
|
|
193
|
+
|
|
194
|
+
- Three updates for one customer reach the billing API one at a time, as versions 1, 2, and 3.
|
|
195
|
+
- Two customers each have a staged push at the same time. One customer does not wait for the other.
|
|
196
|
+
- A push that runs again after a lost response uses the same idempotency key, and the billing API applies it once.
|
|
197
|
+
- A push that runs again after the backoff also uses the same idempotency key, and the billing API applies it once.
|
|
198
|
+
- A repeated or older update sends no push to the billing API.
|
|
199
|
+
- A newer update that arrives during the backoff waits for the retry.
|
|
200
|
+
- A failed push records the retry count and the next retry time. After the test resets the caller process and moves the clock 3 minutes ahead, the reminder runs, the push succeeds, and the retry count goes back to 0.
|
|
201
|
+
|
|
202
|
+
## Questions that teams ask
|
|
203
|
+
|
|
204
|
+
**Is Solid Objects mature enough?** Here are the facts:
|
|
205
|
+
|
|
206
|
+
- First public release: August 6, 2026.
|
|
207
|
+
- Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md).
|
|
208
|
+
- One maintainer.
|
|
209
|
+
- One first-party application with more than 100,000 users runs it in production. That is a count of users, not a measure of load. No production use by other people is known.
|
|
210
|
+
- The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work.
|
|
211
|
+
|
|
212
|
+
Solid Queue is older and has more users. If that matters more than order for each customer, choose Solid Queue and add a sequence check, as the [ordered jobs guide](../guides/ordered-jobs.md) shows.
|
|
213
|
+
|
|
214
|
+
**Does it need another process?** Yes. `bundle exec solid_objects start` runs async messages, effects, and reminders. Solid Queue also needs a process: `bin/jobs` or the Puma plugin. While the Solid Objects process is down, pending work stays in SQL.
|
|
215
|
+
|
|
216
|
+
**Is delivery exactly once?** No. Delivery is at least once. A turn can run again after a crash. Give each external call a stable idempotency key. The effect ID (`context.id`) is stable across the retries of one effect. The example stores a key with each update, so the key also survives a backoff retry. See [correctness and delivery semantics](../correctness.md).
|
|
217
|
+
|
|
218
|
+
**Can actor code write my tables?** Not directly. An actor reads application records. To write a row in the same transaction as the actor state, use a commit action. The [ordered jobs guide](../guides/ordered-jobs.md) shows one.
|
|
219
|
+
|
|
220
|
+
**What if one customer is very busy?** One actor runs one turn at a time. If one customer has independent streams of work, give each stream its own actor ID, for example one for each subscription.
|
|
221
|
+
|
|
222
|
+
**Can I use both?** Yes. Keep your other jobs in Solid Queue. The two use different tables. By default, Solid Queue uses its own `queue` database, and Solid Objects uses the app database.
|
|
223
|
+
|
|
224
|
+
## Sources
|
|
225
|
+
|
|
226
|
+
Checked October 15, 2026:
|
|
227
|
+
|
|
228
|
+
- [Solid Queue 1.7.0 README: concurrency controls](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#concurrency-controls)
|
|
229
|
+
- [Solid Queue 1.7.0 README: jobs interrupted by non-graceful process death](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#jobs-interrupted-by-non-graceful-process-death)
|
|
230
|
+
- [Solid Queue 1.7.0 README: scheduled jobs](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#scheduled-jobs)
|
|
231
|
+
- [Solid Queue 1.7.0 README: performance considerations](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#performance-considerations)
|
|
232
|
+
- [Active Job `retry_on`](https://api.rubyonrails.org/classes/ActiveJob/Exceptions/ClassMethods.html)
|
|
233
|
+
|
|
234
|
+
## More information
|
|
235
|
+
|
|
236
|
+
- [All comparisons](../comparisons.md)
|
|
237
|
+
- [GoodJob concurrency controls and Solid Objects](good-job.md)
|
|
238
|
+
- [Run jobs in order for each customer in Rails](../guides/ordered-jobs.md)
|
|
239
|
+
- [Guide for coding agents](../agents.md)
|
data/docs/comparisons.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Compare Solid Objects with other Rails tools
|
|
2
|
+
|
|
3
|
+
Each page compares Solid Objects with one tool on one real task. Each page says when the other tool is the better choice. The Solid Objects actor and effect code on each page runs in the test suite.
|
|
4
|
+
|
|
5
|
+
| Page | The other tool | The task |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| [Solid Queue limits_concurrency and Solid Objects](compare/solid-queue.md) | Solid Queue, the Rails 8 default job backend | Ordered billing sync for each customer |
|
|
8
|
+
| [GoodJob concurrency controls and Solid Objects](compare/good-job.md) | GoodJob, a PostgreSQL job backend | Ordered billing sync for each customer |
|
|
9
|
+
|
|
10
|
+
## Choose the smallest tool that works
|
|
11
|
+
|
|
12
|
+
- The whole rule fits in one request: use a row lock (`with_lock`), a constraint, or a short transaction.
|
|
13
|
+
- Independent background work: use a job queue such as Solid Queue or GoodJob.
|
|
14
|
+
- One job at a time for each key, order not important: use the concurrency controls of your job queue.
|
|
15
|
+
- Order for each key, state for each key, reminders, and recovery after a restart: use an actor for each key.
|
|
16
|
+
|
|
17
|
+
The problem guides start from the same choices:
|
|
18
|
+
|
|
19
|
+
- [Prevent race conditions in Rails](guides/race-conditions.md)
|
|
20
|
+
- [Run jobs in order for each customer in Rails](guides/ordered-jobs.md)
|
|
21
|
+
- [Expiring reservations](guides/expiring-reservations.md)
|
|
22
|
+
- [Save state and queue work together](guides/transactional-outbox.md)
|
|
23
|
+
|
|
24
|
+
## Status
|
|
25
|
+
|
|
26
|
+
Solid Objects 1.0.0 shipped on October 13, 2026. The first public release was on August 6, 2026. It has one maintainer. One first-party application with more than 100,000 users runs it in production. That is a count of users, not a measure of load. No production use by other people is known. See [versioning](versioning.md).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class CustomerBillingSync < SolidObjects::Actor
|
|
4
|
+
MAXIMUM_BACKOFF_MINUTES = 60
|
|
5
|
+
|
|
6
|
+
attribute :last_synced_version, default: 0
|
|
7
|
+
attribute :pending, default: -> { [] }
|
|
8
|
+
attribute :pushing, default: false
|
|
9
|
+
attribute :retry_count, default: 0
|
|
10
|
+
attribute :next_retry_at, default: nil
|
|
11
|
+
|
|
12
|
+
def update_subscription(version:, plan:)
|
|
13
|
+
return newest_version if version <= newest_version
|
|
14
|
+
|
|
15
|
+
self.pending = pending + [ { "version" => version, "plan" => plan, "key" => "billing-#{actor_id}-v#{version}" } ]
|
|
16
|
+
push_next unless pushing || next_retry_at
|
|
17
|
+
version
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pushed(effect_id:, arguments:, result:)
|
|
21
|
+
self.pushing = false
|
|
22
|
+
self.last_synced_version = arguments.fetch("version")
|
|
23
|
+
self.pending = pending.drop(1)
|
|
24
|
+
self.retry_count = 0
|
|
25
|
+
self.next_retry_at = nil
|
|
26
|
+
push_next
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def push_failed(effect_id:, arguments:, error:)
|
|
30
|
+
self.pushing = false
|
|
31
|
+
self.retry_count += 1
|
|
32
|
+
retry_at = [ 2**retry_count, MAXIMUM_BACKOFF_MINUTES ].min.minutes.from_now
|
|
33
|
+
self.next_retry_at = retry_at.iso8601
|
|
34
|
+
schedule(at: retry_at, key: "retry").retry_push
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def retry_push
|
|
38
|
+
self.next_retry_at = nil
|
|
39
|
+
push_next unless pushing
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def newest_version
|
|
45
|
+
pending.last&.fetch("version") || last_synced_version
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def push_next
|
|
49
|
+
update = pending.first
|
|
50
|
+
return unless update
|
|
51
|
+
|
|
52
|
+
self.pushing = true
|
|
53
|
+
emit :push_subscription,
|
|
54
|
+
idempotency_key: update.fetch("key"),
|
|
55
|
+
customer_id: actor_id,
|
|
56
|
+
version: update.fetch("version"),
|
|
57
|
+
plan: update.fetch("plan"),
|
|
58
|
+
on_success: :pushed,
|
|
59
|
+
on_failure: :push_failed
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
SolidObjects.register_effect(:push_subscription) do |arguments, _context|
|
|
4
|
+
BillingApi.push_subscription(
|
|
5
|
+
idempotency_key: arguments.fetch("idempotency_key"),
|
|
6
|
+
customer_id: arguments.fetch("customer_id"),
|
|
7
|
+
version: arguments.fetch("version"),
|
|
8
|
+
plan: arguments.fetch("plan")
|
|
9
|
+
)
|
|
10
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_objects
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Carlson
|
|
@@ -357,6 +357,9 @@ files:
|
|
|
357
357
|
- docs/architecture.md
|
|
358
358
|
- docs/authorization.md
|
|
359
359
|
- docs/benchmarks.md
|
|
360
|
+
- docs/compare/good-job.md
|
|
361
|
+
- docs/compare/solid-queue.md
|
|
362
|
+
- docs/comparisons.md
|
|
360
363
|
- docs/correctness.md
|
|
361
364
|
- docs/dashboard.md
|
|
362
365
|
- docs/database-schema.md
|
|
@@ -397,6 +400,8 @@ files:
|
|
|
397
400
|
- examples/at_least_once/demo.rb
|
|
398
401
|
- examples/at_least_once/effect_worker.rb
|
|
399
402
|
- examples/at_least_once/sink.rb
|
|
403
|
+
- examples/compare/customer_billing_sync/customer_billing_sync.rb
|
|
404
|
+
- examples/compare/customer_billing_sync/solid_objects.rb
|
|
400
405
|
- examples/guides/expiring_reservations/seat_inventory.rb
|
|
401
406
|
- examples/guides/ordered_jobs/account.rb
|
|
402
407
|
- examples/guides/ordered_jobs/apply_entry_job.rb
|