Skip to content

Commit d208789

Browse files
authored
Merge pull request #78 from cardmagic/feat/dead-letter-redrive
feat: retry and redrive dead effects and broadcasts
2 parents 588281f + 92bc474 commit d208789

34 files changed

Lines changed: 1709 additions & 22 deletions

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,29 @@
22

33
## Unreleased
44

5+
- Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its
6+
message meaning and answers `effects` and `broadcasts`, so the kind rides on
7+
the receiver. `retry` returns a dead row to pending with a zero attempt count
8+
and no claim, reuses the stable id so a deduplicating handler sees the same
9+
key, and acts only on a dead row, so a second press cannot double-enqueue. A
10+
dead transmit effect replays rather than stay lost.
11+
- Redrive a whole scope. `redrive` opens a durable task, returns at once, and is
12+
idempotent over its scope and filters, which a dashboard button needs. A
13+
unique index on the active scope enforces that in the database, so two
14+
processes that start the same redrive share one task. The supervisor advances
15+
one bounded batch per pass, so a redrive never holds a transaction longer than
16+
one batch. `SolidObjects.redrives` reads tasks back, and `task.cancel` stops
17+
one and leaves the rows it already moved. A redrive moves what was dead when
18+
it started, so a still-broken handler cannot make it run forever.
19+
- Record who pressed what. Every retry and every redrive transition writes one
20+
row to `solid_objects_administration_events`. The identity comes from the
21+
authorization context through a new `administration_identity` hook.
22+
- Add `redrive_batch_size`, which defaults to 100, and `redrive_batch_pause`,
23+
which defaults to 0.05 seconds.
24+
- Add two tables, `solid_objects_administration_events` and
25+
`solid_objects_redrives`. Run `bin/rails solid_objects:install:migrations` and
26+
migrate.
27+
528
- Select a wake-up adapter automatically. `config.wake_up_adapter` now takes a
629
name or an adapter, as `config.cache_store` and
730
`config.active_job.queue_adapter` do, and defaults to `:automatic`. Selection
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# rbs_inline: enabled
2+
3+
module SolidObjects
4+
class AdministrationEvent < Record
5+
self.table_name = SolidObjects.table_name(:administration_events)
6+
end
7+
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# rbs_inline: enabled
2+
3+
module SolidObjects
4+
class Redrive < Record
5+
self.table_name = SolidObjects.table_name(:redrives)
6+
end
7+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# rbs_inline: enabled
2+
3+
class AddSolidObjectsAdministrationEvents < ActiveRecord::Migration[7.1]
4+
# @rbs () -> void
5+
def change
6+
create_table SolidObjects.table_name(:administration_events) do |definition|
7+
definition.string :action, null: false, limit: 64
8+
definition.string :kind, null: false, limit: 32
9+
definition.string :subject_id, limit: 191
10+
definition.public_send(json_type, :filters)
11+
definition.string :actor, limit: 255
12+
definition.datetime :occurred_at, null: false, precision: 6
13+
definition.timestamps precision: 6, null: false
14+
15+
definition.index [ :occurred_at, :id ], name: "idx_so_admin_events_occurred"
16+
definition.index [ :kind, :subject_id ], name: "idx_so_admin_events_subject"
17+
end
18+
end
19+
20+
private
21+
22+
# @rbs () -> Symbol
23+
def json_type
24+
connection.adapter_name.match?(/postgres/i) ? :jsonb : :json
25+
end
26+
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# rbs_inline: enabled
2+
3+
class AddSolidObjectsRedrives < ActiveRecord::Migration[7.1]
4+
# @rbs () -> void
5+
def change
6+
create_table SolidObjects.table_name(:redrives), id: :string, limit: 64 do |definition|
7+
definition.string :kind, null: false, limit: 32
8+
definition.public_send(json_type, :filters, null: false)
9+
definition.string :status, null: false, default: "running", limit: 32
10+
definition.string :active_scope, limit: 191
11+
definition.integer :moved, null: false, default: 0
12+
definition.integer :move_limit
13+
definition.string :actor, limit: 255
14+
definition.datetime :started_at, null: false, precision: 6
15+
definition.datetime :finished_at, precision: 6
16+
definition.timestamps precision: 6, null: false
17+
18+
definition.index :active_scope, unique: true, name: "idx_so_redrives_active_scope"
19+
definition.index [ :status, :started_at, :id ], name: "idx_so_redrives_poll"
20+
definition.check_constraint "moved >= 0", name: "chk_so_redrives_moved"
21+
definition.check_constraint "move_limit IS NULL OR move_limit > 0", name: "chk_so_redrives_limit"
22+
definition.check_constraint "status IN ('running', 'completed', 'cancelled')", name: "chk_so_redrives_status"
23+
end
24+
end
25+
26+
private
27+
28+
# @rbs () -> Symbol
29+
def json_type
30+
connection.adapter_name.match?(/postgres/i) ? :jsonb : :json
31+
end
32+
end

