Skip to content

Commit 627555e

Browse files
cardmagicclaude
andcommitted
fix: list a dead row by the id retry accepts
Two host applications in /tmp/testruby and /tmp/testnode exercised every unreleased feature as a consumer, and Ruby failed a call TypeScript answers. `dead_letters.effects.all` returned Active Record rows, so `row.id` was the primary key while `retry` reads `effect_id`: row.id # => 3 row.effect_id # => "b6d51a55-…" retry(row.id) # => ActiveRecord::RecordNotFound retry(row.effect_id) # => ok TypeScript returns `DeadRow` values whose `id` is what `retry` takes, so `retry(row.id)` has always worked there and the obvious Ruby call raised. `SolidObjects::DeadRow` now carries the identifier, the kind, the actor, the status, the attempt count, and the error, and `dead` still answers the relation for a caller that wants to scope it further. Wake-up selection rescued every exception, so a `NameError` from an unloaded model read as "the database could not be reached to select an adapter" and silently downgraded the process to in-process signalling. It now rescues database, system call, and IO errors only, and the new test fails without that with `NameError expected but nothing was raised`. `json` 3.0.2 breaks `ActiveSupport::JSON.decode`, and with it every JSON column this gem writes. The defect is in Active Support and a new Rails 8.1 application resolves that version today, so operations.md names the combination and the pin. Validation: bundle exec rake (789 runs, 0 failures), plus a Rails 8.1 host application where all 24 consumer checks pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 028f4bc commit 627555e

9 files changed

Lines changed: 128 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@
4949
healthy and found out from a worker crash. A test fails when the list does not
5050
name a column that a migration after the first adds.
5151

52+
- List a dead effect or broadcast as a `SolidObjects::DeadRow` rather than as
53+
an Active Record row. `all` returned rows whose `id` was the primary key while
54+
`retry` reads `effect_id` or `broadcast_id`, so the obvious
55+
`scope.retry(scope.all.first.id)` raised `ActiveRecord::RecordNotFound`.
56+
`DeadRow#id` is now the value `retry` accepts, which is what the TypeScript
57+
runtime has always returned. `dead` still answers the relation for a caller
58+
that wants to scope it further.
59+
- Raise a load error rather than report an unreachable database. Wake-up
60+
selection rescued every exception, so a `NameError` from an unloaded model
61+
read as "the database could not be reached" and downgraded the process to
62+
in-process signalling. It now rescues database, system call, and IO errors
63+
only.
64+
- Note that `json` 3.0.2 breaks `ActiveSupport::JSON.decode`, and therefore
65+
every JSON column, in [docs/operations.md](docs/operations.md).
5266
- Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its
5367
message meaning and answers `effects` and `broadcasts`, so the kind rides on
5468
the receiver. `retry` returns a dead row to pending with a zero attempt count

‎docs/operations.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ reports a failed or warned check rather than raising out of the command.
2525

2626
## Installing and upgrading
2727

28+
Solid Objects keeps actor state, message arguments, results, and the remembered
29+
idempotency keys in JSON columns. Active Support decodes every one of them, and
30+
`ActiveSupport::JSON.decode` raises with the `json` gem at 3.0.2:
31+
32+
```
33+
ArgumentError: wrong number of arguments (given 2, expected 1)
34+
```
35+
36+
The failure is in Active Support rather than in Solid Objects, and it reaches
37+
every JSON column in a Rails application. A new Rails 8.1 application resolves
38+
`json` 3.0.2 today, so pin the 2.x series until Rails ships a fix:
39+
40+
```ruby
41+
gem "json", "~> 2"
42+
```
43+
2844
Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
2945
notes, then update the gem:
3046

‎lib/solid_objects.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
require "solid_objects/redrive_task"
3333
require "solid_objects/redrive_manager"
3434
require "solid_objects/redrive_runner"
35+
require "solid_objects/dead_row"
3536
require "solid_objects/dead_letter_scope"
3637
require "solid_objects/dead_letter_manager"
3738
require "solid_objects/message_pruner"
@@ -229,7 +230,7 @@ def resolve_wake_up
229230
WakeUpAdapters.build(configuration.wake_up_adapter)
230231
rescue ArgumentError
231232
raise
232-
rescue => error
233+
rescue ActiveRecord::ActiveRecordError, SystemCallError, IOError => error
233234
unreachable_wake_up(error)
234235
end
235236

‎lib/solid_objects/dead_letter_scope.rb‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,25 @@ def self.for_kind(kind)
2727
raise ArgumentError, "unknown dead letter kind #{kind.inspect}"
2828
end
2929

30-
# @rbs (?authorization_context: untyped) -> ActiveRecord::Relation[untyped]
30+
# @rbs (?authorization_context: untyped) -> Array[DeadRow]
3131
def all(authorization_context: nil)
3232
authorize!(:inspect, authorization_context:)
33-
dead.order(updated_at: :desc, id: :desc)
33+
dead.includes(:instance).order(updated_at: :desc, id: :desc).map { |row| dead_row(row) }
34+
end
35+
36+
# @rbs (untyped) -> DeadRow
37+
def dead_row(row)
38+
DeadRow.new(
39+
id: row.public_send(identifier),
40+
kind:,
41+
actor_type: row.instance.actor_type,
42+
actor_id: row.instance.actor_id,
43+
status: row.status,
44+
attempt_count: row.attempt_count,
45+
available_at: row.available_at,
46+
failed_at: row.updated_at,
47+
error: row.error
48+
)
3449
end
3550

