ductwork 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10cf03c4f8237368de710c2ee604a9d20cf40c2dcebfda776fe490a38ba26aad
4
- data.tar.gz: fd44065015e06a493905b493b847df2de01d779f7451662b186a458cb1cc9977
3
+ metadata.gz: 4c1f2564c8a446cc2daf77b882acfc6abab314e1a651bf1e7a6ab9c7432a77f6
4
+ data.tar.gz: ff33012c7f1dce6f9297c41185e97d8bffc4054ac26738c19459db50a70f6d19
5
5
  SHA512:
6
- metadata.gz: 712b5f00ad1a1a1c736dd186f04dce4a85f2ba3d57df234a5d1182d91483530f0722c9a153e1a4f8517142657ffc8f486e39e6bcd51c28b66f4df5a5958e5455
7
- data.tar.gz: 18c7d56494dc4251310e73bffe47c56e80820634dce555ac2e8d2d46b65973242adc621004dada1efa94cb353063ab650bcb833cec2c7d0cffd635727b808fa1
6
+ metadata.gz: cdc5618e70f3b42f758ab6de842ff46732ee34435e82b2b04777aad7e203f0457fc00ce496fa23f2b64e232ecc81548bbff669fddfbe3cdb5c404b3d0036beea
7
+ data.tar.gz: e3697a5c616527013580003f6e0f131c4ddfabf871b01dc2f910527febdb19e2ead1e5f3f244a001242ff0876fe331f84eca116c24111b3fdeffbc05f7205289
data/CHANGELOG.md CHANGED
@@ -1,10 +1,24 @@
1
1
  # Ductwork Changelog
2
2
 
3
+ ## [0.3.0]
4
+
5
+ - fix: correctly create collapsing and combining steps and jobs for complex pipelines
6
+ - fix: add a new step and job for each active branch in a running pipeline
7
+ - fix: add a new node and edge for each active branch of the definition
8
+ - feat: add info-level logging for job events
9
+ - feat: add info-level logging for pipeline events
10
+
11
+ ## [0.2.1]
12
+
13
+ - fix: do not splat arguments when executing a job nor triggering a pipeline
14
+ - fix: do not splat arguments when enqueuing a job and fix related spec
15
+ - fix: add missing `dependent: :destroy` on certain associations
16
+
3
17
  ## [0.2.0]
4
18
 
5
- - fix: allow steps to be chained while pipeline is expanded or divided (before collapsing or combining) - before this incorrectly raised a `CollapseError` or `CombineError`
6
- - feat: validate argument(s) passed to step transition DSL methods to be valid step class
7
19
  - feat: validate all pipeline definitions on rails boot
20
+ - feat: validate argument(s) passed to step transition DSL methods to be valid step class
21
+ - fix: allow steps to be chained while pipeline is expanded or divided (before collapsing or combining) - before this incorrectly raised a `CollapseError` or `CombineError`
8
22
 
9
23
  ## [0.1.0]
10
24
 
data/README.md CHANGED
@@ -75,7 +75,7 @@ class UsersRequiringEnrichment
75
75
 
76
76
  def execute
77
77
  ids = User.where("data_last_refreshed_at < ?", @days_outdated.days.ago).ids
78
- Rails.logger.info("Enriching #{ids.length} users' data")
78
+ Ductwork.logger.info("Enriching #{ids.length} users' data")
79
79
 
80
80
  # Return value becomes input to the next step
81
81
  ids
@@ -3,9 +3,9 @@
3
3
  module Ductwork
4
4
  class Execution < Ductwork::Record
5
5
  belongs_to :job, class_name: "Ductwork::Job"
6
- has_one :availability, class_name: "Ductwork::Availability", foreign_key: "execution_id"
7
- has_one :run, class_name: "Ductwork::Run", foreign_key: "execution_id"
8
- has_one :result, class_name: "Ductwork::Result", foreign_key: "execution_id"
6
+ has_one :availability, class_name: "Ductwork::Availability", foreign_key: "execution_id", dependent: :destroy
7
+ has_one :run, class_name: "Ductwork::Run", foreign_key: "execution_id", dependent: :destroy
8
+ has_one :result, class_name: "Ductwork::Result", foreign_key: "execution_id", dependent: :destroy
9
9
 
