ductwork 0.14.1 → 0.15.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: 99db2113458db663a866d26859c59ba4c1672d6a9180a89662834ef1c667ea58
4
- data.tar.gz: cc0c38a5202a791db820efca5501cf31bd1cb71f214dfae8ed9a7b831d442788
3
+ metadata.gz: cd378a2cedabe241b11559ecc7611a6ca8aa631ca2739a97199fdf4a34387f4e
4
+ data.tar.gz: 3faddccd331cd7bd94cc7e1487a4f4f823fc0037ca7109bf2c093ac357488263
5
5
  SHA512:
6
- metadata.gz: 7316e5d9d6b103a754e2f4fa12a9e7b33474fbbcb6522c75b8da8cf2e514c8bba61a84ecd801428093a4dc77395a358c30a59e47f1f146c99b489fa2e9f6f64c
7
- data.tar.gz: a14268df21d58fcc1e92de2a01d475d0fe14b8974a9a0da7fef49be84a201e574f743d5ed999f7d0b46150f6da837d225c0b5c000fb3a40c528d2c66b27c7a28
6
+ metadata.gz: eb6051a653d7aa5f9dbf4cc0784ed69d364784fb14d3d24120683616eb2601a4d4f97c028c2cc8df137c2a992429af450f63589f6381977dcbabc68eb926314f
7
+ data.tar.gz: 8e484847a038021d399272e3565a58b4204f2752d346b515ee59503204fae7f6ee3756c780376b4df03dd73d960ef191c484dd0bd2ec33e384b268272a63aae8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Ductwork Changelog
2
2
 
3
+ ## [0.15.0]
4
+
5
+ - chore: remove unnecessary transaction in job enqueueing method
6
+ - feat: expose pipeline and step classes validation method instead of running in a rails initializer
7
+ - feat!: introduce pipeline Context - this is a BREAKING CHANGE because step classes now need to inherit from `Ductwork::Step`; they are no longer POROs
8
+
3
9
  ## [0.14.1]
4
10
 
5
11
  - fix: check if steps and pipelines directories exist before eager loading
data/README.md CHANGED
@@ -7,6 +7,8 @@ A Ruby pipeline framework.
7
7
 
8
8
  Ductwork lets you build complex pipelines quickly and easily using intuitive Ruby tooling and a natural DSL. No need to learn complicated unified object models or stand up separate runner instances—just write Ruby code and let Ductwork handle the orchestration.
9
9
 