3651
# @rbs (String, ?authorization_context: untyped) -> untyped

‎lib/solid_objects/dead_row.rb‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# rbs_inline: enabled
2+
3+
module SolidObjects
4+
DeadRow = Data.define(
5+
:id,
6+
:kind,
7+
:actor_type,
8+
:actor_id,
9+
:status,
10+
:attempt_count,
11+
:available_at,
12+
:failed_at,
13+
:error
14+
)
15+
end

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ module SolidObjects
2222
# @rbs (String) -> DeadLetterScope
2323
def self.for_kind: (String) -> DeadLetterScope
2424

25-
# @rbs (?authorization_context: untyped) -> ActiveRecord::Relation[untyped]
26-
def all: (?authorization_context: untyped) -> ActiveRecord::Relation[untyped]
25+
# @rbs (?authorization_context: untyped) -> Array[DeadRow]
26+
def all: (?authorization_context: untyped) -> Array[DeadRow]
27+
28+
# @rbs (untyped) -> DeadRow
29+
def dead_row: (untyped) -> DeadRow
2730

2831
# @rbs (String, ?authorization_context: untyped) -> untyped
2932
def retry: (String, ?authorization_context: untyped) -> untyped
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated from lib/solid_objects/dead_row.rb with RBS::Inline
2+
3+
module SolidObjects
4+
class DeadRow < Data
5+
attr_reader id(): untyped
6+
7+
attr_reader kind(): untyped
8+
9+
attr_reader actor_type(): untyped
10+
11+
attr_reader actor_id(): untyped
12+
13+
attr_reader status(): untyped
14+
15+
attr_reader attempt_count(): untyped
16+
17+
attr_reader available_at(): untyped
18+
19+
attr_reader failed_at(): untyped
20+
21+
attr_reader error(): untyped
22+
23+
def self.new: (untyped id, untyped kind, untyped actor_type, untyped actor_id, untyped status, untyped attempt_count, untyped available_at, untyped failed_at, untyped error) -> instance
24+
| (id: untyped, kind: untyped, actor_type: untyped, actor_id: untyped, status: untyped, attempt_count: untyped, available_at: untyped, failed_at: untyped, error: untyped) -> instance
25+
26+
def self.members: () -> [ :id, :kind, :actor_type, :actor_id, :status, :attempt_count, :available_at, :failed_at, :error ]
27+
28+
def members: () -> [ :id, :kind, :actor_type, :actor_id, :status, :attempt_count, :available_at, :failed_at, :error ]
29+
end
30+
end

‎test/integration/dead_letter_scopes_test.rb‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,26 @@ def run
144144
broadcasts = SolidObjects.dead_letters.broadcasts.all(authorization_context: "operator")
145145
messages = SolidObjects.dead_letters.all(authorization_context: "operator")
146146

147-
assert_equal [ effect.effect_id ], effects.map(&:effect_id)
148-
assert_equal [ broadcast.broadcast_id ], broadcasts.map(&:broadcast_id)
147+
assert_equal [ effect.effect_id ], effects.map(&:id)
148+
assert_equal [ broadcast.broadcast_id ], broadcasts.map(&:id)
149149
assert_equal 1, messages.count
150150
end
151151

152+
test "lists a dead row whose id retry accepts" do
153+
effect = dead_effect
154+
155+
row = SolidObjects.dead_letters.effects.all(authorization_context: "operator").sole
156+
157+
assert_equal effect.effect_id, row.id
158+
assert_equal "effect", row.kind
159+
assert_equal "dead", row.status
160+
assert_equal "scoped-dead-letter-orders", row.actor_type
161+
assert_equal "one", row.actor_id
162+
SolidObjects.dead_letters.effects.retry(row.id, authorization_context: "operator")
163+
164+
assert_equal "pending", effect.reload.status
165+
end
166+
152167
test "reads only dead rows, not pending ones" do
153168
dead = dead_effect
154169
OrderActor.ref("two").async.place
@@ -157,7 +172,7 @@ def run
157172
effects = SolidObjects.dead_letters.effects.all(authorization_context: "operator")
158173

159174
assert_equal 2, SolidObjects::Effect.count
160-
assert_equal [ dead.effect_id ], effects.map(&:effect_id)
175+
assert_equal [ dead.effect_id ], effects.map(&:id)
161176
end
162177

163178
test "refuses an unauthorized caller" do

‎test/integration/wake_up_selection_test.rb‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,17 @@ def wait(timeout:) = false
260260
end
261261
end
262262

263+
test "a load error is not reported as an unreachable database" do
264+
SolidObjects::WakeUpAdapters.singleton_class.alias_method(:built, :build)
265+
SolidObjects::WakeUpAdapters.define_singleton_method(:build) do |_name|
266+
raise NameError, "uninitialized constant SolidObjects::Record"
267+
end
268+
269+
assert_raises(NameError) { SolidObjects.wake_up }
270+
ensure
271+
SolidObjects::WakeUpAdapters.singleton_class.alias_method(:build, :built)
272+
end
273+
263274
test "the doctor reports the selected adapter" do
264275
SolidObjects.configuration.authorize_administration = ->(**) { true }
265276
check = SolidObjects::Doctor.new.call.check(:wake_up)

0 commit comments

Comments
 (0)