10
10
  validates :retry_count, presence: true
11
11
  validates :started_at, presence: true
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- class Job < Ductwork::Record
4
+ class Job < Ductwork::Record # rubocop:todo Metrics/ClassLength
5
5
  belongs_to :step, class_name: "Ductwork::Step"
6
6
  has_many :executions, class_name: "Ductwork::Execution", foreign_key: "job_id", dependent: :destroy
7
7
 
@@ -60,14 +60,14 @@ module Ductwork
60
60
  end
61
61
  end
62
62
 
63
- def self.enqueue(step, *args)
64
- Ductwork::Record.transaction do
65
- job = step.create_job!(
63
+ def self.enqueue(step, args)
64
+ job = Ductwork::Record.transaction do
65
+ j = step.create_job!(
66
66
  klass: step.klass,
67
67
  started_at: Time.current,
68
68
  input_args: JSON.dump({ args: })
69
69
  )
70
- execution = job.executions.create!(
70
+ execution = j.executions.create!(
71
71
  started_at: Time.current,
72
72
  retry_count: 0
73
73
  )
@@ -75,8 +75,16 @@ module Ductwork
75
75
  started_at: Time.current
76
76
  )
77
77
 
78
- job
78
+ j
79
79
  end
80
+
81
+ Ductwork.configuration.logger.info(
82
+ msg: "Job enqueued",
83
+ job_id: job.id,
84
+ job_klass: job.klass
85
+ )
86
+
87
+ job
80
88
  end
81
89
 
82
90
  def execute(pipeline)
@@ -89,7 +97,7 @@ module Ductwork
89
97
  job_klass: klass
90
98
  )
91
99
  args = JSON.parse(input_args)["args"]
92
- instance = Object.const_get(klass).new(*args)
100
+ instance = Object.const_get(klass).new(args)
93
101
  run = execution.create_run!(
94
102
  started_at: Time.current
95
103
  )
@@ -103,12 +111,13 @@ module Ductwork
103
111
  execution_failed!(execution, run, e)
104
112
  result = "failure"
105
113
  ensure