‎docs/dashboard.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,16 @@ actor class that no longer exists, a full mailbox, a payload over the cap. The
149149
dashboard renders the dead letter again with the reason and a 422 status,
150150
rather than failing the request.
151151

152+
Dead effects and broadcasts have the same API, which the dashboard does not yet
153+
surface. `SolidObjects.dead_letters.effects` and
154+
`SolidObjects.dead_letters.broadcasts` read and retry their own kind, and
155+
`redrive` moves a whole scope as a durable task. See
156+
[Operations](operations.md) for both.
157+
158+
Every retry and every redrive transition writes one row to
159+
`solid_objects_administration_events`, holding the action, the kind, the
160+
subject, and the identity that asked for it.
161+
152162
**Pause an instance** sets `paused_at`, and the activation manager stops
153163
claiming that identity. Two consequences matter:
154164

‎docs/operations.md‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,95 @@ interval, and current interval. The polling-only warning is also emitted as
282282
adapters should return `true` for a notification and `false` for a timeout; an
283283
older adapter that returns `nil` remains compatible and keeps the fast cadence.
284284

285+
## Dead letters, retry, and redrive
286+
287+
A message that exhausts its attempts becomes a dead letter. An effect or a
288+
broadcast that exhausts its attempts stays in its own table with
289+
`status = 'dead'`. All three are read and retried through one receiver, which
290+
carries the kind:
291+
292+
```ruby
293+
SolidObjects.dead_letters.all(authorization_context: current_admin)
294+
SolidObjects.dead_letters.retry(dead_letter_id, authorization_context: current_admin)
295+
296+
SolidObjects.dead_letters.effects.all(authorization_context: current_admin)
297+
SolidObjects.dead_letters.effects.retry(effect_id, authorization_context: current_admin)
298+
SolidObjects.dead_letters.broadcasts.retry(broadcast_id, authorization_context: current_admin)
299+
```
300+
301+
An effect or broadcast retry returns the row to pending with a zero attempt
302+
count, no claim, and immediate availability. It keeps the stable id, so a
303+
handler that deduplicates on `effect_id` still sees the same key. An effect is
304+
at-least-once by contract, so a retried effect can run twice.
305+
306+
Retry acts only on a dead row. A row that is pending, processing, or completed
307+
comes back unchanged, so pressing a button twice cannot double-enqueue and
308+
cannot take a row away from a worker that holds it.
309+
310+
An incident produces dead rows in the hundreds, so a scope also answers
311+
`redrive`:
312+
313+
```ruby
314+
task = SolidObjects.dead_letters.effects.redrive(
315+
actor_type: "payments",
316+
failed_after: 6.hours.ago,
317+
limit: 5_000,
318+
authorization_context: current_admin
319+
)
320+
321+
task.id # => "redrive_..."
322+
task.status # => "running"
323+
task.moved # => 412
324+
task.remaining # => 4_588
325+
326+
task.cancel(authorization_context: current_admin)
327+
```
328+
329+
`redrive` returns at once. The task is durable, and the supervisor advances one
330+
bounded batch per pass, so a redrive of thousands of rows never holds a
331+
transaction longer than one batch. `redrive_batch_size` defaults to 100 and
332+
`redrive_batch_pause` to 0.05 seconds.
333+
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+
338+
A redrive is idempotent over its scope and its filters. Starting the same one
339+
while it runs returns the running task rather than a second one, which a
340+
dashboard button an operator can press twice needs. A different scope or a
341+
different filter starts its own task, and the same scope can be redriven again
342+
once the first task finishes.
343+
344+
Read tasks back with `SolidObjects.redrives`:
345+
346+
```ruby
347+
SolidObjects.redrives.find(task.id, authorization_context: current_admin)
348+
SolidObjects.redrives.all(status: :running, authorization_context: current_admin)
349+
```
350+
351+
A running task reports what is left to move rather than a stored estimate,
352+
because rows die and are retried while it runs.
353+
354+
Retry, redrive, and cancel each go through `authorize_administration` under
355+
their own resource name: `dead_letters`, `effect_dead_letters`,
356+
`broadcast_dead_letters`, and `redrives`. Every retry and every task transition
357+
writes one row to `solid_objects_administration_events`, holding the action, the
358+
kind, the subject, the identity, and when it happened. The identity comes from
359+
`administration_identity`, which receives the authorization context the caller
360+
passed and defaults to its `to_s`.
361+
362+
An event records an authorized press, not a state transition. Pressing retry
363+
twice writes two rows, because an operator did two things and a log that shows
364+
one cannot answer who pressed what. The row the event names carries the outcome.
365+
A refused caller writes nothing, and a retry that raises after the lookup writes
366+
nothing, because the event shares the transaction with the work. The redrive
367+
transitions are different: `redrive.start`, `redrive.finish`, and
368+
`redrive.cancel` are written only when the task actually changes.
369+
370+
Automatic redrive on a schedule is deliberately absent. A dead row means a
371+
person decided something, and these APIs give that person an alternative to an
372+
`UPDATE` against a runtime table.
373+
285374
## Graceful shutdown
286375

