Skip to content

Commit ec42211

Browse files
cardmagicclaude
andcommitted
fix: stop a redrive from moving a row twice
A redrive read the scope on every pass, so a row it moved that failed again landed straight back in it. With a handler that was still broken, a task without a limit would move the same rows forever and never finish. Porting this to the TypeScript runtime is where it showed: there the workers run beside the redrive, so the churn was immediate. A pass now takes only rows that were already dead when the task started. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d7e3e0e commit ec42211

6 files changed

Lines changed: 49 additions & 9 deletions

File tree

‎docs/operations.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,10 @@ bounded batch per pass, so a redrive of thousands of rows never holds a
331331
transaction longer than one batch. `redrive_batch_size` defaults to 100 and
332332
`redrive_batch_pause` to 0.05 seconds.
333333

334+
A redrive moves the rows that were already dead when it started. A row that
335+
fails again lands back in the same scope, and without that bound a task whose
336+
handler is still broken would move it forever.
337+
334338
A redrive is idempotent over its scope and its filters. Starting the same one
335339
while it runs returns the running task rather than a second one, which a
336340
dashboard button an operator can press twice needs. A different scope or a

‎lib/solid_objects/dead_letter_scope.rb‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,17 @@ def dead
6868
model.where(status: DEAD)
6969
end
7070

71-
# @rbs (Hash[String, untyped]) -> ActiveRecord::Relation[untyped]
72-
def matching(filters)
71+
# A redrive moves what was already dead when it started. Without that bound
72+
# a row that fails again lands back in the same scope, and a task whose
73+
# handler is still broken would move it forever.
74+
# @rbs (Hash[String, untyped], ?dead_before: untyped) -> ActiveRecord::Relation[untyped]
75+
def matching(filters, dead_before: nil)
7376
relation = dead
7477
actor_type = filters["actor_type"]
7578
failed_after = filters["failed_after"]
7679
relation = relation.joins(:instance).where(Instance.table_name => { actor_type: }) if actor_type
7780
relation = relation.where(updated_at: Time.parse(failed_after)..) if failed_after
81+
relation = relation.where(updated_at: ..dead_before) if dead_before
7882
relation
7983
end
8084

‎lib/solid_objects/redrive_manager.rb‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ def open_task(scope:, filters:, active_scope:, authorization_context:)
103103
def remaining_for(record)
104104
return 0 unless record.status == RUNNING
105105

106-
matching = DeadLetterScope.for_kind(record.kind).matching(record.filters).count
106+
matching = DeadLetterScope
107+
.for_kind(record.kind)
108+
.matching(record.filters, dead_before: record.started_at)
109+
.count
107110
limit = record.move_limit
108111
return matching unless limit
109112

‎lib/solid_objects/redrive_runner.rb‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ def move_batch(record)
3737
size = batch_size(record)
3838
return 0 unless size.positive?
3939

40-
identifiers = scope.matching(record.filters).order(:id).limit(size).pluck(:id)
40+
identifiers = scope
41+
.matching(record.filters, dead_before: record.started_at)
42+
.order(:id)
43+
.limit(size)
44+
.pluck(:id)
4145
return 0 if identifiers.empty?
4246

4347
revived = scope.revive_all(identifiers)

‎sig/generated/lib/solid_objects/dead_letter_scope.rbs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ module SolidObjects
3434
# @rbs () -> ActiveRecord::Relation[untyped]
3535
def dead: () -> ActiveRecord::Relation[untyped]
3636

37-
# @rbs (Hash[String, untyped]) -> ActiveRecord::Relation[untyped]
38-
def matching: (Hash[String, untyped]) -> ActiveRecord::Relation[untyped]
37+
# A redrive moves what was already dead when it started. Without that bound
38+
# a row that fails again lands back in the same scope, and a task whose
39+
# handler is still broken would move it forever.
40+
# @rbs (Hash[String, untyped], ?dead_before: untyped) -> ActiveRecord::Relation[untyped]
41+
def matching: (Hash[String, untyped], ?dead_before: untyped) -> ActiveRecord::Relation[untyped]
3942

4043
# @rbs (Array[untyped]) -> Integer
4144
def revive_all: (Array[untyped]) -> Integer

‎test/integration/redrive_test.rb‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ def touch
103103
dead_effects(5)
104104
first = SolidObjects.dead_letters.effects.redrive(authorization_context: "operator")
105105
drain
106-
SolidObjects::Effect.update_all(status: "dead")
106+
SolidObjects::Effect.where.not(status: "dead").update_all(
107+
status: "dead", updated_at: SolidObjects.database_adapter.database_now
108+
)
107109

108110
second = SolidObjects.dead_letters.effects.redrive(authorization_context: "operator")
109111

@@ -127,6 +129,22 @@ def touch
127129
assert_equal 15, SolidObjects::Effect.where(status: "dead").count
128130
end
129131

132+
test "does not move a row that died after the task started" do
133+
dead_effects(2)
134+
task = SolidObjects.dead_letters.effects.redrive(authorization_context: "operator")
135+
sleep 0.01
136+
PaymentActor.ref("late").async.place(order: "late")
137+
run_actors
138+
SolidObjects::Effect.where.not(status: "dead").update_all(
139+
status: "dead", updated_at: SolidObjects.database_adapter.database_now
140+
)
141+
142+
drain
143+
144+
assert_equal 2, SolidObjects.redrives.find(task.id, authorization_context: "operator").moved
145+
assert_equal 1, SolidObjects::Effect.where(status: "dead").count
146+
end
147+
130148
test "filters by actor type" do
131149
dead_effects(3)
132150
ShipmentActor.ref("one").async.touch
@@ -255,15 +273,19 @@ def dead_effects(count)
255273
SolidObjects.register_effect(:settle) { raise "declined" }
256274
count.times { |index| PaymentActor.ref("order-#{index}").async.place(order: index) }
257275
run_actors
258-
SolidObjects::Effect.update_all(status: "dead")
276+
SolidObjects::Effect.where.not(status: "dead").update_all(
277+
status: "dead", updated_at: SolidObjects.database_adapter.database_now
278+
)
259279
assert_equal count, SolidObjects::Effect.where(status: "dead").count
260280
end
261281

262282
def dead_broadcast
263283
SolidObjects.configuration.broadcast_adapter = ->(_payload) { raise "transport down" }
264284
ShipmentActor.ref("one").async.touch
265285
run_actors
266-
SolidObjects::Broadcast.update_all(status: "dead")
286+
SolidObjects::Broadcast.where.not(status: "dead").update_all(
287+
status: "dead", updated_at: SolidObjects.database_adapter.database_now
288+
)
267289
end
268290

269291
def drain

0 commit comments

Comments
 (0)