106
- logger.debug(
107
- msg: "Executed job",
108
- role: :job_worker,
114
+ logger.info(
115
+ msg: "Job executed",
109
116
  pipeline: pipeline,
117
+ job_id: id,
110
118
  job_klass: klass,
111
- result: result
119
+ result: result,
120
+ role: :job_worker
112
121
  )
113
122
  end
114
123
  end
@@ -167,6 +176,16 @@ module Ductwork
167
176
  end
168
177
  end
169
178
 
179
+ logger.warn(
180
+ msg: "Job errored",
181
+ error_klass: error.class.name,
182
+ error_message: error.message,
183
+ job_id: id,
184
+ job_klass: klass,
185
+ pipeline_id: pipeline.id,
186
+ role: :job_worker
187
+ )
188
+
170
189
  # NOTE: perform lifecycle hook execution outside of the transaction as
171
190
  # to not unnecessarily hold it open
172
191
  if halted
@@ -48,7 +48,7 @@ module Ductwork
48
48
  Ductwork.defined_pipelines << name.to_s
49
49
  end
50
50
 
51
- def trigger(*args)
51
+ def trigger(args)
52
52
  if pipeline_definition.nil?
53
53
  raise DefinitionError, "Pipeline must be defined before triggering"
54
54
  end
@@ -56,8 +56,8 @@ module Ductwork
56
56
  step_klass = pipeline_definition.dig(:nodes, 0)
57
57
  definition = JSON.dump(pipeline_definition)
58
58
 
59
- Record.transaction do
60
- pipeline = create!(
59
+ pipeline = Record.transaction do
60
+ p = create!(
61
61
  klass: name.to_s,
62
62
  status: :in_progress,
63
63
  definition: definition,
@@ -65,32 +65,44 @@ module Ductwork
65
65
  triggered_at: Time.current,
66
66
  last_advanced_at: Time.current
67
67
  )
68
- step = pipeline.steps.create!(
68
+ step = p.steps.create!(
69
69
  klass: step_klass,
70
70
  status: :in_progress,
71
71
  step_type: :start,
72
72
  started_at: Time.current
73
73
  )
74
- Ductwork::Job.enqueue(step, *args)
74
+ Ductwork::Job.enqueue(step, args)
75
75
 
76
- pipeline
76
+ p
77
77
  end
78
+
79
+ Ductwork.configuration.logger.info(
80
+ msg: "Pipeline triggered",
81
+ pipeline_id: pipeline.id,
82
+ role: :application
83
+ )
84
+
85
+ pipeline
78
86
  end
79
87
  end
80
88
 
81
89
  def advance!
82
- step = steps.advancing.take
83
- edge = if step.present?
84
- parsed_definition.dig(:edges, step.klass, 0)
85
- end
90
+ # NOTE: there could be A LOT of steps advancing for a single pipeline
91
+ # so instead of loading everything into memory and using ruby collection
92
+ # methods we make multiple queries. may need to revisist this once
93
+ # we do extensive load testing
94
+ advancing = steps.advancing
95
+ edges = if advancing.exists?
96
+ parsed_definition
97
+ .fetch(:edges, {})
98
+ .select { |k| k.in?(advancing.pluck(:klass)) }
99
+ end
86
100
 
87
101
  Ductwork::Record.transaction do
88
- steps.advancing.update!(status: :completed, completed_at: Time.current)
89
-
90
- if edge.nil?
91
- conditionally_complete_pipeline
102
+ if edges.nil? || edges.values.all?(&:empty?)
103
+ conditionally_complete_pipeline(advancing)
92
104
  else
93
- advance_to_next_step_by_type(edge, step)
105
+ advance_to_next_steps_by_type(edges, advancing)
94
106
  end
95
107
  end
96
108
  end
@@ -108,34 +120,64 @@ module Ductwork
108
120
  @parsed_definition ||= JSON.parse(definition).with_indifferent_access
109
121
  end
110
122
 
111
- def conditionally_complete_pipeline
123
+ def conditionally_complete_pipeline(advancing)
124
+ advancing.update!(status: :completed, completed_at: Time.current)
125
+
112
126
  if steps.where(status: %w[in_progress pending]).none?
113
127
  update!(status: :completed, completed_at: Time.current)
128
+
129
+ Ductwork.configuration.logger.info(
130
+ msg: "Pipeline completed",
131
+ pipeline_id: id,
132
+ role: :pipeline_advancer
133
+ )
114
134
  end
115
135
  end
116
136
 
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)
137
+ def advance_to_next_steps_by_type(edges, advancing)
138
+ if edges.all? { |_, v| v.dig(-1, :type) == "combine" }
139
+ conditionally_combine_next_steps(edges, advancing)
130
140
  else
131
- Ductwork.configuration.logger.error(
132
- msg: "Invalid step type",
133
- role: :pipeline_advancer
134
- )
141
+ edges.each do |step_klass, step_edges|
142
+ edge = step_edges[-1]
143
+ # NOTE: "chain" is used by ActiveRecord so we have to call
144
+ # this enum value "default" :sad:
145
+ step_type = edge[:type] == "chain" ? "default" : edge[:type]
146
+
147
+ if step_type == "collapse"
148
+ conditionally_collapse_next_steps(step_klass, edge, advancing)
149
+ else
150
+ advance_non_merging_steps(step_klass, edges, advancing)
151
+ end
152
+ end
135
153
  end
154
+ advancing.update!(status: :completed, completed_at: Time.current)
155
+ log_pipeline_advanced(edges)
136
156
  end
137
157
 
138
- def advance_to_next_steps(step_type, step, edge)
158
+ def advance_non_merging_steps(step_klass, edges, advancing)
159
+ advancing.where(klass: step_klass).find_each do |step|
160
+ edge = edges.dig(step.klass, -1)
161
+ # NOTE: "chain" is used by ActiveRecord so we have to call
162
+ # this enum value "default" :sad:
163
+ step_type = edge[:type] == "chain" ? "default" : edge[:type]
164
+
165
+ if step_type.in?(%w[default divide])
166
+ advance_to_next_steps(step_type, advancing, edge)
167
+ elsif step_type == "expand"
168
+ expand_to_next_steps(step_type, advancing, edge)
169
+ else
170
+ Ductwork.configuration.logger.error(
171
+ msg: "Invalid step type",
172
+ step_type: step_type,
173
+ pipeline_id: id,
174
+ role: :pipeline_advancer
175
+ )
176
+ end
177
+ end
178
+ end
179
+
180
+ def advance_to_next_steps(step_type, advancing, edge)
139
181
  edge[:to].each do |to_klass|
140
182
  next_step = steps.create!(
141
183
  klass: to_klass,
@@ -143,53 +185,82 @@ module Ductwork
143
185
  step_type: step_type,
144
186
  started_at: Time.current
145
187
  )
146
- Ductwork::Job.enqueue(next_step, step.job.return_value)
188
+ Ductwork::Job.enqueue(next_step, advancing.take.job.return_value)
147
189
  end
148
190
  end
149
191
 
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
192
+ def conditionally_combine_next_steps(edges, advancing)
193
+ if steps.where(status: %w[pending in_progress], klass: edges.keys).none?
194
+ combine_next_steps(edges, advancing)
195
+ else
196
+ Ductwork.configuration.logger.debug(
197
+ msg: "Not all divided steps have completed; not combining",
198
+ pipeline_id: id,
199
+ role: :pipeline_advancer
163
200
  )
164
201
  end
165
202
  end
166
203
 
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
- )
204
+ def combine_next_steps(edges, advancing)
205
+ klass = edges.values.sample.dig(-1, :to).sole
206
+ step_type = "combine"
207
+ groups = advancing
208
+ .group(:klass)
209
+ .count
210
+ .keys
211
+ .map { |k| advancing.where(klass: k) }
212
+
213
+ groups.first.zip(*groups[1..]).each do |group|
214
+ input_arg = Ductwork::Job
215
+ .where(step_id: group.map(&:id))
216
+ .map(&:return_value)
217
+ create_step_and_enqueue_job(klass:, step_type:, input_arg:)
174
218
  end
175
219
  end
176
220
 
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)
221
+ def expand_to_next_steps(step_type, advancing, edge)
222
+ Array(advancing.take.job.return_value).each do |input_arg|
182
223
  create_step_and_enqueue_job(
183
224
  klass: edge[:to].sole,
184
225
  step_type: step_type,
185
226
  input_arg: input_arg
186
227
  )
