ductwork 0.26.0 → 1.0.0
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 +11 -0
- data/.claude/skills/audit-database-indexes/SKILL.md +11 -0
- data/.claude/skills/audit-database-support/SKILL.md +18 -0
- data/.claude/skills/audit-durability/SKILL.md +17 -0
- data/.saturnci/database.yml +1 -0
- data/CHANGELOG-PRO.md +60 -0
- data/CHANGELOG.md +144 -1
- data/CLAUDE.md +34 -0
- data/README.md +24 -14
- data/app/controllers/ductwork/application_controller.rb +3 -3
- data/app/controllers/ductwork/dashboards_controller.rb +1 -1
- data/app/controllers/ductwork/pipelines_controller.rb +4 -3
- data/app/helpers/ductwork/application_helper.rb +2 -2
- data/app/views/ductwork/dashboards/show.html.erb +12 -12
- data/app/views/ductwork/pipelines/index.html.erb +12 -12
- data/app/views/ductwork/pipelines/show.html.erb +13 -13
- data/app/views/ductwork/step_errors/index.html.erb +2 -2
- data/lib/ductwork/branch_claim.rb +185 -0
- data/lib/ductwork/cli.rb +73 -24
- data/lib/ductwork/configuration.rb +73 -3
- data/lib/ductwork/context.rb +17 -19
- data/lib/ductwork/database_clock.rb +84 -0
- data/lib/ductwork/dsl/branch_builder.rb +6 -1
- data/lib/ductwork/dsl/definition_builder.rb +24 -1
- data/lib/ductwork/{job_claim.rb → execution_claim.rb} +7 -6
- data/lib/ductwork/fault_injection.rb +35 -0
- data/lib/ductwork/migration_helper.rb +1 -1
- data/lib/ductwork/models/advancement.rb +48 -0
- data/lib/ductwork/models/attempt.rb +9 -0
- data/lib/ductwork/models/availability.rb +2 -0
- data/lib/ductwork/models/branch.rb +800 -0
- data/lib/ductwork/models/branch_link.rb +10 -0
- data/lib/ductwork/models/execution.rb +199 -2
- data/lib/ductwork/models/job.rb +8 -121
- data/lib/ductwork/models/pipeline.rb +150 -272
- data/lib/ductwork/models/process.rb +177 -5
- data/lib/ductwork/models/result.rb +2 -1
- data/lib/ductwork/models/run.rb +119 -1
- data/lib/ductwork/models/step.rb +26 -6
- data/lib/ductwork/models/transition.rb +15 -0
- data/lib/ductwork/models/tuple.rb +1 -4
- data/lib/ductwork/models/workflow.rb +3 -0
- data/lib/ductwork/optimistic_locking_execution_claim.rb +97 -0
- data/lib/ductwork/processes/health_check.rb +87 -0
- data/lib/ductwork/processes/job_worker.rb +86 -20
- data/lib/ductwork/processes/job_worker_runner.rb +25 -37
- data/lib/ductwork/processes/pipeline_advancer.rb +48 -76
- data/lib/ductwork/processes/pipeline_advancer_runner.rb +34 -44
- data/lib/ductwork/processes/process_supervisor.rb +87 -10
- data/lib/ductwork/processes/thread_supervisor.rb +55 -21
- data/lib/ductwork/processes/thread_supervisor_runner.rb +8 -5
- data/lib/ductwork/processes/worker_health_check.rb +55 -0
- data/lib/ductwork/row_locking_execution_claim.rb +77 -0
- data/lib/ductwork/testing/rspec.rb +28 -11
- data/lib/ductwork/version.rb +1 -1
- data/lib/ductwork.rb +5 -0
- data/lib/generators/ductwork/install/install_generator.rb +14 -4
- data/lib/generators/ductwork/install/templates/config/ductwork.yml +4 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_advancements.rb +29 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_attempts.rb +20 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +18 -5
- data/lib/generators/ductwork/install/templates/db/create_ductwork_branch_links.rb +27 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_branches.rb +37 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +8 -1
- data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +0 -8
- data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +1 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +45 -6
- data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +20 -5
- data/lib/generators/ductwork/install/templates/db/create_ductwork_transitions.rb +43 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +3 -3
- data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_advancements.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_executions.rb +14 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_results.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_runs.rb +9 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_transitions.rb +16 -0
- data/lib/generators/ductwork/update/templates/db/add_pipeline_started_index_to_ductwork_runs.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_process_id_to_ductwork_executions.rb +35 -0
- data/lib/generators/ductwork/update/templates/db/add_role_to_ductwork_processes.rb +20 -0
- data/lib/generators/ductwork/update/templates/db/associate_branches_to_runs.rb +74 -0
- data/lib/generators/ductwork/update/templates/db/associate_steps_to_branches.rb +24 -0
- data/lib/generators/ductwork/update/templates/db/associate_steps_to_runs.rb +72 -0
- data/lib/generators/ductwork/update/templates/db/associate_tuples_to_runs.rb +73 -0
- data/lib/generators/ductwork/update/templates/db/backfill_branch_ids_on_steps.rb +21 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_advancements.rb +28 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_branch_links.rb +27 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_branches.rb +37 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_runs.rb +55 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_transitions.rb +32 -0
- data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +11 -4
- data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +5 -0
- data/lib/generators/ductwork/update/templates/db/rename_runs_to_attempts.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/update_process_associations.rb +29 -0
- data/lib/generators/ductwork/update/update_generator.rb +99 -0
- metadata +47 -6
- data/lib/ductwork/optimistic_locking_job_claim.rb +0 -88
- data/lib/ductwork/row_locking_job_claim.rb +0 -75
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
# NOTE: these are SQL fragments that resolve against the database server's
|
|
5
|
+
# clock instead of the calling Ruby process's clock. use them in `WHERE`
|
|
6
|
+
# clauses that compare between a stored timestamp and "now" so that NTP
|
|
7
|
+
# drift between hosts cannot make healthy work look stale (or vice versa)
|
|
8
|
+
class DatabaseClock
|
|
9
|
+
def self.ago_sql(column, interval)
|
|
10
|
+
new(column).ago_sql(interval)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.now_sql(column)
|
|
14
|
+
new(column).now_sql
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.now
|
|
18
|
+
adapter = Ductwork::Record.connection.adapter_name.downcase
|
|
19
|
+
sql =
|
|
20
|
+
case adapter
|
|
21
|
+
when /postgresql|cockroach/i
|
|
22
|
+
"SELECT clock_timestamp()"
|
|
23
|
+
when /mysql|trilogy/i
|
|
24
|
+
"SELECT CURRENT_TIMESTAMP(6)"
|
|
25
|
+
when /sqlite/i
|
|
26
|
+
"SELECT strftime('%Y-%m-%d %H:%M:%f', 'now')"
|
|
27
|
+
when /oracle/i
|
|
28
|
+
"SELECT CURRENT_TIMESTAMP FROM dual"
|
|
29
|
+
else
|
|
30
|
+
raise NotImplementedError, "Database clock does not support adapter #{adapter}"
|
|
31
|
+
end
|
|
32
|
+
raw = Ductwork::Record.connection.select_value(sql)
|
|
33
|
+
|
|
34
|
+
case raw
|
|
35
|
+
when ::Time, ::DateTime
|
|
36
|
+
raw.in_time_zone
|
|
37
|
+
else
|
|
38
|
+
# NOTE: SQLite returns an ISO-8601 string already expressed in UTC
|
|
39
|
+
::Time.find_zone!("UTC").parse(raw.to_s)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(column)
|
|
44
|
+
@adapter = Ductwork::Record.connection.adapter_name.downcase
|
|
45
|
+
@column = column
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ago_sql(interval)
|
|
49
|
+
seconds = interval.to_i
|
|
50
|
+
|
|
51
|
+
case adapter
|
|
52
|
+
when /postgresql|cockroach/i
|
|
53
|
+
"#{column} <= clock_timestamp() - INTERVAL '#{seconds} seconds'"
|
|
54
|
+
when /mysql|trilogy/i
|
|
55
|
+
"#{column} <= CURRENT_TIMESTAMP(6) - INTERVAL #{seconds} SECOND"
|
|
56
|
+
when /sqlite/i
|
|
57
|
+
"julianday(#{column}) <= julianday('now', '-#{seconds} seconds')"
|
|
58
|
+
when /oracle/i
|
|
59
|
+
"#{column} <= CURRENT_TIMESTAMP - NUMTODSINTERVAL(#{seconds}, 'SECOND')"
|
|
60
|
+
else
|
|
61
|
+
raise NotImplementedError, "Database clock does not support adapter #{adapter}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def now_sql
|
|
66
|
+
case adapter
|
|
67
|
+
when /sqlite/i
|
|
68
|
+
"julianday(#{column}) <= julianday('now')"
|
|
69
|
+
when /mysql|trilogy/i
|
|
70
|
+
"#{column} <= CURRENT_TIMESTAMP(6)"
|
|
71
|
+
when /postgresql|cockroach/i
|
|
72
|
+
"#{column} <= clock_timestamp()"
|
|
73
|
+
when /oracle/i
|
|
74
|
+
"#{column} <= CURRENT_TIMESTAMP"
|
|
75
|
+
else
|
|
76
|
+
raise NotImplementedError, "Database clock does not support adapter #{adapter}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
attr_reader :adapter, :column
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -11,6 +11,7 @@ module Ductwork
|
|
|
11
11
|
@last_node = last_node
|
|
12
12
|
@definition = definition
|
|
13
13
|
@expansions = 0
|
|
14
|
+
@expand_sources = []
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def chain(next_klass)
|
|
@@ -100,6 +101,9 @@ module Ductwork
|
|
|
100
101
|
|
|
101
102
|
def expand(to:)
|
|
102
103
|
next_klass_name = node_name(to)
|
|
104
|
+
# NOTE: record the expanding node so the matching `collapse` can stamp it
|
|
105
|
+
# as `barrier_node` (see `Ductwork::DSL::DefinitionBuilder#expand`).
|
|
106
|
+
expand_sources.push(last_node)
|
|
103
107
|
definition[:edges][last_node][:to] = [next_klass_name]
|
|
104
108
|
definition[:edges][last_node][:type] = :expand
|
|
105
109
|
definition[:nodes].push(next_klass_name)
|
|
@@ -119,6 +123,7 @@ module Ductwork
|
|
|
119
123
|
next_klass_name = node_name(into)
|
|
120
124
|
definition[:edges][last_node][:to] = [next_klass_name]
|
|
121
125
|
definition[:edges][last_node][:type] = :collapse
|
|
126
|
+
definition[:edges][last_node][:barrier_node] = expand_sources.pop
|
|
122
127
|
|
|
123
128
|
definition[:nodes].push(next_klass_name)
|
|
124
129
|
definition[:edges][next_klass_name] ||= { klass: into.name }
|
|
@@ -130,7 +135,7 @@ module Ductwork
|
|
|
130
135
|
|
|
131
136
|
private
|
|
132
137
|
|
|
133
|
-
attr_reader :definition, :expansions
|
|
138
|
+
attr_reader :definition, :expansions, :expand_sources
|
|
134
139
|
|
|
135
140
|
def node_name(klass)
|
|
136
141
|
"#{klass.name}.#{SecureRandom.hex(4)}"
|
|
@@ -17,6 +17,7 @@ module Ductwork
|
|
|
17
17
|
}
|
|
18
18
|
@divergences = []
|
|
19
19
|
@last_nodes = []
|
|
20
|
+
@expand_sources = []
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def start(klass)
|
|
@@ -98,6 +99,13 @@ module Ductwork
|
|
|
98
99
|
def expand(to:)
|
|
99
100
|
validate_classes!(to)
|
|
100
101
|
validate_definition_started!(action: "expanding chain")
|
|
102
|
+
|
|
103
|
+
# NOTE: record the node(s) carrying this `expand` edge before
|
|
104
|
+
# `add_edge_to_last_nodes` reassigns `@last_nodes`. The matching
|
|
105
|
+
# `collapse` stamps this on its edge as `barrier_node` so the runtime
|
|
106
|
+
# fan-in can find the expanding branch (which carries the barrier) no
|
|
107
|
+
# matter how many transitions sit between the `expand` and `collapse`.
|
|
108
|
+
expand_sources.push(last_nodes.dup)
|
|
101
109
|
add_edge_to_last_nodes(to, type: :expand)
|
|
102
110
|
divergences.push(:expand)
|
|
103
111
|
|
|
@@ -108,8 +116,13 @@ module Ductwork
|
|
|
108
116
|
validate_classes!(into)
|
|
109
117
|
validate_definition_started!(action: "collapsing steps")
|
|
110
118
|
validate_can_collapse!
|
|
119
|
+
collapse_sources = last_nodes.dup
|
|
111
120
|
add_edge_to_last_nodes(into, type: :collapse)
|
|
112
121
|
divergences.pop
|
|
122
|
+
barrier_node = pop_barrier_node
|
|
123
|
+
collapse_sources.each do |source|
|
|
124
|
+
definition[:edges][source][:barrier_node] = barrier_node
|
|
125
|
+
end
|
|
113
126
|
|
|
114
127
|
self
|
|
115
128
|
end
|
|
@@ -190,7 +203,17 @@ module Ductwork
|
|
|
190
203
|
|
|
191
204
|
private
|
|
192
205
|
|
|
193
|
-
attr_reader :definition, :last_nodes, :divergences
|
|
206
|
+
attr_reader :definition, :last_nodes, :divergences, :expand_sources
|
|
207
|
+
|
|
208
|
+
def pop_barrier_node
|
|
209
|
+
sources = expand_sources.pop || []
|
|
210
|
+
|
|
211
|
+
if sources.length == 1
|
|
212
|
+
sources.first
|
|
213
|
+
else
|
|
214
|
+
sources
|
|
215
|
+
end
|
|
216
|
+
end
|
|
194
217
|
|
|
195
218
|
def validate_classes!(klasses)
|
|
196
219
|
valid = Array(klasses).all? do |klass|
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ductwork
|
|
4
|
-
class
|
|
5
|
-
def initialize(klass)
|
|
4
|
+
class ExecutionClaim
|
|
5
|
+
def initialize(klass, owner_process_id)
|
|
6
6
|
@klass = klass
|
|
7
|
+
@owner_process_id = owner_process_id
|
|
7
8
|
@adapter = Ductwork::Record.connection.adapter_name.downcase
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def latest
|
|
11
12
|
claim = if supports_row_locking?
|
|
12
|
-
|
|
13
|
+
RowLockingExecutionClaim
|
|
13
14
|
else
|
|
14
|
-
|
|
15
|
+
OptimisticLockingExecutionClaim
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
claim.new(klass).latest
|
|
18
|
+
claim.new(klass, owner_process_id).latest
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
22
|
-
attr_reader :klass, :adapter
|
|
23
|
+
attr_reader :klass, :owner_process_id, :adapter
|
|
23
24
|
|
|
24
25
|
def supports_row_locking?
|
|
25
26
|
adapter.match?(/postgresql/i) ||
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
module FaultInjection
|
|
5
|
+
ENV_KEY = "DUCTWORK_FAULT"
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def checkpoint(key)
|
|
9
|
+
return if !ENV.key?(ENV_KEY)
|
|
10
|
+
|
|
11
|
+
spec = ENV.fetch(ENV_KEY, "").split(":")
|
|
12
|
+
|
|
13
|
+
return if spec[0] != key.to_s
|
|
14
|
+
|
|
15
|
+
warn "FAULT FIRING: #{spec[0]} -> #{spec[1]} (pid=#{::Process.pid})"
|
|
16
|
+
|
|
17
|
+
case spec[1]
|
|
18
|
+
when "kill" then ::Process.kill(:KILL, ::Process.pid)
|
|
19
|
+
when "raise" then raise "FaultInjection: #{key}"
|
|
20
|
+
when "sleep" then sleep(1)
|
|
21
|
+
when "exit" then exit!(1)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with(key, action)
|
|
26
|
+
previous_value = ENV.fetch(ENV_KEY, nil)
|
|
27
|
+
ENV[ENV_KEY] = "#{key}:#{action}"
|
|
28
|
+
|
|
29
|
+
yield
|
|
30
|
+
ensure
|
|
31
|
+
ENV[ENV_KEY] = previous_value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
class Advancement < Ductwork::Record
|
|
5
|
+
belongs_to :process, class_name: "Ductwork::Process", optional: true
|
|
6
|
+
belongs_to :transition, class_name: "Ductwork::Transition"
|
|
7
|
+
|
|
8
|
+
validates :started_at, presence: true
|
|
9
|
+
|
|
10
|
+
CRASH_ERROR_KLASSES = %w[Ductwork::ProcessCrash Ductwork::ThreadCrash].freeze
|
|
11
|
+
|
|
12
|
+
def crash?
|
|
13
|
+
CRASH_ERROR_KLASSES.include?(error_klass)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def process_crashed!
|
|
17
|
+
Ductwork::Record.transaction do
|
|
18
|
+
rows_updated = self.class
|
|
19
|
+
.where(id: id, completed_at: nil)
|
|
20
|
+
.update_all(
|
|
21
|
+
completed_at: Time.current,
|
|
22
|
+
error_klass: "Ductwork::ProcessCrash",
|
|
23
|
+
error_message: "Reaped from orphaned process"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return if rows_updated.zero?
|
|
27
|
+
|
|
28
|
+
transition.branch.release!
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def thread_crashed!(expected_token)
|
|
33
|
+
Ductwork::Record.transaction do
|
|
34
|
+
rows_updated = self.class
|
|
35
|
+
.where(id: id, completed_at: nil)
|
|
36
|
+
.update_all(
|
|
37
|
+
completed_at: Time.current,
|
|
38
|
+
error_klass: "Ductwork::ThreadCrash",
|
|
39
|
+
error_message: "Advancement abandoned from a thread crash"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return if rows_updated.zero?
|
|
43
|
+
|
|
44
|
+
transition.branch.release!(expected_token)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
class Availability < Ductwork::Record
|
|
5
5
|
belongs_to :execution, class_name: "Ductwork::Execution"
|
|
6
|
+
belongs_to :process, class_name: "Ductwork::Process", optional: true
|
|
6
7
|
|
|
7
8
|
validates :started_at, presence: true
|
|
9
|
+
validates :pipeline_klass, presence: true
|
|
8
10
|
end
|
|
9
11
|
end
|