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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ module Processes
5
+ class Supervisor
6
+ attr_reader :workers
7
+
8
+ def initialize
9
+ @running = true
10
+ @workers = []
11
+
12
+ run_hooks_for(:start)
13
+
14
+ Signal.trap(:INT) { @running = false }
15
+ Signal.trap(:TERM) { @running = false }
16
+ Signal.trap(:TTIN) { puts "No threads to dump" }
17
+ end
18
+
19
+ def add_worker(metadata: {}, &block)
20
+ pid = fork do
21
+ block.call(metadata)
22
+ end
23
+
24
+ workers << { metadata:, pid:, block: }
25
+ logger.debug(
26
+ msg: "Started child process (#{pid}) with metadata #{metadata}",
27
+ pid: pid
28
+ )
29
+ end
30
+
31
+ def run
32
+ logger.debug(msg: "Entering main work loop", role: :supervisor, pid: ::Process.pid)
33
+
34
+ while running
35
+ sleep(Ductwork.configuration.supervisor_polling_timeout)
36
+ check_workers
37
+ end
38
+
39
+ shutdown
40
+ end
41
+
42
+ def shutdown
43
+ @running = false
44
+
45
+ logger.debug(msg: "Beginning shutdown", role: :supervisor)
46
+ terminate_gracefully
47
+ wait_for_workers_to_exit
48
+ terminate_immediately
49
+ run_hooks_for(:stop)
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :running
55
+
56
+ def check_workers
57
+ logger.debug(msg: "Checking workers are alive", role: :supervisor)
58
+
59
+ workers.each do |worker|
60
+ if process_dead?(worker[:pid])
61
+ old_pid = worker[:pid]
62
+ new_pid = fork do
63
+ worker[:block].call(worker[:metadata])
64
+ end
65
+ worker[:pid] = new_pid
66
+ logger.debug(
67
+ msg: "Restarted process (#{old_pid}) as (#{new_pid})",
68
+ role: :supervisor,
69
+ old_pid: old_pid,
70
+ new_pid: new_pid
71
+ )
72
+ end
73
+ end
74
+
75
+ logger.debug(msg: "All workers are alive or revived", role: :supervisor)
76
+ end
77
+
78
+ def terminate_gracefully
79
+ workers.each do |worker|
80
+ logger.debug(
81
+ msg: "Sending TERM signal to process (#{worker[:pid]})",
82
+ role: :supervisor,
83
+ pid: worker[:pid],
84
+ signal: :TERM
85
+ )
86
+ ::Process.kill(:TERM, worker[:pid])
87
+ end
88
+ end
89
+
90
+ def wait_for_workers_to_exit
91
+ deadline = now + Ductwork.configuration.supervisor_shutdown_timeout
92
+
93
+ while workers.any? && now < deadline
94
+ sleep(0.1)
95
+ workers.each_with_index do |worker, index|
96
+ if ::Process.wait(worker[:pid], ::Process::WNOHANG)
97
+ workers[index] = nil
98
+ logger.debug(
99
+ msg: "Child process (#{worker[:pid]}) stopped successfully",
100
+ role: :supervisor,
101
+ pid: worker[:pid]
102
+ )
103
+ end
104
+ end
105
+ @workers = workers.compact
106
+ end
107
+ end
108
+
109
+ def terminate_immediately
110
+ workers.each_with_index do |worker, index|
111
+ logger.debug(
112
+ msg: "Sending KILL signal to process (#{worker[:pid]})",
113
+ role: :supervisor,
114
+ pid: worker[:pid],
115
+ signal: :KILL
116
+ )
117
+ ::Process.kill(:KILL, worker[:pid])
118
+ ::Process.wait(worker[:pid])
119
+ workers[index] = nil
120
+ logger.debug(
121
+ msg: "Child process (#{worker[:pid]}) killed after timeout",
122
+ role: :supervisor,
123
+ pid: worker[:pid]
124
+ )
125
+ rescue Errno::ESRCH, Errno::ECHILD
126
+ # no-op because process is already dead
127
+ end
128
+
129
+ @workers = workers.compact
130
+ end
131
+
132
+ def process_dead?(pid)
133
+ machine_identifier = Ductwork::MachineIdentifier.fetch
134
+
135
+ Ductwork.wrap_with_app_executor do
136
+ Ductwork::Process
137
+ .where(pid:, machine_identifier:)
138
+ .where("last_heartbeat_at < ?", 5.minutes.ago)
139
+ .exists?
140
+ end
141
+ end
142
+
143
+ def run_hooks_for(event)
144
+ Ductwork.hooks[:supervisor].fetch(event, []).each do |block|
145
+ Ductwork.wrap_with_app_executor do
146
+ block.call(self)
147
+ end
148
+ end
149
+ end
150
+
151
+ def now
152
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
153
+ end
154
+
155
+ def logger
156
+ Ductwork.configuration.logger
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ module Processes
5
+ class SupervisorRunner
6
+ def self.start!
7
+ supervisor = Ductwork::Processes::Supervisor.new
8
+ pipelines_to_advance = Ductwork.configuration.pipelines
9
+ logger = Ductwork.configuration.logger
10
+
11
+ supervisor.add_worker(metadata: { pipelines: pipelines_to_advance }) do
12
+ logger.debug(
13
+ msg: "Starting Pipeline Advancer process",
14
+ role: :supervisor_runner
15
+ )
16
+ Ductwork::Processes::PipelineAdvancerRunner
17
+ .new(*pipelines_to_advance).run
18
+ end
19
+
20
+ pipelines_to_advance.each do |pipeline|
21
+ supervisor.add_worker(metadata: { pipeline: }) do
22
+ logger.debug(
23
+ msg: "Starting Job Worker Runner process",
24
+ role: :supervisor_runner,
25
+ pipeline: pipeline
26
+ )
27
+ Ductwork::Processes::JobWorkerRunner.new(pipeline).run
28
+ end
29
+ end
30
+
31
+ supervisor.run
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class RunningContext
5
+ def initialize
6
+ @mutex = Mutex.new
7
+ @running = true
8
+ end
9
+
10
+ def running?
11
+ mutex.synchronize { running }
12
+ end
13
+
14
+ def shutdown!
15
+ @running = false
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :mutex, :running
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ module Testing
5
+ module Helpers
6
+ def pipelines_created_around(&block)
7
+ before_ids = Ductwork::Pipeline.ids
8
+
9
+ block.call
10
+
11
+ after_ids = Ductwork::Pipeline.ids
12
+ ids_delta = after_ids - before_ids
13
+
14
+ Ductwork::Pipeline.where(id: ids_delta)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ module Testing
5
+ module Minitest
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec::Matchers.define(:have_triggered_pipeline) do |expected|
4
+ include Ductwork::Testing::Helpers
5
+
6
+ supports_block_expectations
7
+
8
+ match do |block|
9
+ pipelines = pipelines_created_around(&block)
10
+ delta = pipelines.count
11
+ expected_count = count || 1
12
+
13
+ if delta == expected_count
14
+ pipelines.pluck(:klass).uniq.sort == Array(expected).map(&:name).sort
15
+ else
16
+ @failure_result = if delta.zero?
17
+ :none
18
+ elsif delta > 1
19
+ :too_many
20
+ else
21
+ :other
22
+ end
23
+
24
+ false
25
+ end
26
+ end
27
+
28
+ chain :exactly, :count
29
+
30
+ chain :times do # rubocop:disable Lint/EmptyBlock
31
+ end
32
+
33
+ failure_message do |actual|
34
+ case @failure_result
35
+ when :none
36
+ "expected to trigger pipeline #{expected} but triggered none"
37
+ when :too_many
38
+ "expected to trigger pipeline #{expected} but triggered more than one"
39
+ when :other
40
+ "expected to trigger pipeline #{expected} but triggered #{actual}"
41
+ else
42
+ "expected to trigger pipeline #{expected} but did not"
43
+ end
44
+ end
45
+ end
46
+
47
+ RSpec::Matchers.define(:have_triggered_pipelines) do |*expected|
48
+ include Ductwork::Testing::Helpers
49
+
50
+ supports_block_expectations
51
+
52
+ match do |block|
53
+ pipelines = pipelines_created_around(&block)
54
+
55
+ pipelines.map(&:klass).sort == expected.map(&:name).sort
56
+ end
57
+
58
+ failure_message do |_actual|
59
+ pipeline_names = expected.map(&:name).join(", ")
60
+
61
+ "expected to trigger pipelines: #{pipeline_names} but did not"
62
+ end
63
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ductwork/testing/helpers"
4
+
5
+ module Ductwork
6
+ module Testing
7
+ if defined?(RSpec)
8
+ require "ductwork/testing/rspec"
9
+ elsif defined?(Minitest)
10
+ require "ductwork/testing/minitest"
11
+ else
12
+ raise "Testing framework is not supported"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ductwork.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support"
5
+ require "logger"
6
+ require "rails/engine"
7
+ require "securerandom"
8
+ require "zeitwerk"
9
+
10
+ require "ductwork/engine"
11
+
12
+ loader = Zeitwerk::Loader.for_gem
13
+ loader.inflector.inflect("cli" => "CLI")
14
+ loader.inflector.inflect("dsl" => "DSL")
15
+ loader.ignore("#{__dir__}/generators")
16
+ loader.ignore("#{__dir__}/ductwork/testing")
17
+ loader.ignore("#{__dir__}/ductwork/testing.rb")
18
+ loader.setup
19
+
20
+ module Ductwork
21
+ class << self
22
+ attr_accessor :app_executor, :configuration
23
+ attr_writer :defined_pipelines, :hooks
24
+
25
+ def wrap_with_app_executor(&block)
26
+ if app_executor.present?
27
+ app_executor.wrap(&block)
28
+ else
29
+ yield
30
+ end
31
+ end
32
+
33
+ def hooks
34
+ @hooks ||= {
35
+ supervisor: { start: [], stop: [] },
36
+ advancer: { start: [], stop: [] },
37
+ worker: { start: [], stop: [] },
38
+ }
39
+ end
40
+
41
+ def on_supervisor_start(&block)
42
+ add_lifecycle_hook(:supervisor, :start, block)
43
+ end
44
+
45
+ def on_supervisor_stop(&block)
46
+ add_lifecycle_hook(:supervisor, :stop, block)
47
+ end
48
+
49
+ def on_advancer_start(&block)
50
+ add_lifecycle_hook(:advancer, :start, block)
51
+ end
52
+
53
+ def on_advancer_stop(&block)
54
+ add_lifecycle_hook(:advancer, :stop, block)
55
+ end
56
+
57
+ def on_worker_start(&block)
58
+ add_lifecycle_hook(:worker, :start, block)
59
+ end
60
+
61
+ def on_worker_stop(&block)
62
+ add_lifecycle_hook(:worker, :stop, block)
63
+ end
64
+
65
+ def defined_pipelines
66
+ @defined_pipelines ||= []
67
+ end
68
+
69
+ private
70
+
71
+ def add_lifecycle_hook(target, event, block)
72
+ hooks[target] ||= {}
73
+ hooks[target][event] ||= []
74
+ hooks[target][event].push(block)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,11 @@
1
+ Description:
2
+ Installs necessary ductwork files
3
+
4
+ Example:
5
+ bin/rails generate ductwork:install
6
+
7
+ This will perform the following:
8
+ Adds migrations
9
+ Adds default configurations
10
+ Installs bin/ductwork binstub to start the supervisor
11
+
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/migration"
4
+ require "rails/generators/active_record/migration"
5
+
6
+ module Ductwork
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include ActiveRecord::Generators::Migration
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ def create_files
13
+ template "config/ductwork.yml"
14
+ template "bin/ductwork"
15
+
16
+ migration_template "db/create_ductwork_pipelines.rb",
17
+ "db/migrate/create_ductwork_pipelines.rb"
18
+ migration_template "db/create_ductwork_steps.rb",
19
+ "db/migrate/create_ductwork_steps.rb"
20
+ migration_template "db/create_ductwork_jobs.rb",
21
+ "db/migrate/create_ductwork_jobs.rb"
22
+ migration_template "db/create_ductwork_executions.rb",
23
+ "db/migrate/create_ductwork_executions.rb"
24
+ migration_template "db/create_ductwork_availabilities.rb",
25
+ "db/migrate/create_ductwork_availabilities.rb"
26
+ migration_template "db/create_ductwork_runs.rb",
27
+ "db/migrate/create_ductwork_runs.rb"
28
+ migration_template "db/create_ductwork_results.rb",
29
+ "db/migrate/create_ductwork_results.rb"
30
+ migration_template "db/create_ductwork_processes.rb",
31
+ "db/migrate/create_ductwork_processes.rb"
32
+
33
+ chmod "bin/ductwork", 0o755 & ~File.umask, verbose: false
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require_relative "../config/environment"
6
+ require "ductwork/cli"
7
+
8
+ Ductwork::CLI.start!(ARGV)
@@ -0,0 +1,25 @@
1
+ default: &default
2
+ pipelines: "*"
3
+ logger:
4
+ level: 1
5
+ source: default
6
+ job_worker:
7
+ count: 5
8
+ max_retry: 3
9
+ polling_timeout: 1
10
+ shutdown_timeout: 20
11
+ pipeline_advancer:
12
+ polling_timeout: 1
13
+ shutdown_timeout: 20
14
+ supervisor:
15
+ polling_timeout: 1
16
+ shutdown_timeout: 30
17
+
18
+ development:
19
+ <<: *default
20
+
21
+ test:
22
+ <<: *default
23
+
24
+ production:
25
+ <<: *default
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkAvailabilities < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ductwork_availabilities do |table|
6
+ table.belongs_to :execution, index: false, null: false, foreign_key: { to_table: :ductwork_executions }
7
+ table.timestamp :started_at, null: false
8
+ table.timestamp :completed_at
9
+ table.integer :process_id
10
+ table.timestamps null: false
11
+ end
12
+
13
+ add_index :ductwork_availabilities, :execution_id, unique: true
14
+ add_index :ductwork_availabilities, %i[id process_id]
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkExecutions < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ductwork_executions do |table|
6
+ table.belongs_to :job, index: true, null: false, foreign_key: { to_table: :ductwork_jobs }
7
+ table.timestamp :started_at, null: false
8
+ table.timestamp :completed_at
9
+ table.integer :retry_count, null: false
10
+ table.integer :process_id
11
+ table.timestamps null: false
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkJobs < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ductwork_jobs do |table|
6
+ table.belongs_to :step, index: false, null: false, foreign_key: { to_table: :ductwork_steps }
7
+ table.string :klass, null: false
8
+ table.timestamp :started_at, null: false
9
+ table.timestamp :completed_at
10
+ table.string :input_args, null: false
11
+ table.string :output_payload
12
+ table.timestamps null: false
13
+ end
14
+
15
+ add_index :ductwork_jobs, :step_id, unique: true
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkPipelines < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ductwork_pipelines do |table|
6
+ table.string :klass, null: false
7
+ table.string :definition, null: false
8
+ table.string :definition_sha1, null: false
9
+ table.timestamp :triggered_at, null: false
10
+ table.timestamp :completed_at
11
+ table.timestamp :claimed_for_advancing_at
12
+ table.timestamp :last_advanced_at, null: false
13
+ table.string :status, null: false
14
+ table.timestamps null: false
15
+ end
16
+
17
+ add_index :ductwork_pipelines, :klass
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkProcesses < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ductwork_processes do |table|
6
+ table.integer :pid, null: false
7
+ table.string :machine_identifier, null: false
8
+ table.timestamp :last_heartbeat_at, null: false
9
+ table.timestamps null: false
10
+ end
11
+
12
+ add_index :ductwork_processes, %i[pid machine_identifier], unique: true
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkResults < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ductwork_results do |table|
6
+ table.belongs_to :execution, index: false, null: false, foreign_key: { to_table: :ductwork_executions }
7
+ table.string :result_type, null: false
8
+ table.string :error_klass
9
+ table.string :error_message
10
+ table.text :error_backtrace
11
+ table.timestamps null: false
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkRuns < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ductwork_runs do |table|
6
+ table.belongs_to :execution, index: false, null: false, foreign_key: { to_table: :ductwork_executions }
7
+ table.timestamp :started_at, null: false
8
+ table.timestamp :completed_at
9
+ table.timestamps null: false
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkSteps < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ductwork_steps do |table|
6
+ table.belongs_to :pipeline, index: true, null: false, foreign_key: { to_table: :ductwork_pipelines }
7
+ table.string :klass, null: false
8
+ table.string :step_type, null: false
9
+ table.timestamp :started_at
10
+ table.timestamp :completed_at
11
+ table.string :status, null: false
12
+ table.timestamps null: false
13
+ end
14
+
15
+ add_index :ductwork_steps, %i[pipeline_id status]
16
+ end
17
+ end