Skip to content

Commit 5f37392

Browse files
committed
fix: keep one billing key across backoff retries
Greptile found that a retry after the backoff staged a new effect with a new ID, so a billing API that had already applied the update could apply it again. Each pending update now carries a key made from the customer ID and the version. The accuracy review found claims that went beyond the sources: order is "not guaranteed" rather than "not kept", a graceful Solid Queue stop returns in-flight jobs, and Solid Objects does have a dashboard and repeating reminders. A newer update now also waits for the backoff.
1 parent 7bef934 commit 5f37392

5 files changed

Lines changed: 35 additions & 13 deletions

File tree

‎docs/compare/good-job.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ class CustomerBillingSync < SolidObjects::Actor
111111
def update_subscription(version:, plan:)
112112
return newest_version if version <= newest_version
113113

114-
self.pending = pending + [ { "version" => version, "plan" => plan } ]
114+
self.pending = pending + [ { "version" => version, "plan" => plan, "key" => "billing-#{actor_id}-v#{version}" } ]
115115
push_next unless pushing || next_retry_at
116116
version
117117
end
@@ -150,6 +150,7 @@ class CustomerBillingSync < SolidObjects::Actor
150150

151151
self.pushing = true
152152
emit :push_subscription,
153+
idempotency_key: update.fetch("key"),
153154
customer_id: actor_id,
154155
version: update.fetch("version"),
155156
plan: update.fetch("plan"),
@@ -162,9 +163,9 @@ end
162163
The initializer registers the effect that calls the billing API:
163164

164165
```ruby
165-
SolidObjects.register_effect(:push_subscription) do |arguments, context|
166+
SolidObjects.register_effect(:push_subscription) do |arguments, _context|
166167
BillingApi.push_subscription(
167-
idempotency_key: context.id,
168+
idempotency_key: arguments.fetch("idempotency_key"),
168169
customer_id: arguments.fetch("customer_id"),
169170
version: arguments.fetch("version"),
170171
plan: arguments.fetch("plan")
@@ -187,7 +188,7 @@ How it works:
187188
- 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.
188189
- `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.
189190
- `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.
190-
- The effect handler uses `context.id` as the idempotency key. The effect can run more than once after a crash, but the ID stays the same.
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.
191192
- 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.
192193
- The reminder is a row in the database. It runs after a deploy or a restart.
193194

@@ -198,6 +199,7 @@ How it works:
198199
- Three updates for one customer reach the billing API one at a time, as versions 1, 2, and 3.
199200
- Two customers each have a staged push at the same time. One customer does not wait for the other.
200201
- 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.
201203
- A repeated or older update sends no push to the billing API.
202204
- A newer update that arrives during the backoff waits for the retry.
203205
- 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.
@@ -216,7 +218,7 @@ GoodJob is older and has more users. If that matters more than order for each cu
216218

217219
**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.
218220

219-
**Is delivery exactly once?** No. Delivery is at least once. A turn can run again after a crash. Use the effect ID as the idempotency key for each external call, as the example does. See [correctness and delivery semantics](../correctness.md).
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).
220222

221223
**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.
222224

‎docs/compare/solid-queue.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ class CustomerBillingSync < SolidObjects::Actor
106106
def update_subscription(version:, plan:)
107107
return newest_version if version <= newest_version
108108

109-
self.pending = pending + [ { "version" => version, "plan" => plan } ]
109+
self.pending = pending + [ { "version" => version, "plan" => plan, "key" => "billing-#{actor_id}-v#{version}" } ]
110110
push_next unless pushing || next_retry_at
111111
version
112112
end
@@ -145,6 +145,7 @@ class CustomerBillingSync < SolidObjects::Actor
145145

146146
self.pushing = true
147147
emit :push_subscription,
148+
idempotency_key: update.fetch("key"),
148149
customer_id: actor_id,
149150
version: update.fetch("version"),
150151
plan: update.fetch("plan"),
@@ -157,9 +158,9 @@ end
157158
The initializer registers the effect that calls the billing API:
158159

159160
```ruby
160-
SolidObjects.register_effect(:push_subscription) do |arguments, context|
161+
SolidObjects.register_effect(:push_subscription) do |arguments, _context|
161162
BillingApi.push_subscription(
162-
idempotency_key: context.id,
163+
idempotency_key: arguments.fetch("idempotency_key"),
163164
customer_id: arguments.fetch("customer_id"),
164165
version: arguments.fetch("version"),
165166
plan: arguments.fetch("plan")
@@ -182,7 +183,7 @@ How it works:
182183
- 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.
183184
- `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.
184185
- `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.
185-
- The effect handler uses `context.id` as the idempotency key. The effect can run more than once after a crash, but the ID stays the same.
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.
186187
- 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.
187188
- The reminder is a row in the database. It runs after a deploy or a restart.
188189

@@ -193,6 +194,7 @@ How it works:
193194
- Three updates for one customer reach the billing API one at a time, as versions 1, 2, and 3.
194195
- Two customers each have a staged push at the same time. One customer does not wait for the other.
195196
- 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.
196198
- A repeated or older update sends no push to the billing API.
197199
- A newer update that arrives during the backoff waits for the retry.
198200
- 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.
@@ -211,7 +213,7 @@ Solid Queue is older and has more users. If that matters more than order for eac
211213

212214
**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.
213215

214-
**Is delivery exactly once?** No. Delivery is at least once. A turn can run again after a crash. Use the effect ID as the idempotency key for each external call, as the example does. See [correctness and delivery semantics](../correctness.md).
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).
215217

