ductwork 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-clock-drift/SKILL.md +167 -5
  3. data/.claude/skills/audit-common/accepted-tradeoffs.md +118 -0
  4. data/.claude/skills/audit-common/method.md +86 -0
  5. data/.claude/skills/audit-common/scope-boundaries.md +101 -0
  6. data/.claude/skills/audit-common/severity.md +77 -0
  7. data/.claude/skills/audit-database-indexes/SKILL.md +182 -5
  8. data/.claude/skills/audit-database-support/SKILL.md +209 -11
  9. data/.claude/skills/audit-durability/SKILL.md +227 -10
  10. data/CHANGELOG-PRO.md +17 -1
  11. data/CHANGELOG.md +26 -0
  12. data/CLAUDE.md +1 -1
  13. data/lib/ductwork/abandoned_claim.rb +8 -0
  14. data/lib/ductwork/branch_claim.rb +92 -36
  15. data/lib/ductwork/claimed_state.rb +57 -0
  16. data/lib/ductwork/crash.rb +5 -0
  17. data/lib/ductwork/engine.rb +0 -1
  18. data/lib/ductwork/models/advancement.rb +11 -5
  19. data/lib/ductwork/models/branch.rb +91 -18
  20. data/lib/ductwork/models/execution.rb +41 -14
  21. data/lib/ductwork/models/pipeline.rb +2 -2
  22. data/lib/ductwork/models/process.rb +7 -3
  23. data/lib/ductwork/models/run.rb +2 -1
  24. data/lib/ductwork/models/step.rb +12 -7
  25. data/lib/ductwork/optimistic_locking_execution_claim.rb +6 -16
  26. data/lib/ductwork/orphaned_claim.rb +5 -0
  27. data/lib/ductwork/polling_interval.rb +30 -0
  28. data/lib/ductwork/process_crash.rb +5 -0
  29. data/lib/ductwork/processes/job_worker.rb +25 -6
  30. data/lib/ductwork/processes/pipeline_advancer.rb +16 -6
  31. data/lib/ductwork/processes/process_supervisor.rb +21 -0
  32. data/lib/ductwork/processes/worker_health_check.rb +38 -10
  33. data/lib/ductwork/row_locking_execution_claim.rb +4 -17
  34. data/lib/ductwork/thread_crash.rb +5 -0
  35. data/lib/ductwork/version.rb +1 -1
  36. data/lib/ductwork.rb +6 -2
  37. metadata +12 -1
data/CHANGELOG-PRO.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Ductwork Pro Changelog
2
2
 
3
- ## [1.0.0] (Unreleased)
3
+ ## [1.1.1]
4
+
5
+ - fix: use `Ductwork::DatabaseClock` instead of `Time.current` for every `started_at` Pro stamps, mirroring the OSS fix
6
+ - fix: evaluate the step-timeout deadline in the database (`DatabaseClock.ago_sql`) instead of subtracting `attempt.started_at` from the worker's `Time.current`, so an NTP correction mid-job cannot stretch or collapse a step's timeout
7
+ - fix: temporarily handle multiple return types from `Branch.with_latest_claimed` - this is a result of changes in OSS from v1.1.0 and v1.1.1
8
+ - fix: log the claimed job/branch of a dead thread before the restart clears it
9
+ - fix: add rubocop cop for ensuring `updated_at` is updated (copied from OSS)
10
+ - fix: explicitly set `updated_at` for queries that update the record directly
11
+ - fix: mirror OSS `halt_branch_and_resolve_run_without_rescue!` implementation
12
+ - fix: walk the `collapse` fan-in with an explicit keyset scan so each batch is one indexed range seek, instead of a per-batch subquery plus a second `in_batches` query that re-ran the whole scope
13
+ - fix: don't use `unique_by` argument for `insert_all` for mysql and trilogy database adapters
14
+
15
+ ## [1.1.0]
16
+
17
+ - chore: update CLI banner to match OSS
18
+
19
+ ## [1.0.0]
4
20
 
5
21
  - chore: add multiple database adapters to CI test matrix
