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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6230f0722b011cd685875a291ba17f1435712928c4af1881b692045fbf6f3ab
4
- data.tar.gz: edfbf33e568b2c0e921deea594feeb41152e3bf854271625ca7923b3a28da466
3
+ metadata.gz: 556d59f2ef4f4862b2d8a851f19ce31b2eca80b6ebb1ea45a8f1dce9c7c1e122
4
+ data.tar.gz: b9c1539d5cff7ea9a75e24268771cabe1571a5b0688fec187bfddc255189c4e5
5
5
  SHA512:
6
- metadata.gz: f90a44f702083feb54a1679cb1ad25509275c7ffeea83ff09d45263031a49ca65078ce982405f4402361e6e668830a537b2a6e177cbed89ee0cc9ff9016b4738
7
- data.tar.gz: d632f853b9031d30416b5ba74ecce82cc39822327896024b0cf25846063ad0bf0fb341f887de0fd9d6c232b8eafd8e335e3209b066a965a9fcffa2fe5571c6a6
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)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.2"
5
5
  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.1
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