216218
**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.
217219

‎examples/compare/customer_billing_sync/customer_billing_sync.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class CustomerBillingSync < SolidObjects::Actor
1212
def update_subscription(version:, plan:)
1313
return newest_version if version <= newest_version
1414

15-
self.pending = pending + [ { "version" => version, "plan" => plan } ]
15+
self.pending = pending + [ { "version" => version, "plan" => plan, "key" => "billing-#{actor_id}-v#{version}" } ]
1616
push_next unless pushing || next_retry_at
1717
version
1818
end
@@ -51,6 +51,7 @@ def push_next
5151

5252
self.pushing = true
5353
emit :push_subscription,
54+
idempotency_key: update.fetch("key"),
5455
customer_id: actor_id,
5556
version: update.fetch("version"),
5657
plan: update.fetch("plan"),

‎examples/compare/customer_billing_sync/solid_objects.rb‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# rbs_inline: enabled
22

3-
SolidObjects.register_effect(:push_subscription) do |arguments, context|
3+
SolidObjects.register_effect(:push_subscription) do |arguments, _context|
44
BillingApi.push_subscription(
5-
idempotency_key: context.id,
5+
idempotency_key: arguments.fetch("idempotency_key"),
66
customer_id: arguments.fetch("customer_id"),
77
version: arguments.fetch("version"),
88
plan: arguments.fetch("plan")

‎test/compare/customer_billing_sync_test.rb‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,23 @@ def drain_until_idle
9191
assert_equal 1, customer.snapshot.last_synced_version
9292
end
9393

94+
test "a push after the backoff uses the same idempotency key" do
95+
SolidObjects.configuration.max_attempts = 1
96+
customer = CustomerBillingSync.ref("customer-1")
97+
BillingApi.lose_next_response = true
98+
customer.update_subscription(version: 1, plan: "basic")
99+
drain_until_idle
100+
101+
SolidObjects.reset_caller_process!
102+
run_due_reminders(now: 3.minutes.from_now)
103+
drain_until_idle
104+
105+
assert_equal 2, BillingApi.calls.length
106+
assert_equal 1, BillingApi.calls.map { |call| call.fetch("key") }.uniq.length
107+
assert_equal 1, BillingApi.pushed.length
108+
assert_equal 1, customer.snapshot.last_synced_version
109+
end
110+
94111
test "a repeated or older update changes nothing" do
95112
customer = CustomerBillingSync.ref("customer-1")
96113
customer.update_subscription(version: 2, plan: "pro")

0 commit comments

Comments
 (0)