6
22
  - fix: inspect `in_batches` parameters for passing `cursor` option
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Ductwork Changelog
2
2
 
3
+ ## [1.1.2]
4
+
5
+ - fix: lazily create configuration
6
+ - fix: prevent step's idempotency key from returning `nil`
7
+
8
+ ## [1.1.1]
9
+
10
+ - fix: use database clock instead of `Time.current` to avoid clock skew in `Branch#latest_step` paths
11
+ - fix: record error metadata on `process_crashed` results — `Execution#crashed!` now takes the exception that killed the work loop (writing its class, message, and backtrace, as `errored!` already did) or one of the new `Ductwork::Crash` markers (`ProcessCrash`, `ThreadCrash`, `AbandonedClaim`, `OrphanedClaim`) naming which recovery path noticed the lost claim; previously all five call sites collapsed into a single unlabeled result row and the work loop logged the error class and message before discarding them, leaving a crash loop undiagnosable from the database; recorded backtraces are now capped at the 10 frames nearest the raise, since `text` tops out at 64KB on MySQL and an insert failure inside `crashed!` would leak the claim
12
+ - fix: use `Ductwork::DatabaseClock` instead of `Time.current` in claiming queries
13
+ - fix: set `updated_at` timestamp when touching record through `update_all` or `update_columns`
14
+ - fix: reap the forking supervisor's own process record on shutdown, as the threaded supervisor already did, so a clean stop no longer leaves a record behind for a peer to sweep up `supervisor.reaper_timeout` later
15
+ - fix: skip the reaping process's own record in `Process.reap_all!` so a supervisor whose work loop stalled past `supervisor.reaper_timeout` cannot declare its own in-flight work crashed — in threaded mode the advancer and worker threads share the supervisor's process record, so self-reaping released branches and re-enqueued jobs that live threads were still running; a peer supervisor still reaps the record when the process genuinely dies
16
+ - fix: report a heartbeat from the forking supervisor's own work loop — its process record went stale after `supervisor.reaper_timeout` and its own reaper sweep destroyed it, so a healthy supervisor disappeared from the dashboard and the health check reported it `dead` five minutes into every boot, failing any container liveness probe wired to it
17
+ - fix: read the run and pipeline status before writing when marking a claimed job `in_progress`, so a no-op conditional `UPDATE` no longer takes an exclusive row lock held for the rest of the claim transaction — InnoDB at REPEATABLE READ locks the row during the primary key lookup even when the qualification fails, so on MySQL and Trilogy every worker claiming a step of the same run serialized on the two rows the whole run shares, capping per-run claim throughput regardless of worker count
18
+ - fix: jitter the idle polling sleep of advancer and worker threads by ±25% of the configured `polling_timeout` so threads spawned together stop waking in lockstep and piling onto the same claim candidates — the configured timeout remains the average poll latency
19
+ - fix: distinguish a contended claim (candidates existed but every sampled one lost its race) from an idle queue so an advancer that loses a race re-enters the work loop immediately instead of sleeping a full polling interval — `Branch.with_latest_claimed` now returns `:claimed`/`:contended`/`:idle` rather than a boolean
20
+ - fix: sample the branch to claim from a window of the oldest candidates (sized off `pipeline_advancer.count`) and walk to the next sampled ID when a claim loses its race, instead of every advancer thread in every process contending for the single oldest row — raising the advancer count or adding advancer processes now increases advancement throughput instead of degrading it
21
+ - fix: re-assert the full candidate predicate (branch status and an `advancing`/`failed` step, not just a null claim) in the claim `UPDATE` query so an advancer whose candidate went stale can no longer claim a branch that another advancer already advanced and released — which advanced the branch off a freshly enqueued step whose job had not run, routing on a nil return value
22
+ - fix: release the claim instead of routing or halting when `Branch#advance!` finds no step to advance
23
+ - fix: confirm a stuck thread actually died before restarting it, so a `Thread#kill` that cannot interrupt a blocking C-level database call no longer leaves two threads sharing one worker's claim state
24
+ - fix: give each worker/advancer thread a fresh running context on `start` so a thread restarted after a kill is not immediately shut down by the context the kill tore down
25
+ - fix: reset the heartbeat timestamp on `start` so a restarted thread is not judged stuck on the stale timestamp that got it killed
26
+ - fix: log the claimed job/branch of a dead thread before the restart clears it
27
+ - fix: let the nested halt path (`divert` no-match, `divide`/`expand` fan-out cap) propagate errors to the advancement rescue instead of committing a step as `completed` alongside an unclaimable branch
28
+
3
29
  ## [1.1.0]