228
+ end
229
+ end
230
+
231
+ def conditionally_collapse_next_steps(step_klass, edge, advancing)
232
+ if steps.where(status: %w[pending in_progress], klass: step_klass).none?
233
+ collapse_next_steps(edge[:to].sole, advancing)
187
234
  else
188
235
  Ductwork.configuration.logger.debug(
189
- msg: "Not all expanded steps have completed",
236
+ msg: "Not all expanded steps have completed; not collapsing",
237
+ pipeline_id: id,
190
238
  role: :pipeline_advancer
191
239
  )
192
240
  end
193
241
  end
242
+
243
+ def collapse_next_steps(klass, advancing)
244
+ step_type = "collapse"
245
+ input_arg = []
246
+
247
+ # NOTE: because of expanding based on return values, there
248
+ # could be A LOT of jobs so we want to use batch methods
249
+ # to avoid creating too many in-memory objects
250
+ Ductwork::Job.where(step_id: advancing.ids).find_each do |job|
251
+ input_arg << job.return_value
252
+ end
253
+
254
+ create_step_and_enqueue_job(klass:, step_type:, input_arg:)
255
+ end
256
+
257
+ def log_pipeline_advanced(edges)
258
+ Ductwork.configuration.logger.info(
259
+ msg: "Pipeline advanced",
260
+ pipeline_id: id,
261
+ transitions: edges.map { |_, v| v.dig(-1, :type) },
262
+ role: :pipeline_advancer
263
+ )
264
+ end
194
265
  end
