Skip to content

Commit c358b0a

Browse files
cardmagicclaude
andcommitted
fix: write an audit row only when the retry happens
The audit was written before the work, in its own transaction, so a retry that raised left a record of something that never happened. A message retry that could not enqueue, and a scope retry whose revive failed, both wrote one. Each event goes in the transaction that causes it now. The same defect was in the TypeScript port and is fixed there too, which is where it surfaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 783cacb commit c358b0a

4 files changed

Lines changed: 81 additions & 33 deletions

File tree

‎lib/solid_objects/dead_letter_manager.rb‎

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,18 @@ def all(authorization_context: nil)
1111
# @rbs (Integer, ?authorization_context: untyped) -> MessageReference
1212
def retry(dead_letter_id, authorization_context: nil)
1313
authorize!(:retry, authorization_context:, dead_letter_id:)
14-
dead_letter = DeadLetter.find(dead_letter_id)
15-
AdministrationAudit.record(
16-
action: "dead_letter.retry",
17-
kind: "message",
18-
subject_id: dead_letter.id,
19-
actor: AdministrationAudit.identity(authorization_context)
20-
)
21-
return MessageReference.from_message(Message.find(dead_letter.retried_message_id)) if dead_letter.retried_message_id
22-
23-
original_message = dead_letter.message
24-
message_reference = Mailbox.new.enqueue(
25-
reference: Reference.new(
26-
actor_type: dead_letter.actor_type,
27-
actor_id: dead_letter.actor_id
28-
),
29-
operation: dead_letter.operation,
30-
arguments: dead_letter.arguments,
31-
delivery_mode: original_message.delivery_mode,
32-
idempotency_key: "dead-letter:#{dead_letter.id}"
33-
)
34-
dead_letter.update!(retried_message_id: message_reference.id)
35-
message_reference
14+
actor = AdministrationAudit.identity(authorization_context)
15+
SolidObjects.database_adapter.transaction do
16+
dead_letter = DeadLetter.find(dead_letter_id)
17+
reference = retried_reference(dead_letter)
18+
AdministrationAudit.record(
19+
action: "dead_letter.retry",
20+
kind: "message",
21+
subject_id: dead_letter.id,
22+
actor:
23+
)
24+
reference
25+
end
3626
end
3727

3828
# @rbs () -> DeadLetterScope
@@ -57,6 +47,26 @@ def broadcasts
5747

5848
private
5949

50+
# @rbs (DeadLetter) -> MessageReference
51+
def retried_reference(dead_letter)
52+
if dead_letter.retried_message_id
53+
return MessageReference.from_message(Message.find(dead_letter.retried_message_id))
54+
end
55+
56+
message_reference = Mailbox.new.enqueue(
57+
reference: Reference.new(
58+
actor_type: dead_letter.actor_type,
59+
actor_id: dead_letter.actor_id
60+
),
61+
operation: dead_letter.operation,
62+
arguments: dead_letter.arguments,
63+
delivery_mode: dead_letter.message.delivery_mode,
64+
idempotency_key: "dead-letter:#{dead_letter.id}"
65+
)
66+
dead_letter.update!(retried_message_id: message_reference.id)
67+
message_reference
68+
end
69+
6070
# @rbs (Symbol, authorization_context: untyped, ?dead_letter_id: Integer?) -> void
6171
def authorize!(action, authorization_context:, dead_letter_id: nil)
6272
authorized = SolidObjects.configuration.authorize_administration.call(

‎lib/solid_objects/dead_letter_scope.rb‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,18 @@ def all(authorization_context: nil)
3636
# @rbs (String, ?authorization_context: untyped) -> untyped
3737
def retry(identifier_value, authorization_context: nil)
3838
authorize!(:retry, authorization_context:, resource_id: identifier_value)
39-
row = model.find_by!(identifier => identifier_value)
40-
AdministrationAudit.record(
41-
action: "dead_letter.retry",
42-
kind: kind,
43-
subject_id: identifier_value,
44-
actor: AdministrationAudit.identity(authorization_context)
45-
)
46-
return row unless row.status == DEAD
47-
48-
revive(row)
49-
row
39+
actor = AdministrationAudit.identity(authorization_context)
40+
SolidObjects.database_adapter.transaction do
41+
row = model.find_by!(identifier => identifier_value)
42+
revive(row) if row.status == DEAD
43+
AdministrationAudit.record(
44+
action: "dead_letter.retry",
45+
kind: kind,
46+
subject_id: identifier_value,
47+
actor:
48+
)
49+
row
50+
end
5051
end
5152

5253
# @rbs (?actor_type: String?, ?failed_after: untyped, ?limit: Integer?, ?authorization_context: untyped) -> RedriveTask

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ module SolidObjects
1616

1717
private
1818

19+
# @rbs (DeadLetter) -> MessageReference
20+
def retried_reference: (DeadLetter) -> MessageReference
21+
1922
# @rbs (Symbol, authorization_context: untyped, ?dead_letter_id: Integer?) -> void
2023
def authorize!: (Symbol, authorization_context: untyped, ?dead_letter_id: Integer?) -> void
2124
end

‎test/integration/administration_audit_test.rb‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,32 @@ def run
100100
assert_equal "user:42", SolidObjects::AdministrationEvent.sole.actor
101101
end
102102

103+
test "writes no audit row when the retry itself fails" do
104+
effect = dead_effect
105+
106+
with_failing_method(SolidObjects::DeadLetterScope, :revive) do
107+
assert_raises(RuntimeError) do
108+
SolidObjects.dead_letters.effects.retry(effect.effect_id, authorization_context: "operator")
109+
end
110+
end
111+
112+
assert_equal 0, SolidObjects::AdministrationEvent.count
113+
end
114+
115+
test "writes no audit row when a message retry fails to enqueue" do
116+
PoisonActor.ref("one").async.run
117+
run_actors
118+
dead_letter = SolidObjects::DeadLetter.sole
119+
120+
with_failing_method(SolidObjects::Mailbox, :enqueue) do
121+
assert_raises(RuntimeError) do
122+
SolidObjects.dead_letters.retry(dead_letter.id, authorization_context: "operator")
123+
end
124+
end
125+
126+
assert_equal 0, SolidObjects::AdministrationEvent.count
127+
end
128+
103129
test "writes no audit row when the caller is refused" do
104130
effect = dead_effect
105131
SolidObjects.configuration.authorize_administration = ->(**) { false }
@@ -121,6 +147,14 @@ def run
121147

122148
private
123149

150+
def with_failing_method(target, name)
151+
original = target.instance_method(name)
152+
target.define_method(name) { |*, **| raise "injected failure" }
153+
yield
154+
ensure
155+
target.define_method(name, original)
156+
end
157+
124158
def dead_effect
125159
LedgerActor.ref("one").async.post
126160
run_actors

0 commit comments

Comments
 (0)