4
30
 
5
31
  - chore: add color to CLI banner
data/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Ductwork OSS Architecture Context
1
+ # Ductwork OSS Context
2
2
 
3
3
  This is the OSS `ductwork` gem (LGPL v3). The paid `ductwork-pro` gem
4
4
  extends it via `prepend` and adds features that MUST NOT be reimplemented
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ # A work loop exited an iteration with an uncommitted claim without raising
5
+ # a StandardError -- a non-StandardError exception (a signal, Thread#kill),
6
+ # a non-local exit, or a rescue path that itself failed to commit.
7
+ class AbandonedClaim < Ductwork::Crash; end
8
+ end
@@ -1,12 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- class BranchClaim
4
+ class BranchClaim # rubocop:disable Metrics/ClassLength
5
5
  attr_reader :transition, :advancement, :token
6
6
 
7
+ MAX_CLAIM_ATTEMPTS = 3
8
+
7
9
  def initialize(pipeline_klass)
8
10
  @pipeline_klass = pipeline_klass
9
11
  @claimed_for_advancing_at = nil
12
+ @contended = false
13
+ end
14
+
15
+ def contended?
16
+ @contended
10
17
  end
11
18
 
12
19
  def latest
@@ -22,16 +29,20 @@ module Ductwork
22
29
 
23
30
  return log_no_process if process.nil?
24
31
 
25
- id = find_candidate_branch_id
32
+ ids = find_candidate_branch_ids
26
33
 
27
- return log_no_branches if id.blank?
34
+ return log_no_branches if ids.blank?
28
35
 
29
- rows_updated = claim_and_setup_records(id, process)
36
+ Ductwork::FaultInjection.checkpoint(:after_branch_candidate_select)
30
37
 
31
- if rows_updated == 1
32
- Ductwork::Branch.find(id)
38
+ claimed_id = claim_and_setup_records(ids, process)
39
+
40
+ if claimed_id
41
+ Ductwork::Branch.find(claimed_id)
33
42
  else
34
- log_race_condition(id)
43
+ @contended = true
44
+
45
+ log_lost_claim_races
35
46
  end
36
47
  rescue ActiveRecord::InvalidForeignKey => e
37
48
  # NOTE: our own `process` record was reaped (heartbeat-stale, destroyed)
@@ -41,49 +52,95 @@ module Ductwork
41
52
  # the whole claim transaction (branch + transition + advancement) --
42
53
  # nothing is left half-committed. Treat it like any other lost claim
43
54
  # race instead of letting it propagate and kill the advancer thread.
44
- log_process_reaped_mid_claim(id, e)
55
+ log_process_reaped_mid_claim(e)
45
56
  end
46
57
 
47
58
  private
48
59
 
49
- attr_reader :pipeline_klass, :claimed_for_advancing_at
60
+ attr_reader :pipeline_klass, :claimed_for_advancing_at, :attempted_branch_id
61
+
62
+ def candidate_window_size
63
+ @candidate_window_size ||= begin
64
+ pipeline_advancer_count = Ductwork
65
+ .configuration
66
+ .pipeline_advancer_count(pipeline_klass)
50
67
 