195
266
  end
@@ -3,7 +3,7 @@
3
3
  module Ductwork
4
4
  class Step < Ductwork::Record
5
5
  belongs_to :pipeline, class_name: "Ductwork::Pipeline"
6
- has_one :job, class_name: "Ductwork::Job", foreign_key: "step_id"
6
+ has_one :job, class_name: "Ductwork::Job", foreign_key: "step_id", dependent: :destroy
7
7
 
8
8
  validates :klass, presence: true
9
9
  validates :status, presence: true
@@ -14,6 +14,7 @@ module Ductwork
14
14
  }
15
15
  @divisions = 0
16
16
  @expansions = 0
17
+ @last_nodes = []
17
18
  end
18
19
 
19
20
  def start(klass)
@@ -29,7 +30,7 @@ module Ductwork
29
30
  def chain(klass)
30
31
  validate_classes!(klass)
31
32
  validate_definition_started!(action: "chaining")
32
- add_edge_to_last_node(klass, type: :chain)
33
+ add_edge_to_last_nodes(klass, type: :chain)
33
34
  add_new_nodes(klass)
34
35
 
35
36
  self
@@ -38,7 +39,7 @@ module Ductwork
38
39
  def divide(to:)
39
40
  validate_classes!(to)
40
41
  validate_definition_started!(action: "dividing chain")
41
- add_edge_to_last_node(*to, type: :divide)
42
+ add_edge_to_last_nodes(*to, type: :divide)
42
43
  add_new_nodes(*to)
43
44
 
44
45
  @divisions += 1
@@ -79,7 +80,7 @@ module Ductwork
79
80
  def expand(to:)
80
81
  validate_classes!(to)
81
82
  validate_definition_started!(action: "expanding chain")
82
- add_edge_to_last_node(to, type: :expand)
83
+ add_edge_to_last_nodes(to, type: :expand)
83
84
  add_new_nodes(to)
84
85
 
85
86
  @expansions += 1
@@ -91,7 +92,7 @@ module Ductwork
91
92
  validate_classes!(into)
92
93
  validate_definition_started!(action: "collapsing steps")
93
94
  validate_definition_expanded!
94
- add_edge_to_last_node(into, type: :collapse)
95
+ add_edge_to_last_nodes(into, type: :collapse)
95
96
  add_new_nodes(into)
96
97
 
97
98
  @expansions -= 1
@@ -117,7 +118,7 @@ module Ductwork
117
118
 
118
119
  private
119
120
 
120
- attr_reader :definition, :divisions, :expansions
121
+ attr_reader :definition, :divisions, :expansions, :last_nodes
121
122
 
122
123
  def validate_classes!(klasses)
123
124
  valid = Array(klasses).all? do |klass|
@@ -162,19 +163,22 @@ module Ductwork
162
163
  end
163
164
 
164
165
  def add_new_nodes(*klasses)
165
- definition[:nodes].push(*klasses.map(&:name))
166
+ nodes = klasses.map(&:name)
167
+ @last_nodes = Array(nodes)
168
+
169
+ definition[:nodes].push(*nodes)
166
170
  klasses.each do |klass|
167
171
  definition[:edges][klass.name] ||= []
168
172
  end
169
173
  end
170
174
 
171
- def add_edge_to_last_node(*klasses, type:)
172
- last_node = definition.dig(:nodes, -1)
173
-
174
- definition[:edges][last_node] << {
175
- to: klasses.map(&:name),
176
- type: type,
177
- }
175
+ def add_edge_to_last_nodes(*klasses, type:)
176
+ last_nodes.each do |last_node|
177
+ definition[:edges][last_node] << {
178
+ to: klasses.map(&:name),
179
+ type: type,
180
+ }
181
+ end
178
182
  end
179
183
  end
180
184
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ductwork
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Ewing