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.
- checksums.yaml +4 -4
- data/.claude/skills/audit-clock-drift/SKILL.md +167 -5
- data/.claude/skills/audit-common/accepted-tradeoffs.md +118 -0
- data/.claude/skills/audit-common/method.md +86 -0
- data/.claude/skills/audit-common/scope-boundaries.md +101 -0
- data/.claude/skills/audit-common/severity.md +77 -0
- data/.claude/skills/audit-database-indexes/SKILL.md +182 -5
- data/.claude/skills/audit-database-support/SKILL.md +209 -11
- data/.claude/skills/audit-durability/SKILL.md +227 -10
- data/CHANGELOG-PRO.md +17 -1
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +1 -1
- data/lib/ductwork/abandoned_claim.rb +8 -0
- data/lib/ductwork/branch_claim.rb +92 -36
- data/lib/ductwork/claimed_state.rb +57 -0
- data/lib/ductwork/crash.rb +5 -0
- data/lib/ductwork/engine.rb +0 -1
- data/lib/ductwork/models/advancement.rb +11 -5
- data/lib/ductwork/models/branch.rb +91 -18
- data/lib/ductwork/models/execution.rb +41 -14
- data/lib/ductwork/models/pipeline.rb +2 -2
- data/lib/ductwork/models/process.rb +7 -3
- data/lib/ductwork/models/run.rb +2 -1
- data/lib/ductwork/models/step.rb +12 -7
- data/lib/ductwork/optimistic_locking_execution_claim.rb +6 -16
- data/lib/ductwork/orphaned_claim.rb +5 -0
- data/lib/ductwork/polling_interval.rb +30 -0
- data/lib/ductwork/process_crash.rb +5 -0
- data/lib/ductwork/processes/job_worker.rb +25 -6
- data/lib/ductwork/processes/pipeline_advancer.rb +16 -6
- data/lib/ductwork/processes/process_supervisor.rb +21 -0
- data/lib/ductwork/processes/worker_health_check.rb +38 -10
- data/lib/ductwork/row_locking_execution_claim.rb +4 -17
- data/lib/ductwork/thread_crash.rb +5 -0
- data/lib/ductwork/version.rb +1 -1
- data/lib/ductwork.rb +6 -2
- metadata +12 -1
data/CHANGELOG-PRO.md
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
# Ductwork Pro Changelog
|
|
2
2
|
|
|
3
|
-
## [1.
|
|
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
|
@@ -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
|
-
|
|
32
|
+
ids = find_candidate_branch_ids
|
|
26
33
|
|
|
27
|
-
return log_no_branches if
|
|
34
|
+
return log_no_branches if ids.blank?
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
Ductwork::FaultInjection.checkpoint(:after_branch_candidate_select)
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
claimed_id = claim_and_setup_records(ids, process)
|
|
39
|
+
|
|
40
|
+
if claimed_id
|
|
41
|
+
Ductwork::Branch.find(claimed_id)
|
|
33
42
|
else
|
|
34
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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(
|
|
102
|
+
.limit(candidate_window_size)
|
|
58
103
|
.pluck(:id)
|
|
59
|
-
.
|
|
104
|
+
.sample(MAX_CLAIM_ATTEMPTS)
|
|
60
105
|
end
|
|
61
106
|
|
|
62
|
-
def claim_and_setup_records(
|
|
63
|
-
|
|
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 =
|
|
68
|
-
.where(id
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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:
|
|
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
|
|
218
|
+
def log_lost_claim_races
|
|
162
219
|
Ductwork.logger.debug(
|
|
163
|
-
msg: "Did not claim branch,
|
|
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(
|
|
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:
|
|
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
|
data/lib/ductwork/engine.rb
CHANGED
|
@@ -7,19 +7,22 @@ module Ductwork
|
|
|
7
7
|
|
|
8
8
|
validates :started_at, presence: true
|
|
9
9
|
|
|
10
|
-
CRASH_ERROR_KLASSES =
|
|
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:
|
|
22
|
-
|
|
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:
|
|
38
|
-
|
|
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
|
-
|
|
59
|
+
:claimed
|
|
60
|
+
elsif branch_claim.contended?
|
|
61
|
+
:contended
|
|
60
62
|
else
|
|
61
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|