10
+ There is also a paid [Ductwork Pro](https://www.getductwork.io/) version with more features and support. See the [Pricing](https://www.getductwork.io/#pricing) page to buy a license.
11
+
10
12
  **[Full Documentation](https://docs.getductwork.io/)**
11
13
 
12
14
  ## Installation
@@ -60,7 +62,7 @@ end
60
62
 
61
63
  ### 2. Define Steps
62
64
 
63
- Steps are Plain Old Ruby Objects (POROs) that implement two methods:
65
+ Steps are Ruby objects that inherit from `Ductwork::Step` and implement two methods:
64
66
  - `initialize` - accepts parameters from the trigger call or previous step's return value
65
67
  - `execute` - performs the work and returns data for the next step
66
68
 
@@ -68,7 +70,7 @@ Steps live in `app/steps`:
68
70
 
69
71
  ```ruby
70
72
  # app/steps/users_requiring_enrichment.rb
71
- class UsersRequiringEnrichment
73
+ class QueryUsersRequiringEnrichment < Ductwork::Step
72
74
  def initialize(days_outdated)
73
75
  @days_outdated = days_outdated
74
76
  end
@@ -90,7 +92,7 @@ Connect steps together using Ductwork's fluent interface DSL. The key principle:
90
92
  ```ruby
91
93
  class EnrichUserDataPipeline < Ductwork::Pipeline
92
94
  define do |pipeline|
93
- pipeline.start(UsersRequiringEnrichment) # Start with a single step
95
+ pipeline.start(QueryUsersRequiringEnrichment) # Start with a single step
94
96
  .expand(to: LoadUserData) # Fan out to multiple steps
95
97
  .divide(to: [FetchDataFromSourceA, # Split into parallel branches
96
98
  FetchDataFromSourceB])
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Context
5
+ class OverwriteError < StandardError; end
6
+
7
+ def initialize(pipeline_id)
8
+ @pipeline_id = pipeline_id
9
+ end
10
+
11
+ def get(key)
12
+ raise ArgumentError, "Key must be a string" if !key.is_a?(String)
13
+
14
+ Ductwork.wrap_with_app_executor do
15
+ Ductwork::Tuple
16
+ .select(:serialized_value)
17
+ .find_by(pipeline_id:, key:)
18
+ &.value
19
+ end
20
+ end
21
+
22
+ def set(key, value, overwrite: false)
23
+ attributes = {
24
+ pipeline_id: pipeline_id,
25
+ key: key,
26
+ serialized_value: Ductwork::Tuple.serialize(value),
27
+ first_set_at: Time.current,
28
+ last_set_at: Time.current,
29
+ }
30
+ unique_by = %i[pipeline_id key]
31
+
32
+ if overwrite
33
+ Ductwork.wrap_with_app_executor do
34
+ Ductwork::Tuple.upsert(attributes, unique_by:)
35
+ end
36
+ else
37
+ result = Ductwork.wrap_with_app_executor do
38
+ Ductwork::Tuple.insert(attributes, unique_by:)
39
+ end
40
+
41
+ if result.rows.none?
42
+ raise Ductwork::Context::OverwriteError, "Can only set value once"
43
+ end
44
+ end
45
+
46
+ value
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :pipeline_id
52
+ end
53
+ end
@@ -23,23 +23,5 @@ module Ductwork
23
23
  Ductwork.configuration ||= Ductwork::Configuration.new
24
24
  Ductwork.logger ||= Ductwork::Configuration::DEFAULT_LOGGER
25
25
  end
26
-
27
- initializer "ductwork.validate_definitions", after: :load_config_initializers do
28
- config.after_initialize do
29
- # Load steps and pipelines so definition validation runs and bugs
30
- # can be caught simply by booting the app or running tests
31
- loader = Rails.autoloaders.main
32
- step_directory = Rails.root.join("app/steps")
33
- pipeline_directory = Rails.root.join("app/pipelines")
34
-
35
- if step_directory.exist?
36
- loader.eager_load_dir(step_directory)
37
- end
38
-
39
- if pipeline_directory.exist?
40
- loader.eager_load_dir(pipeline_directory)
41
- end
42
- end
43
- end
44
26
  end
45
27
  end
@@ -68,22 +68,18 @@ module Ductwork
68
68
  end
69
69
 
70
70
  def self.enqueue(step, args)
71
- job = Ductwork::Record.transaction do
72
- j = step.create_job!(
73
- klass: step.klass,
74
- started_at: Time.current,
75
- input_args: JSON.dump({ args: })
76
- )
77
- execution = j.executions.create!(
78
- started_at: Time.current,
79
- retry_count: 0
80
- )
81
- execution.create_availability!(
82
- started_at: Time.current
83
- )
84
-
85
- j
86
- end
71
+ job = step.create_job!(
72
+ klass: step.klass,
73
+ started_at: Time.current,
74
+ input_args: JSON.dump({ args: })
75
+ )
76
+ execution = job.executions.create!(
77
+ started_at: Time.current,
78
+ retry_count: 0
79
+ )
80
+ execution.create_availability!(
81
+ started_at: Time.current
82
+ )
87
83
 
88
84
  Ductwork.logger.info(
89
85
  msg: "Job enqueued",
@@ -104,7 +100,7 @@ module Ductwork
104
100
  job_klass: klass
105
101
  )
106
102
  args = JSON.parse(input_args)["args"]
107
- instance = Object.const_get(klass).new(args)
103
+ instance = Object.const_get(klass).build_for_execution(step, args)
108
104
  run = execution.create_run!(
109
105
  started_at: Time.current
110
106
  )
@@ -3,6 +3,7 @@
3
3
  module Ductwork
4
4
  class Pipeline < Ductwork::Record # rubocop:todo Metrics/ClassLength
5
5
  has_many :steps, class_name: "Ductwork::Step", foreign_key: "pipeline_id", dependent: :destroy
6
+ has_many :tuples, class_name: "Ductwork::Tuple", foreign_key: "pipeline_id", dependent: :destroy
6
7
 
7
8
  validates :klass, presence: true
8
9
  validates :definition, presence: true
@@ -267,6 +268,9 @@ module Ductwork
267
268
  if max_depth != -1 && return_value.count > max_depth
268
269
  halt!
269
270
  else
271
+ # TODO: Brainstorm on using `insert_all` instead of iterating.
272
+ # Performance is bad when the return value has a lot of elements
273
+ # and we create a step and job individually
270
274
  Array(return_value).each do |input_arg|
271
275
  create_step_and_enqueue_job(edge:, input_arg:)
272
276
  end
@@ -25,5 +25,20 @@ module Ductwork
25
25
  combine: "combine",
26
26
  expand: "expand",
27
27
  collapse: "collapse"
28
+
29
+ def self.build_for_execution(record, *args, **kwargs)
30
+ instance = allocate
31
+ instance.instance_variable_set(:@pipeline_id, record.pipeline_id)
32
+ instance.send(:initialize, *args, **kwargs)
33
+ instance
34
+ end
35
+
36
+ def pipeline_id
37
+ @pipeline_id || (@attributes && super)
38
+ end
39
+
40
+ def context
41
+ @_context ||= Ductwork::Context.new(pipeline_id)
42
+ end
28
43
  end
29
44
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Tuple < Ductwork::Record
5
+ belongs_to :pipeline, class_name: "Ductwork::Pipeline"
6
+
7
+ # NOTE: These model-level validations are here for symmetry. The current
8
+ # methods used to create `Tuple` records skip validations lol
9
+ validates :key, presence: true
10
+ validates :key, uniqueness: { scope: :pipeline_id }
11
+ validates :first_set_at, presence: true
12
+ validates :last_set_at, presence: true
13
+
14
+ def self.serialize(raw_value)
15
+ { raw_value: }.to_json
16
+ end
17
+
18
+ def value=(raw_value)
19
+ self.serialized_value = self.class.serialize(raw_value)
20
+ end
21
+
22
+ def value
23
+ if serialized_value.present?
24
+ JSON
25
+ .parse(serialized_value, symbolize_names: true)
26
+ .fetch(:raw_value)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- VERSION = "0.14.1"
4
+ VERSION = "0.15.0"
5
5
  end
data/lib/ductwork.rb CHANGED
@@ -62,6 +62,26 @@ module Ductwork
62
62
  @defined_pipelines ||= []
63
63
  end
64
64
 
65
+ def validate!
66
+ step_directory = Rails.root.join("app/steps")
67
+ pipeline_directory = Rails.root.join("app/pipelines")
68
+ loader = if defined?(Rails.autoloaders)
69
+ Rails.autoloaders.main
70
+ else
71
+ Ductwork.loader
72
+ end
73
+
74
+ if step_directory.exist?
75
+ loader.eager_load_dir(step_directory)
76
+ end
77
+
78
+ if pipeline_directory.exist?
79
+ loader.eager_load_dir(pipeline_directory)
80
+ end
81
+
82
+ true
83
+ end
84
+
65
85
  private
66
86
 
67
87
  def add_lifecycle_hook(target, event, block)
@@ -31,6 +31,8 @@ module Ductwork
31
31
  "db/migrate/create_ductwork_results.rb"
32
32
  migration_template "db/create_ductwork_processes.rb",
33
33
  "db/migrate/create_ductwork_processes.rb"
34
+ migration_template "db/create_ductwork_tuples.rb",
35
+ "db/migrate/create_ductwork_tuples.rb"
34
36
 
35
37
  route <<~ROUTE
36
38
  # This mounts the web dashboard. It is recommended to add authentication around it.
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkTuples < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :ductwork_tuples do |table|
6
+ table.belongs_to :pipeline, index: true, null: false, foreign_key: { to_table: :ductwork_pipelines }
7
+ table.string :key, null: false
8
+ table.string :serialized_value
9
+ table.datetime :first_set_at, null: false
10
+ table.datetime :last_set_at, null: false
11
+ table.timestamps null: false
12
+ end
13
+
14
+ add_index :ductwork_tuples, %i[key pipeline_id], unique: true
15
+ end
16
+ 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.14.1
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Ewing
@@ -135,6 +135,7 @@ files:
135
135
  - lib/ductwork.rb
136
136
  - lib/ductwork/cli.rb
137
137
  - lib/ductwork/configuration.rb
138
+ - lib/ductwork/context.rb
138
139
  - lib/ductwork/dsl/branch_builder.rb
139
140
  - lib/ductwork/dsl/definition_builder.rb
140
141
  - lib/ductwork/engine.rb
@@ -148,6 +149,7 @@ files:
148
149
  - lib/ductwork/models/result.rb
149
150
  - lib/ductwork/models/run.rb
150
151
  - lib/ductwork/models/step.rb
152
+ - lib/ductwork/models/tuple.rb
151
153
  - lib/ductwork/processes/job_worker.rb
152
154
  - lib/ductwork/processes/job_worker_runner.rb
153
155
  - lib/ductwork/processes/pipeline_advancer.rb
@@ -172,6 +174,7 @@ files:
172
174
  - lib/generators/ductwork/install/templates/db/create_ductwork_results.rb
173
175
  - lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb
174
176
  - lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb
177
+ - lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb
175
178
  homepage: https://github.com/ductwork/ductwork
176
179
  licenses:
177
180
  - LGPL-3.0-or-later