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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-clock-drift/SKILL.md +11 -0
  3. data/.claude/skills/audit-database-indexes/SKILL.md +11 -0
  4. data/.claude/skills/audit-database-support/SKILL.md +18 -0
  5. data/.claude/skills/audit-durability/SKILL.md +17 -0
  6. data/.saturnci/database.yml +1 -0
  7. data/CHANGELOG-PRO.md +60 -0
  8. data/CHANGELOG.md +144 -1
  9. data/CLAUDE.md +34 -0
  10. data/README.md +24 -14
  11. data/app/controllers/ductwork/application_controller.rb +3 -3
  12. data/app/controllers/ductwork/dashboards_controller.rb +1 -1
  13. data/app/controllers/ductwork/pipelines_controller.rb +4 -3
  14. data/app/helpers/ductwork/application_helper.rb +2 -2
  15. data/app/views/ductwork/dashboards/show.html.erb +12 -12
  16. data/app/views/ductwork/pipelines/index.html.erb +12 -12
  17. data/app/views/ductwork/pipelines/show.html.erb +13 -13
  18. data/app/views/ductwork/step_errors/index.html.erb +2 -2
  19. data/lib/ductwork/branch_claim.rb +185 -0
  20. data/lib/ductwork/cli.rb +73 -24
  21. data/lib/ductwork/configuration.rb +73 -3
  22. data/lib/ductwork/context.rb +17 -19
  23. data/lib/ductwork/database_clock.rb +84 -0
  24. data/lib/ductwork/dsl/branch_builder.rb +6 -1
  25. data/lib/ductwork/dsl/definition_builder.rb +24 -1
  26. data/lib/ductwork/{job_claim.rb → execution_claim.rb} +7 -6
  27. data/lib/ductwork/fault_injection.rb +35 -0
  28. data/lib/ductwork/migration_helper.rb +1 -1
  29. data/lib/ductwork/models/advancement.rb +48 -0
  30. data/lib/ductwork/models/attempt.rb +9 -0
  31. data/lib/ductwork/models/availability.rb +2 -0
  32. data/lib/ductwork/models/branch.rb +800 -0
  33. data/lib/ductwork/models/branch_link.rb +10 -0
  34. data/lib/ductwork/models/execution.rb +199 -2
  35. data/lib/ductwork/models/job.rb +8 -121
  36. data/lib/ductwork/models/pipeline.rb +150 -272
  37. data/lib/ductwork/models/process.rb +177 -5
  38. data/lib/ductwork/models/result.rb +2 -1
  39. data/lib/ductwork/models/run.rb +119 -1
  40. data/lib/ductwork/models/step.rb +26 -6
  41. data/lib/ductwork/models/transition.rb +15 -0
  42. data/lib/ductwork/models/tuple.rb +1 -4
  43. data/lib/ductwork/models/workflow.rb +3 -0
  44. data/lib/ductwork/optimistic_locking_execution_claim.rb +97 -0
  45. data/lib/ductwork/processes/health_check.rb +87 -0
  46. data/lib/ductwork/processes/job_worker.rb +86 -20
  47. data/lib/ductwork/processes/job_worker_runner.rb +25 -37
  48. data/lib/ductwork/processes/pipeline_advancer.rb +48 -76
  49. data/lib/ductwork/processes/pipeline_advancer_runner.rb +34 -44
  50. data/lib/ductwork/processes/process_supervisor.rb +87 -10
  51. data/lib/ductwork/processes/thread_supervisor.rb +55 -21
  52. data/lib/ductwork/processes/thread_supervisor_runner.rb +8 -5
  53. data/lib/ductwork/processes/worker_health_check.rb +55 -0
  54. data/lib/ductwork/row_locking_execution_claim.rb +77 -0
  55. data/lib/ductwork/testing/rspec.rb +28 -11
  56. data/lib/ductwork/version.rb +1 -1
  57. data/lib/ductwork.rb +5 -0
  58. data/lib/generators/ductwork/install/install_generator.rb +14 -4
  59. data/lib/generators/ductwork/install/templates/config/ductwork.yml +4 -0
  60. data/lib/generators/ductwork/install/templates/db/create_ductwork_advancements.rb +29 -0
  61. data/lib/generators/ductwork/install/templates/db/create_ductwork_attempts.rb +20 -0
  62. data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +18 -5
  63. data/lib/generators/ductwork/install/templates/db/create_ductwork_branch_links.rb +27 -0
  64. data/lib/generators/ductwork/install/templates/db/create_ductwork_branches.rb +37 -0
  65. data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +8 -1
  66. data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +0 -8
  67. data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -0
  68. data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +1 -0
  69. data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +45 -6
  70. data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +20 -5
  71. data/lib/generators/ductwork/install/templates/db/create_ductwork_transitions.rb +43 -0
  72. data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +3 -3
  73. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_advancements.rb +7 -0
  74. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_executions.rb +14 -0
  75. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_results.rb +7 -0
  76. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_runs.rb +9 -0
  77. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_transitions.rb +16 -0
  78. data/lib/generators/ductwork/update/templates/db/add_pipeline_started_index_to_ductwork_runs.rb +7 -0
  79. data/lib/generators/ductwork/update/templates/db/add_process_id_to_ductwork_executions.rb +35 -0
  80. data/lib/generators/ductwork/update/templates/db/add_role_to_ductwork_processes.rb +20 -0
  81. data/lib/generators/ductwork/update/templates/db/associate_branches_to_runs.rb +74 -0
  82. data/lib/generators/ductwork/update/templates/db/associate_steps_to_branches.rb +24 -0
  83. data/lib/generators/ductwork/update/templates/db/associate_steps_to_runs.rb +72 -0
  84. data/lib/generators/ductwork/update/templates/db/associate_tuples_to_runs.rb +73 -0
  85. data/lib/generators/ductwork/update/templates/db/backfill_branch_ids_on_steps.rb +21 -0
  86. data/lib/generators/ductwork/update/templates/db/create_ductwork_advancements.rb +28 -0
  87. data/lib/generators/ductwork/update/templates/db/create_ductwork_branch_links.rb +27 -0
  88. data/lib/generators/ductwork/update/templates/db/create_ductwork_branches.rb +37 -0
  89. data/lib/generators/ductwork/update/templates/db/create_ductwork_runs.rb +55 -0
  90. data/lib/generators/ductwork/update/templates/db/create_ductwork_transitions.rb +32 -0
  91. data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +11 -4
  92. data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +5 -0
  93. data/lib/generators/ductwork/update/templates/db/rename_runs_to_attempts.rb +7 -0
  94. data/lib/generators/ductwork/update/templates/db/update_process_associations.rb +29 -0
  95. data/lib/generators/ductwork/update/update_generator.rb +99 -0
  96. metadata +47 -6
  97. data/lib/ductwork/optimistic_locking_job_claim.rb +0 -88
  98. 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 JobClaim
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
- RowLockingJobClaim
13
+ RowLockingExecutionClaim
13
14
  else
14
- OptimisticLockingJobClaim
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
@@ -36,7 +36,7 @@ module Ductwork
36
36
  end
37
37
 
38
38
  def mysql?
39
- connection.adapter_name.match?(/mysql/i)
39
+ connection.adapter_name.match?(/mysql|trilogy/i)
40
40
  end
41
41
  end
42
42
  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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Attempt < Ductwork::Record
5
+ belongs_to :execution, class_name: "Ductwork::Execution"
6
+
7
+ validates :started_at, presence: true
8
+ end
9
+ 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