51
- def find_candidate_branch_id
68
+ (pipeline_advancer_count * 4).clamp(20, 100)
69
+ end
70
+ end
71
+
72
+ # NOTE: the single source of truth for "this branch has work to advance",
73
+ # shared by the candidate `SELECT` and the claiming `UPDATE` so the two can
74
+ # never drift. Without a `SELECT ... FOR UPDATE`, candidate selection and
75
+ # claiming are separate statements, so the claim MUST re-assert every
76
+ # predicate the selection relied on -- not just `claimed_for_advancing_at IS
77
+ # NULL`. Between the two, the advancer that already owned this branch can
78
+ # finish and `release!` it (back to `in_progress`, claim nulled), which
79
+ # leaves the null check satisfied by a branch whose latest step is now a
80
+ # freshly enqueued `in_progress` one. Claiming that would advance the branch
81
+ # off a step whose job has not run yet -- routing on a nil return value and
82
+ # minting a duplicate downstream step. The window is normally microseconds,
83
+ # but the candidate `SELECT` scans the branch/step tables, so at scale
84
+ # (~1M live branches) it stretches into the tens of milliseconds and the
85
+ # race becomes routine. Sampling from a window rather than always taking
86
+ # the single oldest row spreads advancers across distinct candidates, but
87
+ # it also means an id can sit unattempted while earlier attempts in the
88
+ # walk run -- so the ids reaching the claiming `UPDATE` are, if anything,
89
+ # staler than before and the re-assertion matters more, not less.
90
+ # Re-asserting the status keeps a `completed`/`halted` branch from being
91
+ # resurrected the same way.
92
+ def claimable_branches
52
93
  Ductwork::Branch
53
94
  .in_progress
54
95
  .where(pipeline_klass:, claimed_for_advancing_at:)
55
- .where(steps: Ductwork::Step.where(status: %w[advancing failed]))
96
+ .where(steps: Ductwork::Step.where(status: Ductwork::Step::ADVANCEABLE_STATUSES))
97
+ end
98
+
99
+ def find_candidate_branch_ids
100
+ claimable_branches
56
101
  .order(:last_advanced_at)
57
- .limit(1)
102
+ .limit(candidate_window_size)
58
103
  .pluck(:id)
59
- .first
104
+ .sample(MAX_CLAIM_ATTEMPTS)
60
105
  end
61
106
 
62
- def claim_and_setup_records(id, process)
63
- now = Time.current
107
+ def claim_and_setup_records(ids, process)
108
+ ids.each do |id|
109
+ @attempted_branch_id = id
110
+ claimed_id = attempt_claim(id, process)
111
+
112
+ return claimed_id if claimed_id
113
+ end
114
+
115
+ nil
116
+ end
117
+
118
+ def attempt_claim(id, process)
119
+ now = Ductwork::DatabaseClock.now
64
120
  @token = SecureRandom.uuid
65
121
 
66
122
  Ductwork::Record.transaction do
67
- rows_updated = Ductwork::Branch
68
- .where(id:, claimed_for_advancing_at:)
123
+ rows_updated = claimable_branches
124
+ .where(id:)
69
125
  .update_all(
70
126
  claimed_for_advancing_at: now,
71
127
  claim_token: token,
72
- status: :advancing
128
+ status: :advancing,
129
+ updated_at: now
73
130
  )
74
131
 
75
- if rows_updated == 1
76
- branch = Branch.find(id)
77
- @transition = find_or_create_transition(branch, now)
78
- Ductwork::FaultInjection.checkpoint(:before_advancement_create)
79
- @advancement = transition.advancements.create!(
80
- process: process,
81
- started_at: now,
82
- crash_count: next_crash_count(transition)
83
- )
84
- end
85
-
86
- rows_updated
132
+ next nil unless rows_updated == 1
133
+
134
+ branch = Branch.find(id)
135
+ @transition = find_or_create_transition(branch, now)
136
+ Ductwork::FaultInjection.checkpoint(:before_advancement_create)
137
+ @advancement = transition.advancements.create!(
138
+ process: process,
139
+ started_at: now,
140
+ crash_count: next_crash_count(transition)
141
+ )
142
+
143
+ id
87
144
  end
88
145
  end
89
146
 
@@ -133,7 +190,7 @@ module Ductwork
133
190
  .first
134
191
  &.update!(
135
192
  completed_at: now,
136
- error_klass: "Ductwork::ProcessCrash",
193
+ error_klass: Ductwork::ProcessCrash.name,
137
194
  error_message: "Advancement was abandoned from a process crash"
138
195
  )
