ductwork 0.1.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +168 -0
  4. data/README.md +154 -0
  5. data/Rakefile +10 -0
  6. data/app/models/ductwork/availability.rb +9 -0
  7. data/app/models/ductwork/execution.rb +13 -0
  8. data/app/models/ductwork/job.rb +181 -0
  9. data/app/models/ductwork/pipeline.rb +195 -0
  10. data/app/models/ductwork/process.rb +19 -0
  11. data/app/models/ductwork/record.rb +15 -0
  12. data/app/models/ductwork/result.rb +13 -0
  13. data/app/models/ductwork/run.rb +9 -0
  14. data/app/models/ductwork/step.rb +27 -0
  15. data/lib/ductwork/cli.rb +48 -0
  16. data/lib/ductwork/configuration.rb +145 -0
  17. data/lib/ductwork/dsl/branch_builder.rb +102 -0
  18. data/lib/ductwork/dsl/definition_builder.rb +153 -0
  19. data/lib/ductwork/engine.rb +14 -0
  20. data/lib/ductwork/machine_identifier.rb +11 -0
  21. data/lib/ductwork/processes/job_worker.rb +71 -0
  22. data/lib/ductwork/processes/job_worker_runner.rb +164 -0
  23. data/lib/ductwork/processes/pipeline_advancer.rb +91 -0
  24. data/lib/ductwork/processes/pipeline_advancer_runner.rb +169 -0
  25. data/lib/ductwork/processes/supervisor.rb +160 -0
  26. data/lib/ductwork/processes/supervisor_runner.rb +35 -0
  27. data/lib/ductwork/running_context.rb +22 -0
  28. data/lib/ductwork/testing/helpers.rb +18 -0
  29. data/lib/ductwork/testing/minitest.rb +8 -0
  30. data/lib/ductwork/testing/rspec.rb +63 -0
  31. data/lib/ductwork/testing.rb +15 -0
  32. data/lib/ductwork/version.rb +5 -0
  33. data/lib/ductwork.rb +77 -0
  34. data/lib/generators/ductwork/install/USAGE +11 -0
  35. data/lib/generators/ductwork/install/install_generator.rb +36 -0
  36. data/lib/generators/ductwork/install/templates/bin/ductwork +8 -0
  37. data/lib/generators/ductwork/install/templates/config/ductwork.yml +25 -0
  38. data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +16 -0
  39. data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +14 -0
  40. data/lib/generators/ductwork/install/templates/db/create_ductwork_jobs.rb +17 -0
  41. data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +19 -0
  42. data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +14 -0
  43. data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +14 -0
  44. data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +12 -0
  45. data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +17 -0
  46. metadata +165 -0
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Pipeline < Ductwork::Record # rubocop:todo Metrics/ClassLength
5
+ has_many :steps, class_name: "Ductwork::Step", foreign_key: "pipeline_id", dependent: :destroy
6
+
7
+ validates :klass, presence: true
8
+ validates :definition, presence: true
9
+ validates :definition_sha1, presence: true
10
+ validates :status, presence: true
11
+ validates :triggered_at, presence: true
12
+ validates :last_advanced_at, presence: true
13
+
14
+ enum :status,
15
+ pending: "pending",
16
+ in_progress: "in_progress",
17
+ halted: "halted",
18
+ completed: "completed"
19
+
20
+ def self.inherited(subclass)
21
+ super
22
+
23
+ subclass.class_eval do
24
+ default_scope { where(klass: name.to_s) }
25
+ end
26
+ end
27
+
28
+ class DefinitionError < StandardError; end
29
+
30
+ class << self
31
+ attr_reader :pipeline_definition
32
+
33
+ def define(&block)
34
+ if !block_given?
35
+ raise DefinitionError, "Definition block must be given"
36
+ end
37
+
38
+ if pipeline_definition
39
+ raise DefinitionError, "Pipeline has already been defined"
40
+ end
41
+
42
+ builder = Ductwork::DSL::DefinitionBuilder.new
43
+
44
+ block.call(builder)
45
+
46
+ @pipeline_definition = builder.complete
47
+
48
+ Ductwork.defined_pipelines << name.to_s
49
+ end
50
+
51
+ def trigger(*args)
52
+ if pipeline_definition.nil?
53
+ raise DefinitionError, "Pipeline must be defined before triggering"
54
+ end
55
+
56
+ step_klass = pipeline_definition.dig(:nodes, 0)
57
+ definition = JSON.dump(pipeline_definition)
58
+
59
+ Record.transaction do
60
+ pipeline = create!(
61
+ klass: name.to_s,
62
+ status: :in_progress,
63
+ definition: definition,
64
+ definition_sha1: Digest::SHA1.hexdigest(definition),
65
+ triggered_at: Time.current,
66
+ last_advanced_at: Time.current
67
+ )
68
+ step = pipeline.steps.create!(
69
+ klass: step_klass,
70
+ status: :in_progress,
71
+ step_type: :start,
72
+ started_at: Time.current
73
+ )
74
+ Ductwork::Job.enqueue(step, *args)
75
+
76
+ pipeline
77
+ end
78
+ end
79
+ end
80
+
81
+ def advance!
82
+ step = steps.advancing.take
83
+ edge = if step.present?
84
+ parsed_definition.dig(:edges, step.klass, 0)
85
+ end
86
+
87
+ Ductwork::Record.transaction do
88
+ steps.advancing.update!(status: :completed, completed_at: Time.current)
89
+
90
+ if edge.nil?
91
+ conditionally_complete_pipeline
92
+ else
93
+ advance_to_next_step_by_type(edge, step)
94
+ end
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def create_step_and_enqueue_job(klass:, step_type:, input_arg:)
101
+ status = :in_progress
102
+ started_at = Time.current
103
+ next_step = steps.create!(klass:, status:, step_type:, started_at:)
104
+ Ductwork::Job.enqueue(next_step, input_arg)
105
+ end
106
+
107
+ def parsed_definition
108
+ @parsed_definition ||= JSON.parse(definition).with_indifferent_access
109
+ end
110
+
111
+ def conditionally_complete_pipeline
112
+ if steps.where(status: %w[in_progress pending]).none?
113
+ update!(status: :completed, completed_at: Time.current)
114
+ end
115
+ end
116
+
117
+ def advance_to_next_step_by_type(edge, step)
118
+ # NOTE: "chain" is used by ActiveRecord so we have to call
119
+ # this enum value "default" :sad:
120
+ step_type = edge[:type] == "chain" ? "default" : edge[:type]
121
+
122
+ if step_type.in?(%w[default divide])
123
+ advance_to_next_steps(step_type, step, edge)
124
+ elsif step_type == "combine"
125
+ combine_next_steps(step_type, edge)
126
+ elsif step_type == "expand"
127
+ expand_to_next_steps(step_type, step, edge)
128
+ elsif step_type == "collapse"
129
+ collapse_next_steps(step_type, step, edge)
130
+ else
131
+ Ductwork.configuration.logger.error(
132
+ msg: "Invalid step type",
133
+ role: :pipeline_advancer
134
+ )
135
+ end
136
+ end
137
+
138
+ def advance_to_next_steps(step_type, step, edge)
139
+ edge[:to].each do |to_klass|
140
+ next_step = steps.create!(
141
+ klass: to_klass,
142
+ status: :in_progress,
143
+ step_type: step_type,
144
+ started_at: Time.current
145
+ )
146
+ Ductwork::Job.enqueue(next_step, step.job.return_value)
147
+ end
148
+ end
149
+
150
+ def combine_next_steps(step_type, edge)
151
+ previous_klasses = parsed_definition[:edges].select do |_, v|
152
+ v.dig(0, :to, 0) == edge[:to].sole && v.dig(0, :type) == "combine"
153
+ end.keys
154
+
155
+ if steps.not_completed.where(klass: previous_klasses).none?
156
+ input_arg = Job.where(
157
+ step: steps.completed.where(klass: previous_klasses)
158
+ ).map(&:return_value)
159
+ create_step_and_enqueue_job(
160
+ klass: edge[:to].sole,
161
+ step_type: step_type,
162
+ input_arg: input_arg
163
+ )
164
+ end
165
+ end
166
+
167
+ def expand_to_next_steps(step_type, step, edge)
168
+ step.job.return_value.each do |input_arg|
169
+ create_step_and_enqueue_job(
170
+ klass: edge[:to].sole,
171
+ step_type: step_type,
172
+ input_arg: input_arg
173
+ )
174
+ end
175
+ end
176
+
177
+ def collapse_next_steps(step_type, step, edge)
178
+ if steps.not_completed.where(klass: step.klass).none?
179
+ input_arg = Job.where(
180
+ step: steps.completed.where(klass: step.klass)
181
+ ).map(&:return_value)
182
+ create_step_and_enqueue_job(
183
+ klass: edge[:to].sole,
184
+ step_type: step_type,
185
+ input_arg: input_arg
186
+ )
187
+ else
188
+ Ductwork.configuration.logger.debug(
189
+ msg: "Not all expanded steps have completed",
190
+ role: :pipeline_advancer
191
+ )
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Process < Ductwork::Record
5
+ class NotFoundError < StandardError; end
6
+
7
+ validates :pid, uniqueness: { scope: :machine_identifier }
8
+
9
+ def self.report_heartbeat!
10
+ pid = ::Process.pid
11
+ machine_identifier = Ductwork::MachineIdentifier.fetch
12
+
13
+ find_by!(pid:, machine_identifier:)
14
+ .update!(last_heartbeat_at: Time.current)
15
+ rescue ActiveRecord::RecordNotFound
16
+ raise NotFoundError, "Process #{pid} not found"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Record < ActiveRecord::Base
5
+ self.abstract_class = true
6
+
7
+ if Ductwork.configuration.database.present?
8
+ connects_to(database: { writing: Ductwork.configuration.database.to_sym })
9
+ end
10
+
11
+ def self.table_name_prefix
12
+ "ductwork_"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Result < Ductwork::Record
5
+ belongs_to :execution, class_name: "Ductwork::Execution"
6
+
7
+ validates :result_type, presence: true
8
+
9
+ enum :result_type,
10
+ success: "success",
11
+ failure: "failure"
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Run < Ductwork::Record
5
+ belongs_to :execution, class_name: "Ductwork::Execution"
6
+
7
+ validates :started_at, presence: true
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Step < Ductwork::Record
5
+ belongs_to :pipeline, class_name: "Ductwork::Pipeline"
6
+ has_one :job, class_name: "Ductwork::Job", foreign_key: "step_id"
7
+
8
+ validates :klass, presence: true
9
+ validates :status, presence: true
10
+ validates :step_type, presence: true
11
+
12
+ enum :status,
13
+ pending: "pending",
14
+ in_progress: "in_progress",
15
+ advancing: "advancing",
16
+ failed: "failed",
17
+ completed: "completed"
18
+
19
+ enum :step_type,
20
+ start: "start",
21
+ default: "default", # `chain` is used by AR
22
+ divide: "divide",
23
+ combine: "combine",
24
+ expand: "expand",
25
+ collapse: "collapse"
26
+ end
27
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Ductwork
6
+ class CLI
7
+ class << self
8
+ def start!(args)
9
+ options = parse_options(args)
10
+ Ductwork.configuration = Configuration.new(**options)
11
+ Ductwork.configuration.logger = if Ductwork.configuration.logger_source == "rails"
12
+ Rails.logger
13
+ else
14
+ Ductwork::Configuration::DEFAULT_LOGGER
15
+ end
16
+ Ductwork.configuration.logger.level = Ductwork.configuration.logger_level
17
+
18
+ Ductwork::Processes::SupervisorRunner.start!
19
+ end
20
+
21
+ private
22
+
23
+ def parse_options(args)
24
+ options = {}
25
+
26
+ OptionParser.new do |op|
27
+ op.banner = "ductwork [options]"
28
+
29
+ op.on("-c", "--config PATH", "path to YAML config file") do |arg|
30
+ options[:path] = arg
31
+ end
32
+
33
+ op.on("-h", "--help", "Prints this help") do
34
+ puts op
35
+ exit
36
+ end
37
+
38
+ op.on("-v", "--version", "Prints the version") do
39
+ puts "Ductwork #{Ductwork::VERSION}"
40
+ exit
41
+ end
42
+ end.parse!(args)
43
+
44
+ options
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Configuration
5
+ DEFAULT_ENV = :default
6
+ DEFAULT_FILE_PATH = "config/ductwork.yml"
7
+ DEFAULT_JOB_WORKER_COUNT = 5 # threads
8
+ DEFAULT_JOB_WORKER_MAX_RETRY = 3 # attempts
9
+ DEFAULT_JOB_WORKER_POLLING_TIMEOUT = 1 # second
10
+ DEFAULT_JOB_WORKER_SHUTDOWN_TIMEOUT = 20 # seconds
11
+ DEFAULT_LOGGER_LEVEL = ::Logger::INFO
12
+ DEFAULT_LOGGER_SOURCE = "default" # `Logger` instance writing to STDOUT
13
+ DEFAULT_PIPELINE_POLLING_TIMEOUT = 1 # second
14
+ DEFAULT_PIPELINE_SHUTDOWN_TIMEOUT = 20 # seconds
15
+ DEFAULT_SUPERVISOR_POLLING_TIMEOUT = 1 # second
16
+ DEFAULT_SUPERVISOR_SHUTDOWN_TIMEOUT = 30 # seconds
17
+ DEFAULT_LOGGER = ::Logger.new($stdout)
18
+ PIPELINES_WILDCARD = "*"
19
+
20
+ attr_accessor :logger
21
+ attr_writer :job_worker_polling_timeout, :job_worker_shutdown_timeout,
22
+ :job_worker_max_retry,
23
+ :logger_level,
24
+ :pipeline_polling_timeout, :pipeline_shutdown_timeout,
25
+ :supervisor_polling_timeout, :supervisor_shutdown_timeout
26
+
27
+ def initialize(path: DEFAULT_FILE_PATH)
28
+ full_path = Pathname.new(path)
29
+ data = ActiveSupport::ConfigurationFile.parse(full_path).deep_symbolize_keys
30
+ env = defined?(Rails) ? Rails.env.to_sym : DEFAULT_ENV
31
+ @config = data[env]
32
+ rescue Errno::ENOENT
33
+ @config = {}
34
+ end
35
+
36
+ def pipelines
37
+ raw_pipelines = config[:pipelines] || []
38
+
39
+ if raw_pipelines == PIPELINES_WILDCARD
40
+ Dir
41
+ .glob("**/*.rb", base: "app/pipelines")
42
+ .map { |path| path.delete_suffix(".rb").camelize }
43
+ else
44
+ raw_pipelines.map(&:strip)
45
+ end
46
+ end
47
+
48
+ def database
49
+ config[:database]
50
+ end
51
+
52
+ def job_worker_count(pipeline)
53
+ raw_count = config.dig(:job_worker, :count) || DEFAULT_JOB_WORKER_COUNT
54
+
55
+ if raw_count.is_a?(Hash)
56
+ raw_count[pipeline.to_sym]
57
+ else
58
+ raw_count
59
+ end
60
+ end
61
+
62
+ def job_worker_max_retry
63
+ @job_worker_max_retry ||= fetch_job_worker_max_retry
64
+ end
65
+
66
+ def job_worker_polling_timeout
67
+ @job_worker_polling_timeout ||= fetch_job_worker_polling_timeout
68
+ end
69
+
70
+ def job_worker_shutdown_timeout
71
+ @job_worker_shutdown_timeout ||= fetch_job_worker_shutdown_timeout
72
+ end
73
+
74
+ def logger_level
75
+ @logger_level ||= fetch_logger_level
76
+ end
77
+
78
+ def logger_source
79
+ @logger_source ||= fetch_logger_source
80
+ end
81
+
82
+ def pipeline_polling_timeout
83
+ @pipeline_polling_timeout ||= fetch_pipeline_polling_timeout
84
+ end
85
+
86
+ def pipeline_shutdown_timeout
87
+ @pipeline_shutdown_timeout ||= fetch_pipeline_shutdown_timeout
88
+ end
89
+
90
+ def supervisor_polling_timeout
91
+ @supervisor_polling_timeout ||= fetch_supervisor_polling_timeout
92
+ end
93
+
94
+ def supervisor_shutdown_timeout
95
+ @supervisor_shutdown_timeout ||= fetch_supervisor_shutdown_timeout
96
+ end
97
+
98
+ private
99
+
100
+ attr_reader :config
101
+
102
+ def fetch_job_worker_max_retry
103
+ config.dig(:job_worker, :max_retry) ||
104
+ DEFAULT_JOB_WORKER_MAX_RETRY
105
+ end
106
+
107
+ def fetch_job_worker_polling_timeout
108
+ config.dig(:job_worker, :polling_timeout) ||
109
+ DEFAULT_JOB_WORKER_POLLING_TIMEOUT
110
+ end
111
+
112
+ def fetch_job_worker_shutdown_timeout
113
+ config.dig(:job_worker, :shutdown_timeout) ||
114
+ DEFAULT_JOB_WORKER_SHUTDOWN_TIMEOUT
115
+ end
116
+
117
+ def fetch_logger_level
118
+ config.dig(:logger, :level) || DEFAULT_LOGGER_LEVEL
119
+ end
120
+
121
+ def fetch_logger_source
122
+ config.dig(:logger, :source) || DEFAULT_LOGGER_SOURCE
123
+ end
124
+
125
+ def fetch_pipeline_polling_timeout
126
+ config.dig(:pipeline_advancer, :polling_timeout) ||
127
+ DEFAULT_PIPELINE_POLLING_TIMEOUT
128
+ end
129
+
130
+ def fetch_pipeline_shutdown_timeout
131
+ config.dig(:pipeline_advancer, :shutdown_timeout) ||
132
+ DEFAULT_PIPELINE_SHUTDOWN_TIMEOUT
133
+ end
134
+
135
+ def fetch_supervisor_polling_timeout
136
+ config.dig(:supervisor, :polling_timeout) ||
137
+ DEFAULT_SUPERVISOR_POLLING_TIMEOUT
138
+ end
139
+
140
+ def fetch_supervisor_shutdown_timeout
141
+ config.dig(:supervisor, :shutdown_timeout) ||
142
+ DEFAULT_SUPERVISOR_SHUTDOWN_TIMEOUT
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ module DSL
5
+ class BranchBuilder
6
+ class CollapseError < StandardError; end
7
+
8
+ attr_reader :last_node
9
+
10
+ def initialize(klass:, definition:)
11
+ @last_node = klass.name
12
+ @definition = definition
13
+ @expansions = 0
14
+ end
15
+
16
+ def chain(next_klass)
17
+ definition[:edges][last_node] << {
18
+ to: [next_klass.name],
19
+ type: :chain,
20
+ }
21
+
22
+ definition[:nodes].push(next_klass.name)
23
+ definition[:edges][next_klass.name] ||= []
24
+ @last_node = next_klass.name
25
+
26
+ self
27
+ end
28
+
29
+ def divide(to:)
30
+ definition[:edges][last_node] << {
31
+ to: to.map(&:name),
32
+ type: :divide,
33
+ }
34
+
35
+ definition[:nodes].push(*to.map(&:name))
36
+ sub_branches = to.map do |klass|
37
+ definition[:edges][klass.name] ||= []
38
+
39
+ Ductwork::DSL::BranchBuilder.new(klass:, definition:)
40
+ end
41
+
42
+ yield sub_branches
43
+
44
+ self
45
+ end
46
+
47
+ def combine(*branch_builders, into:)
48
+ definition[:edges][last_node] << {
49
+ to: [into.name],
50
+ type: :combine,
51
+ }
52
+ branch_builders.each do |branch|
53
+ definition[:edges][branch.last_node] << {
54
+ to: [into.name],
55
+ type: :combine,
56
+ }
57
+ end
58
+ definition[:nodes].push(into.name)
59
+ definition[:edges][into.name] ||= []
60
+
61
+ self
62
+ end
63
+
64
+ def expand(to:)
65
+ definition[:edges][last_node] << {
66
+ to: [to.name],
67
+ type: :expand,
68
+ }
69
+
70
+ definition[:nodes].push(to.name)
71
+ definition[:edges][to.name] ||= []
72
+ @last_node = to.name
73
+ @expansions += 1
74
+
75
+ self
76
+ end
77
+
78
+ def collapse(into:)
79
+ if expansions.zero?
80
+ raise CollapseError,
81
+ "Must expand pipeline definition before collapsing steps"
82
+ end
83
+
84
+ definition[:edges][last_node] << {
85
+ to: [into.name],
86
+ type: :collapse,
87
+ }
88
+
89
+ definition[:nodes].push(into.name)
90
+ definition[:edges][into.name] ||= []
91
+ @last_node = into.name
92
+ @expansions -= 1
93
+
94
+ self
95
+ end
96
+
97
+ private
98
+
99
+ attr_reader :definition, :expansions
100
+ end
101
+ end
102
+ end