287376
The supervisor requests shutdown, stops new claims, lets active loops return,

‎docs/roadmap.md‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -178,15 +178,18 @@
178178
or turn off. Every route declares its
179179
own administration policy and a route declared without one raises at load
180180
time, so the deny-by-default posture is enforced by construction rather than
181-
by remembering to add a check. It changes only two things: an idempotent dead
182-
letter retry and instance pause/resume. What does not exist is audit records
183-
of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
184-
because `DeadLetterManager` exposes no bulk operation. Pause is an operator
185-
brake and not a stop, since a pass already in flight finishes its turn and a
186-
synchronous caller waiting on a paused instance times out. Retry also only
187-
exists for message dead letters: a dead effect or broadcast has no retry
188-
API, which matters for transmit effects because a dead one is a lost
189-
replay until an operator returns its row to pending. The page cost was
181+
by remembering to add a check. It changes only three things: an idempotent dead
182+
letter retry, a redrive, and instance pause/resume. Retry covers all three
183+
kinds. `SolidObjects.dead_letters` keeps its message meaning and answers
184+
`effects` and `broadcasts`, so a dead effect or broadcast returns to pending
185+
through an API rather than an operator's `UPDATE`, and a dead transmit effect
186+
is no longer a lost replay. `redrive` moves a whole scope as a durable task
187+
that is idempotent over its filters, cancellable, and advanced in bounded
188+
batches by the supervisor. Every retry and task transition writes one row to
189+
`solid_objects_administration_events`, so who pressed what is recorded. Pause
190+
is an operator brake and not a stop, since a pass already in flight finishes
191+
its turn and a synchronous caller waiting on a paused instance times out. The
192+
dashboard does not yet surface the scopes or redrive; the API does. The page cost was
190193
reasoned about rather than measured: the summary bar issues a fixed set of
191194
indexed aggregate queries per page, which is why `HEAD /` exists for uptime
192195
monitors, but no dashboard latency has been benchmarked against a large

‎lib/solid_objects.rb‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
require "solid_objects/actor"
2828
require "solid_objects/reference"
2929
require "solid_objects/message_reference"
30+
require "solid_objects/administration_audit"
31+
require "solid_objects/redrive_task"
32+
require "solid_objects/redrive_manager"
33+
require "solid_objects/redrive_runner"
34+
require "solid_objects/dead_letter_scope"
3035
require "solid_objects/dead_letter_manager"
3136
require "solid_objects/message_pruner"
3237
require "solid_objects/instance_pruner"
@@ -160,6 +165,11 @@ def dead_letters
160165
@dead_letters ||= DeadLetterManager.new
161166
end
162167

168+
# @rbs () -> RedriveManager
169+
def redrives
170+
@redrives ||= RedriveManager.new
171+
end
172+
163173
# @rbs () -> Administration
164174
def administration
165175
@administration ||= Administration.new
@@ -192,6 +202,7 @@ def reset!
192202
@effect_registry = EffectRegistry.new
193203
@commit_action_registry = CommitActionRegistry.new
194204
@dead_letters = nil
205+
@redrives = nil
195206
@administration = nil
196207
end
197208

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# rbs_inline: enabled
2+
3+
module SolidObjects
4+
module AdministrationAudit
5+
module_function
6+
7+
# @rbs (action: String, kind: String, ?subject_id: untyped, ?filters: Hash[Symbol | String, untyped]?, ?actor: String?) -> void
8+
def record(action:, kind:, subject_id: nil, filters: nil, actor: nil)
9+
AdministrationEvent.create!(
10+
action:,
11+
kind:,
12+
subject_id: subject_id&.to_s,
13+
filters:,
14+
actor:,
15+
occurred_at: SolidObjects.database_adapter.database_now
16+
)
17+
nil
18+
end
19+
20+
# @rbs (untyped) -> String?
21+
def identity(authorization_context)
22+
return nil if authorization_context.nil?
23+
24+
SolidObjects.configuration.administration_identity
25+
.call(authorization_context)
26+
&.to_s
27+
&.byteslice(0, 255)
28+
end
29+
end
30+
end

0 commit comments

Comments
 (0)