139
196
  end
@@ -158,10 +215,9 @@ module Ductwork
158
215
  nil
159
216
  end
160
217
 
161
- def log_race_condition(id)
218
+ def log_lost_claim_races
162
219
  Ductwork.logger.debug(
163
- msg: "Did not claim branch, avoided race condition",
164
- branch_id: id,
220
+ msg: "Did not claim branch, lost races on all sampled IDs",
165
221
  pipeline_klass: pipeline_klass,
166
222
  role: :pipeline_advancer
167
223
  )
@@ -169,10 +225,10 @@ module Ductwork
169
225
  nil
170
226
  end
171
227
 
172
- def log_process_reaped_mid_claim(id, error)
228
+ def log_process_reaped_mid_claim(error)
173
229
  Ductwork.logger.warn(
174
230
  msg: "Did not claim branch, our process record was reaped mid-claim",
175
- branch_id: id,
231
+ branch_id: attempted_branch_id,
176
232
  pipeline_klass: pipeline_klass,
177
233
  error_klass: error.class.to_s,
178
234
  error_message: error.message,
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ # NOTE: normalizes the display state of a freshly claimed job, moving its
5
+ # step, run, and pipeline to `in_progress`.
6
+ #
7
+ # The step row belongs to the one claim, but the run and pipeline rows are
8
+ # shared by every step of the run, so a million-wide expand funnels every
9
+ # concurrent claim through the same two rows. Each write is already
10
+ # conditional and matches nothing once the run is under way, but a no-op
11
+ # `UPDATE` is not free everywhere: PostgreSQL never locks a tuple that fails
12
+ # the qualification, while InnoDB at REPEATABLE READ takes the exclusive lock
13
+ # during the primary key lookup and only releases it on a non-matching row
14
+ # under READ COMMITTED's semi-consistent read. On MySQL and Trilogy that left
15
+ # every worker holding both shared rows for the rest of its claim
16
+ # transaction, capping claim throughput for a single run regardless of how
17
+ # many workers were running.
18
+ #
19
+ # Reading first keeps the steady state lock free, since a plain `SELECT` is a
20
+ # consistent-snapshot read on every supported adapter. The writes stay
21
+ # conditional, so losing the guard's race is benign: a stale read costs one
22
+ # redundant no-op `UPDATE` on the single claim that actually transitions.
23
+ class ClaimedState
24
+ def self.mark_in_progress!(step)
25
+ new(step).mark_in_progress!
26
+ end
27
+
28
+ def initialize(step)
29
+ @step = step
30
+ end
31
+
32
+ def mark_in_progress!
33
+ transition!(Ductwork::Step, step.id)
34
+
35
+ run_status, pipeline_id = Ductwork::Run.where(id: step.run_id).pick(:status, :pipeline_id)
36
+
37
+ return if run_status.nil?
38
+
39
+ transition!(Ductwork::Run, step.run_id) unless run_status == "in_progress"
40
+
41
+ return if Ductwork::Pipeline.where(id: pipeline_id, status: "in_progress").exists?
42
+
43
+ transition!(Ductwork::Pipeline, pipeline_id)
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :step
49
+
50
+ def transition!(klass, id)
51
+ klass
52
+ .where(id:)
53
+ .where.not(status: "in_progress")
54
+ .update_all(status: "in_progress", updated_at: Time.current)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Crash < StandardError; end
5
+ end
@@ -20,7 +20,6 @@ module Ductwork
20
20
  end
21
21
 
22
22
  initializer "ductwork.configure" do
23
- Ductwork.configuration ||= Ductwork::Configuration.new
24
23
  Ductwork.logger ||= Ductwork::Configuration::DEFAULT_LOGGER
25
24
  end
26
25
  end
@@ -7,19 +7,22 @@ module Ductwork
7
7
 
8
8
  validates :started_at, presence: true
9
9
 
10
- CRASH_ERROR_KLASSES = %w[Ductwork::ProcessCrash Ductwork::ThreadCrash].freeze
10
+ CRASH_ERROR_KLASSES = [Ductwork::ProcessCrash.name, Ductwork::ThreadCrash.name].freeze
11
11
 
12
12
  def crash?
13
13
  CRASH_ERROR_KLASSES.include?(error_klass)
14
14
  end
15
15
 
16
16
  def process_crashed!
17
+ completed_at = updated_at = Time.current
18
+
17
19
  Ductwork::Record.transaction do
18
20
  rows_updated = self.class
19
21
  .where(id: id, completed_at: nil)
20
22
  .update_all(
21
- completed_at: Time.current,
22
- error_klass: "Ductwork::ProcessCrash",
23
+ completed_at: completed_at,
24
+ updated_at: updated_at,
25
+ error_klass: Ductwork::ProcessCrash.name,
23
26
  error_message: "Reaped from orphaned process"
24
27
  )
25
28
 
@@ -30,12 +33,15 @@ module Ductwork
30
33
  end
31
34
 
32
35
  def thread_crashed!(expected_token)
36
+ completed_at = updated_at = Time.current
37
+
33
38
  Ductwork::Record.transaction do
34
39
  rows_updated = self.class
35
40
  .where(id: id, completed_at: nil)
36
41
  .update_all(
37
- completed_at: Time.current,
38
- error_klass: "Ductwork::ThreadCrash",
42
+ completed_at: completed_at,
43
+ updated_at: updated_at,
44
+ error_klass: Ductwork::ThreadCrash.name,
39
45
  error_message: "Advancement abandoned from a thread crash"
40
46
  )
41
47
 
@@ -56,9 +56,11 @@ module Ductwork
56
56
 
57
57
  yield branch, branch_claim.transition, branch_claim.advancement
58
58
 
59
- true
59
+ :claimed
60
+ elsif branch_claim.contended?
61
+ :contended
60
62
  else
61
- false
63
+ :idle
62
64
  end
63
65
  ensure
64
66
  advancement = branch_claim.advancement
@@ -107,6 +109,19 @@ module Ductwork
107
109
  step = latest_step
108
110
  max_crash = Ductwork.configuration.pipeline_advancer_max_crash
109
111
 
112
+ # NOTE: backstop for a stale claim, checked before anything else because a
113
+ # branch with no advanceable step must be neither routed NOR halted. Every
114
+ # legitimate claim arrives with its latest step in `advancing` (job
115
+ # succeeded, or `revive!`'s re-advance) or `failed` (budgets exhausted);
116
+ # `BranchClaim` re-asserts exactly that in its claiming UPDATE. If we
117
+ # nonetheless hold a branch whose latest step is still running, the
118
+ # previous advancer already handled that step and enqueued the next one --
119
+ # there is nothing to advance and no failure to attribute. A poison branch
120
+ # that keeps crashing the advancer is unaffected: its step stays
121
+ # `advancing` (the crash rolled the transition back), so the crash cap
122
+ # below still fires.
123
+ return release_stale_claim!(transition, advancement, step) unless advanceable_step?(step)
124
+
110
125
  # NOTE: the crash cap is checked first as the true backstop against a
111
126
  # poison branch that repeatedly crashes the advancer process/thread. In a
112
127
  # normal failed-step halt no advancer crashes have accrued, so this only
@@ -168,18 +183,49 @@ module Ductwork
168
183
  # silently leave the branch stranded in `advancing`. Callers outside an
169
184
  # advancement (fresh branch objects, explicit tokens) are unaffected.
170
185
  def release!(expected_token = @claim_fence_token || claim_token)
186
+ last_advanced_at = updated_at = Time.current
187
+
171
188
  Ductwork::Branch
172
189
  .where(id: id, claim_token: expected_token, status: :advancing)
173
190
  .update_all(
174
191
  claimed_for_advancing_at: nil,
175
192
  claim_token: nil,
176
193
  status: :in_progress,
177
- last_advanced_at: Time.current
194
+ last_advanced_at: last_advanced_at,
195
+ updated_at: updated_at
178
196
  )
179
197
  end
180
198
 
181
199
  private
182
200
 
201
+ def advanceable_step?(step)
202
+ step.present? && Ductwork::Step::ADVANCEABLE_STATUSES.include?(step.status)
203
+ end
204
+
205
+ # NOTE: close our bookkeeping and hand the branch back untouched. The
206
+ # advancement is closed WITHOUT an `error_klass` on purpose: this is not an
207
+ # advancer failure, and stamping one would burn either the retry budget
208
+ # (`too_many_failed_attempts?`) or the crash budget (`next_crash_count`) and
209
+ # eventually halt a perfectly healthy branch. `release!` restores
210
+ # `in_progress`, so the branch no longer matches the candidate predicate
211
+ # until its running step lands -- no reclaim loop.
212
+ def release_stale_claim!(transition, advancement, step)
213
+ with_claim_fence do
214
+ now = Time.current
215
+ advancement&.update!(completed_at: now)
216
+ transition.update!(completed_at: now)
217
+ release!
218
+ end
219
+
220
+ Ductwork.logger.warn(
221
+ msg: "Branch claimed with no step to advance, released",
222
+ branch_id: id,
223
+ step_id: step&.id,
224
+ step_status: step&.status,
225
+ role: :pipeline_advancer
226
+ )
227
+ end
228
+
183
229
  def log_claim_diverged
184
230
  Ductwork.logger.info(
185
231
  msg: "Branch claim no longer held",
@@ -201,13 +247,28 @@ module Ductwork
201
247
  end
202
248
  end
203
249
 
250
+ # NOTE: the unrescued form of the halt, for callers that are ALREADY inside a
251
+ # `with_claim_fence`. Those callers must NOT use the rescuing wrapper below:
252
+ # `Ductwork::Record.transaction` nests by joining (no savepoint), so a rescue
253
+ # here swallows the error without unwinding the caller's still-open outer
254
+ # transaction. The fence would then commit the caller's partial work (a step
255
+ # already flipped to `completed`) alongside a branch the rescue put back to
256
+ # `in_progress` — a branch `find_candidate_branch_id` can never select again
257
+ # (no step in `advancing`/`failed`) and no reaper covers (the advancement was
258
+ # closed). Letting the error propagate instead lets `route_by_edge`'s rescue,
259
+ # which sits outside the fence, roll the whole thing back and retry.
260
+ def halt_branch_and_resolve_run_without_rescue!(transition, advancement, halt_reason)
261
+ now = Time.current
262
+ advancement.update!(completed_at: now)
263
+ transition.update!(completed_at: now)
264
+ halt!(halt_reason)
265
+ run.resolve_terminal_state!
266
+ end
267
+
268
+ # NOTE: only for callers outside a claim fence (`advance!`'s direct halts).
204
269
  def halt_branch_and_resolve_run!(transition, advancement, halt_reason)
205
270
  with_claim_fence do
206
- now = Time.current
207
- advancement.update!(completed_at: now)
208
- transition.update!(completed_at: now)
209
- halt!(halt_reason)
210
- run.resolve_terminal_state!
271
+ halt_branch_and_resolve_run_without_rescue!(transition, advancement, halt_reason)
211
272
  end
212
273
  rescue StandardError => e
213
274
  # NOTE: re-enter the claim fence before mutating branch/advancement state.
@@ -346,7 +407,7 @@ module Ductwork
346
407
  input_arg = Ductwork::Job.find_by(step: latest_step).return_value
347
408
  node = edge[:to].sole
348
409
  klass = run.parsed_definition.dig(:edges, node, :klass)
349
- started_at = Time.current
410
+ started_at = Ductwork::DatabaseClock.now
350
411
 
351
412
  with_claim_fence do
352
413
  latest_step.update!(status: :completed, completed_at: Time.current)
@@ -396,7 +457,7 @@ module Ductwork
396
457
  .map(&:return_value)
397
458
  next_node = edge[:to].sole
398
459
  klass = run.parsed_definition.dig(:edges, next_node, :klass)
399
- started_at = Time.current
460
+ started_at = Ductwork::DatabaseClock.now
400
461
  next_branch = run.branches.create!(
401
462
  started_at: started_at,
402
463
  status: "in_progress",
@@ -537,7 +598,7 @@ module Ductwork
537
598
  .map(&:return_value)
538
599
  next_node = edge[:to].sole
539
600
  klass = run.parsed_definition.dig(:edges, next_node, :klass)
540
- started_at = Time.current
601
+ started_at = Ductwork::DatabaseClock.now
541
602
  next_branch = run.branches.create!(
542
603
  started_at: started_at,
543
604
  status: "in_progress",
@@ -573,7 +634,7 @@ module Ductwork
573
634
  input_arg = Ductwork::Job.find_by(step: latest_step).return_value
574
635
  node = edge[:to].sole
575
636
  klass = run.parsed_definition.dig(:edges, node, :klass)
576
- started_at = Time.current
637
+ started_at = Ductwork::DatabaseClock.now
577
638
 
578
639
  with_claim_fence do
579
640
  latest_step.update!(status: :completed, completed_at: Time.current)
@@ -599,12 +660,16 @@ module Ductwork
599
660
  input_arg = Ductwork::Job.find_by(step: latest_step).return_value
600
661
  node = edge[:to][input_arg.to_s] || edge[:to]["otherwise"]
601
662
  klass = run.parsed_definition.dig(:edges, node, :klass)
602
- started_at = Time.current
663
+ started_at = Ductwork::DatabaseClock.now
603
664
 
604
665
  if node.nil?
605
666
  with_claim_fence do
606
667
  latest_step.update!(status: :completed, completed_at: Time.current)
607
- halt_branch_and_resolve_run!(transition, advancement, "condition_unmatched")
668
+ halt_branch_and_resolve_run_without_rescue!(
669
+ transition,
670
+ advancement,
671
+ "condition_unmatched"
672
+ )
608
673
  end
609
674
  else
610
675
  with_claim_fence do
@@ -628,7 +693,7 @@ module Ductwork
628
693
  end
629
694
 
630
695
  def divide_branch(edge, transition, advancement) # rubocop:todo Metrics
631
- started_at = Time.current
696
+ started_at = Ductwork::DatabaseClock.now
632
697
  input_arg = Ductwork::Job.find_by(step: latest_step).return_value
633
698
  too_many = edge[:to].tally.any? do |to_klass, count|
634
699
  depth = Ductwork
@@ -641,7 +706,11 @@ module Ductwork
641
706
  if too_many
642
707
  with_claim_fence do
643
708
  latest_step.update!(status: :completed, completed_at: Time.current)
644
- halt_branch_and_resolve_run!(transition, advancement, "max_fanout_exceeded")
709
+ halt_branch_and_resolve_run_without_rescue!(
710
+ transition,
711
+ advancement,
712
+ "max_fanout_exceeded"
713
+ )
645
714
  end
646
715
  else
647
716
  with_claim_fence do
@@ -686,7 +755,11 @@ module Ductwork
686
755
  if max_depth != -1 && return_value.count > max_depth
687
756
  with_claim_fence do
688
757
  latest_step.update!(status: :completed, completed_at: Time.current)
689
- halt_branch_and_resolve_run!(transition, advancement, "max_fanout_exceeded")
758
+ halt_branch_and_resolve_run_without_rescue!(
759
+ transition,
760
+ advancement,
761
+ "max_fanout_exceeded"
762
+ )
690
763
  end
691
764
  elsif return_value.none?
692
765
  complete_branch_and_pipeline(transition, advancement)
@@ -698,7 +771,7 @@ module Ductwork
698
771
  def bulk_create_steps_and_jobs(edge:, return_value:, transition:, advancement:) # rubocop:todo Metrics
699
772
  node = edge[:to].sole
700
773
  next_klass = run.parsed_definition.dig(:edges, node, :klass)
701
- now = Time.current
774
+ now = Ductwork::DatabaseClock.now
702
775
 
703
776
  with_claim_fence do # rubocop:todo Metrics/BlockLength
704
777
  latest_step.update!(status: :completed, completed_at: Time.current)