ductwork 0.20.2 → 0.21.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 +4 -4
- data/.saturnci/Dockerfile +22 -0
- data/.saturnci/database.yml +5 -0
- data/.saturnci/docker-compose.yml +10 -0
- data/.saturnci/pre.sh +4 -0
- data/CHANGELOG.md +10 -0
- data/README.md +6 -0
- data/app/views/ductwork/pipelines/show.html.erb +1 -1
- data/lib/ductwork/context.rb +1 -0
- data/lib/ductwork/job_claim.rb +31 -0
- data/lib/ductwork/migration.rb +7 -0
- data/lib/ductwork/migration_helper.rb +42 -0
- data/lib/ductwork/models/job.rb +7 -57
- data/lib/ductwork/models/pipeline.rb +72 -6
- data/lib/ductwork/models/record.rb +8 -0
- data/lib/ductwork/optimistic_locking_job_claim.rb +88 -0
- data/lib/ductwork/processes/pipeline_advancer_runner.rb +1 -1
- data/lib/ductwork/row_locking_job_claim.rb +75 -0
- data/lib/ductwork/version.rb +1 -1
- data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +13 -5
- data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +9 -3
- data/lib/generators/ductwork/install/templates/db/create_ductwork_jobs.rb +9 -3
- data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +2 -2
- data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -2
- data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +9 -3
- data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +9 -3
- data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +9 -3
- data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +9 -3
- data/lib/generators/ductwork/update/USAGE +9 -0
- data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +20 -0
- data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +118 -0
- data/lib/generators/ductwork/update/update_generator.rb +24 -0
- metadata +17 -4
- /data/{COMM-LICENSE.txt → PRO-LICENSE.txt} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2029d11f63583fd4b5cb774bf3c249b30f933700852ea286c56ae9f6262aa584
|
|
4
|
+
data.tar.gz: 5c32000bd3409f54af3fc43aac6296e7fad458d8ae74336b4edb84310b22bf2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 543e4ab981017a51580b7b3a2a750bbd52a1de313ec27556d1cbb72045b2f6a8fbe743e15585cc56e2682914f0040c91b31a1328be86097f1c08b0dadfcdc73a
|
|
7
|
+
data.tar.gz: 88da2d0dfa602e6850aacbe4fa20a256606eff5084b4d13826a0b715bf46a6a611dc39b4afa13cda35160a66bbf8226b45fc2e618a06178b023632ff98c6c35a
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
FROM ruby:4.0.1-slim
|
|
2
|
+
|
|
3
|
+
RUN apt-get update -qq && \
|
|
4
|
+
apt-get install --no-install-recommends -y \
|
|
5
|
+
build-essential \
|
|
6
|
+
git \
|
|
7
|
+
libsqlite3-dev \
|
|
8
|
+
libyaml-dev \
|
|
9
|
+
sqlite3 \
|
|
10
|
+
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* \
|
|
11
|
+
&& echo "saturnci-test" > /etc/machine-id
|
|
12
|
+
|
|
13
|
+
WORKDIR /code
|
|
14
|
+
|
|
15
|
+
COPY Gemfile Gemfile.lock ductwork.gemspec ./
|
|
16
|
+
COPY lib/ductwork/version.rb ./lib/ductwork/
|
|
17
|
+
|
|
18
|
+
RUN bundle install
|
|
19
|
+
|
|
20
|
+
COPY . .
|
|
21
|
+
|
|
22
|
+
CMD ["bundle", "exec", "rspec"]
|
data/.saturnci/pre.sh
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Ductwork Changelog
|
|
2
2
|
|
|
3
|
+
## [0.21.0]
|
|
4
|
+
|
|
5
|
+
- feat: enqueue jobs in batches when expanding - this marks a major performance improvement
|
|
6
|
+
- fix: use node's defined klass when looking up max expansion depth
|
|
7
|
+
- feat!: change all tables' primary key type to UUID v7 - BREAKING CHANGE: be sure to run `bin/rails g ductwork:update` to get the database migrations required to migrate existing data
|
|
8
|
+
- chore!: drop support for ruby 3.2 - BREAKING CHANGE: we need UUID v7 from `SecureRandom` to use as primary keys for our tables; it is only available on Ruby 3.3+ (support ends in a ~month so this was going to happen anyway)
|
|
9
|
+
- feat: use row locking job claiming when the database supports it
|
|
10
|
+
- feat: denormalize pipeline klass column on `ductwork_availabilities` - be sure to run `bin/rails g ductwork:update` to get the latest migration
|
|
11
|
+
- fix: use pipeline class name in log message
|
|
12
|
+
|
|
3
13
|
## [0.20.2]
|
|
4
14
|
|
|
5
15
|
- fix: complete pipeline when there are no steps to expand to
|
data/README.md
CHANGED
|
@@ -25,6 +25,12 @@ Run the Rails generator to create the binstub, configuration file, and migration
|
|
|
25
25
|
bin/rails generate ductwork:install
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
**NOTE**: run the update generator if you've already installed ductwork to get updates:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bin/rails generate ductwork:update
|
|
32
|
+
```
|
|
33
|
+
|
|
28
34
|
Run migrations and you're ready to start building pipelines!
|
|
29
35
|
|
|
30
36
|
## Configuration
|
data/lib/ductwork/context.rb
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
class JobClaim
|
|
5
|
+
def initialize(klass)
|
|
6
|
+
@klass = klass
|
|
7
|
+
@adapter = Ductwork::Record.connection.adapter_name.downcase
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def latest
|
|
11
|
+
claim = if supports_row_locking?
|
|
12
|
+
RowLockingJobClaim
|
|
13
|
+
else
|
|
14
|
+
OptimisticLockingJobClaim
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
claim.new(klass).latest
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :klass, :adapter
|
|
23
|
+
|
|
24
|
+
def supports_row_locking?
|
|
25
|
+
adapter.match?(/postgresql/i) ||
|
|
26
|
+
adapter.match?(/mysql2/i) ||
|
|
27
|
+
adapter.match?(/trilogy/i) ||
|
|
28
|
+
adapter.match?(/oracle_enhanced/i)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
module MigrationHelper
|
|
5
|
+
def create_ductwork_table(table_name, &block)
|
|
6
|
+
if postgresql?
|
|
7
|
+
create_table table_name, id: :uuid, &block
|
|
8
|
+
else
|
|
9
|
+
create_table table_name, id: false do |table|
|
|
10
|
+
table.string :id, limit: 36, null: false, primary_key: true
|
|
11
|
+
block.call(table)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def belongs_to(table_object, association_name, **options)
|
|
17
|
+
full_options = if postgresql?
|
|
18
|
+
{ type: uuid_column_type }.merge(options)
|
|
19
|
+
else
|
|
20
|
+
{ type: uuid_column_type, limit: 36 }.merge(options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
table_object.belongs_to association_name, **full_options
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def uuid_column_type
|
|
27
|
+
if postgresql?
|
|
28
|
+
:uuid
|
|
29
|
+
else
|
|
30
|
+
:string
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def postgresql?
|
|
35
|
+
connection.adapter_name.match?(/postgresql/i)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mysql?
|
|
39
|
+
connection.adapter_name.match?(/mysql/i)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/ductwork/models/job.rb
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -11,60 +11,8 @@ module Ductwork
|
|
|
11
11
|
|
|
12
12
|
FAILED_EXECUTION_TIMEOUT = 10.seconds
|
|
13
13
|
|
|
14
|
-
def self.claim_latest(klass)
|
|
15
|
-
|
|
16
|
-
id = Ductwork::Availability
|
|
17
|
-
.joins(execution: { job: { step: :pipeline } })
|
|
18
|
-
.where("ductwork_availabilities.started_at <= ?", Time.current)
|
|
19
|
-
.where(completed_at: nil)
|
|
20
|
-
.where(ductwork_pipelines: { klass: })
|
|
21
|
-
.order(:created_at)
|
|
22
|
-
.limit(1)
|
|
23
|
-
.pluck(:id)
|
|
24
|
-
.first
|
|
25
|
-
|
|
26
|
-
if id.present?
|
|
27
|
-
# TODO: probably makes sense to use SQL here instead of relying
|
|
28
|
-
# on ActiveRecord to construct the correct `UPDATE` query
|
|
29
|
-
rows_updated = nil
|
|
30
|
-
Ductwork::Record.transaction do
|
|
31
|
-
rows_updated = Ductwork::Availability
|
|
32
|
-
.where(id: id, completed_at: nil)
|
|
33
|
-
.update_all(completed_at: Time.current, process_id: process_id)
|
|
34
|
-
Ductwork::Execution
|
|
35
|
-
.joins(:availability)
|
|
36
|
-
.where(completed_at: nil)
|
|
37
|
-
.where(ductwork_availabilities: { id: })
|
|
38
|
-
.update_all(process_id:)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
if rows_updated == 1
|
|
42
|
-
Ductwork.logger.debug(
|
|
43
|
-
msg: "Job claimed",
|
|
44
|
-
role: :job_worker,
|
|
45
|
-
process_id: process_id,
|
|
46
|
-
availability_id: id
|
|
47
|
-
)
|
|
48
|
-
job = Ductwork::Job
|
|
49
|
-
.joins(executions: :availability)
|
|
50
|
-
.find_by(ductwork_availabilities: { id:, process_id: })
|
|
51
|
-
|
|
52
|
-
Ductwork::Record.transaction do
|
|
53
|
-
job.step.in_progress!
|
|
54
|
-
job.step.pipeline.in_progress!
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
job
|
|
58
|
-
else
|
|
59
|
-
Ductwork.logger.debug(
|
|
60
|
-
msg: "Did not claim job, avoided race condition",
|
|
61
|
-
role: :job_worker,
|
|
62
|
-
process_id: process_id,
|
|
63
|
-
availability_id: id
|
|
64
|
-
)
|
|
65
|
-
nil
|
|
66
|
-
end
|
|
67
|
-
end
|
|
14
|
+
def self.claim_latest(klass)
|
|
15
|
+
Ductwork::JobClaim.new(klass).latest
|
|
68
16
|
end
|
|
69
17
|
|
|
70
18
|
def self.enqueue(step, *args)
|
|
@@ -78,7 +26,8 @@ module Ductwork
|
|
|
78
26
|
retry_count: 0
|
|
79
27
|
)
|
|
80
28
|
execution.create_availability!(
|
|
81
|
-
started_at: Time.current
|
|
29
|
+
started_at: Time.current,
|
|
30
|
+
pipeline_klass: step.pipeline.klass
|
|
82
31
|
)
|
|
83
32
|
|
|
84
33
|
Ductwork.logger.info(
|
|
@@ -168,7 +117,8 @@ module Ductwork
|
|
|
168
117
|
started_at: FAILED_EXECUTION_TIMEOUT.from_now
|
|
169
118
|
)
|
|
170
119
|
new_execution.create_availability!(
|
|
171
|
-
started_at: FAILED_EXECUTION_TIMEOUT.from_now
|
|
120
|
+
started_at: FAILED_EXECUTION_TIMEOUT.from_now,
|
|
121
|
+
pipeline_klass: pipeline.klass
|
|
172
122
|
)
|
|
173
123
|
elsif execution.retry_count >= max_retry
|
|
174
124
|
halted = true
|
|
@@ -259,7 +259,7 @@ module Ductwork
|
|
|
259
259
|
end
|
|
260
260
|
|
|
261
261
|
def expand_to_next_steps(step_id, edge)
|
|
262
|
-
next_klass = edge[:to].sole
|
|
262
|
+
next_klass = parsed_definition.dig(:edges, edge[:to].sole, :klass)
|
|
263
263
|
return_value = Ductwork::Job
|
|
264
264
|
.find_by(step_id:)
|
|
265
265
|
.return_value
|
|
@@ -270,12 +270,78 @@ module Ductwork
|
|
|
270
270
|
elsif return_value.none?
|
|
271
271
|
complete!
|
|
272
272
|
else
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
273
|
+
bulk_create_steps_and_jobs(edge:, return_value:)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def bulk_create_steps_and_jobs(edge:, return_value:) # rubocop:todo Metrics
|
|
278
|
+
# NOTE: "chain" is used by ActiveRecord so we have to call
|
|
279
|
+
# this enum value "default" :sad:
|
|
280
|
+
to_transition = edge[:type] == "chain" ? "default" : edge[:type]
|
|
281
|
+
node ||= edge[:to].sole
|
|
282
|
+
step_klass = parsed_definition.dig(:edges, node, :klass)
|
|
283
|
+
now = Time.current
|
|
284
|
+
|
|
285
|
+
Array(return_value).each_slice(1_000).each do |batch| # rubocop:todo Metrics/BlockLength
|
|
286
|
+
step_rows = []
|
|
287
|
+
job_rows = []
|
|
288
|
+
execution_rows = []
|
|
289
|
+
availability_rows = []
|
|
290
|
+
|
|
291
|
+
batch.each do |value| # rubocop:todo Metrics/BlockLength
|
|
292
|
+
step_id = SecureRandom.uuid_v7
|
|
293
|
+
job_id = SecureRandom.uuid_v7
|
|
294
|
+
execution_id = SecureRandom.uuid_v7
|
|
295
|
+
availability_id = SecureRandom.uuid_v7
|
|
296
|
+
|
|
297
|
+
step_rows << {
|
|
298
|
+
id: step_id,
|
|
299
|
+
pipeline_id: id,
|
|
300
|
+
node: node,
|
|
301
|
+
klass: step_klass,
|
|
302
|
+
status: "in_progress",
|
|
303
|
+
to_transition: to_transition,
|
|
304
|
+
started_at: now,
|
|
305
|
+
created_at: now,
|
|
306
|
+
updated_at: now,
|
|
307
|
+
}
|
|
308
|
+
job_rows << {
|
|
309
|
+
id: job_id,
|
|
310
|
+
step_id: step_id,
|
|
311
|
+
input_args: JSON.dump({ args: [value] }),
|
|
312
|
+
klass: step_klass,
|
|
313
|
+
started_at: now,
|
|
314
|
+
created_at: now,
|
|
315
|
+
updated_at: now,
|
|
316
|
+
}
|
|
317
|
+
execution_rows << {
|
|
318
|
+
id: execution_id,
|
|
319
|
+
job_id: job_id,
|
|
320
|
+
retry_count: 0,
|
|
321
|
+
started_at: now,
|
|
322
|
+
created_at: now,
|
|
323
|
+
updated_at: now,
|
|
324
|
+
}
|
|
325
|
+
availability_rows << {
|
|
326
|
+
id: availability_id,
|
|
327
|
+
execution_id: execution_id,
|
|
328
|
+
pipeline_klass: klass,
|
|
329
|
+
started_at: now,
|
|
330
|
+
created_at: now,
|
|
331
|
+
updated_at: now,
|
|
332
|
+
}
|
|
278
333
|
end
|
|
334
|
+
|
|
335
|
+
Ductwork::Step.insert_all!(step_rows)
|
|
336
|
+
Ductwork::Job.insert_all!(job_rows)
|
|
337
|
+
Ductwork::Execution.insert_all!(execution_rows)
|
|
338
|
+
Ductwork::Availability.insert_all!(availability_rows)
|
|
339
|
+
|
|
340
|
+
Ductwork.logger.info(
|
|
341
|
+
msg: "Job batch enqueued",
|
|
342
|
+
count: batch.count,
|
|
343
|
+
job_klass: step_klass
|
|
344
|
+
)
|
|
279
345
|
end
|
|
280
346
|
end
|
|
281
347
|
|
|
@@ -8,8 +8,16 @@ module Ductwork
|
|
|
8
8
|
connects_to(database: { writing: Ductwork.configuration.database.to_sym })
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
before_create :generate_uuid_v7
|
|
12
|
+
|
|
11
13
|
def self.table_name_prefix
|
|
12
14
|
"ductwork_"
|
|
13
15
|
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def generate_uuid_v7
|
|
20
|
+
self.id ||= SecureRandom.uuid_v7
|
|
21
|
+
end
|
|
14
22
|
end
|
|
15
23
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
class OptimisticLockingJobClaim
|
|
5
|
+
def initialize(klass)
|
|
6
|
+
@id = nil
|
|
7
|
+
@job = nil
|
|
8
|
+
@klass = klass
|
|
9
|
+
@process_id = ::Process.pid
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def latest
|
|
13
|
+
@id = latest_availability_id
|
|
14
|
+
|
|
15
|
+
if id.present?
|
|
16
|
+
rows_updated = claim_availability
|
|
17
|
+
|
|
18
|
+
if rows_updated == 1
|
|
19
|
+
Ductwork.logger.debug(
|
|
20
|
+
msg: "Job claimed",
|
|
21
|
+
role: :job_worker,
|
|
22
|
+
process_id: process_id,
|
|
23
|
+
availability_id: id
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@job = find_job
|
|
27
|
+
|
|
28
|
+
update_state
|
|
29
|
+
else
|
|
30
|
+
Ductwork.logger.debug(
|
|
31
|
+
msg: "Did not claim job, avoided race condition",
|
|
32
|
+
role: :job_worker,
|
|
33
|
+
process_id: process_id,
|
|
34
|
+
availability_id: id
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
else
|
|
38
|
+
Ductwork.logger.debug(
|
|
39
|
+
msg: "No available job to claim",
|
|
40
|
+
role: :job_worker,
|
|
41
|
+
process_id: process_id,
|
|
42
|
+
pipeline: klass
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
job
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :id, :job, :klass, :process_id
|
|
52
|
+
|
|
53
|
+
def latest_availability_id
|
|
54
|
+
Ductwork::Availability
|
|
55
|
+
.where("ductwork_availabilities.started_at <= ?", Time.current)
|
|
56
|
+
.where(completed_at: nil, pipeline_klass: klass)
|
|
57
|
+
.order(:started_at)
|
|
58
|
+
.limit(1)
|
|
59
|
+
.pluck(:id)
|
|
60
|
+
.first
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def claim_availability
|
|
64
|
+
Ductwork::Availability
|
|
65
|
+
.where(id: id, completed_at: nil)
|
|
66
|
+
.update_all(completed_at: Time.current, process_id: process_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def find_job
|
|
70
|
+
Ductwork::Job
|
|
71
|
+
.joins(executions: :availability)
|
|
72
|
+
.find_by!(ductwork_availabilities: { id:, process_id: })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def update_state
|
|
76
|
+
Ductwork::Record.transaction do
|
|
77
|
+
Ductwork::Execution
|
|
78
|
+
.joins(:availability)
|
|
79
|
+
.where(completed_at: nil)
|
|
80
|
+
.where(ductwork_availabilities: { id: })
|
|
81
|
+
.sole
|
|
82
|
+
.update!(process_id:)
|
|
83
|
+
job.step.in_progress!
|
|
84
|
+
job.step.pipeline.in_progress!
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
class RowLockingJobClaim
|
|
5
|
+
def initialize(klass)
|
|
6
|
+
@id = nil
|
|
7
|
+
@job = nil
|
|
8
|
+
@klass = klass
|
|
9
|
+
@process_id = ::Process.pid
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def latest
|
|
13
|
+
rows_updated = attempt_availability_claim
|
|
14
|
+
|
|
15
|
+
if rows_updated == 1
|
|
16
|
+
Ductwork.logger.debug(
|
|
17
|
+
msg: "Job claimed",
|
|
18
|
+
role: :job_worker,
|
|
19
|
+
process_id: process_id,
|
|
20
|
+
availability_id: id
|
|
21
|
+
)
|
|
22
|
+
update_state
|
|
23
|
+
else
|
|
24
|
+
Ductwork.logger.debug(
|
|
25
|
+
msg: "No available job to claim",
|
|
26
|
+
role: :job_worker,
|
|
27
|
+
process_id: process_id,
|
|
28
|
+
pipeline: klass
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
job
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :id, :job, :klass, :process_id
|
|
38
|
+
|
|
39
|
+
def attempt_availability_claim
|
|
40
|
+
Ductwork::Record.transaction do
|
|
41
|
+
@id = Ductwork::Availability
|
|
42
|
+
.where("ductwork_availabilities.started_at <= ?", Time.current)
|
|
43
|
+
.where(completed_at: nil, pipeline_klass: klass)
|
|
44
|
+
.order(:started_at)
|
|
45
|
+
.lock("FOR UPDATE SKIP LOCKED")
|
|
46
|
+
.limit(1)
|
|
47
|
+
.ids
|
|
48
|
+
.first
|
|
49
|
+
|
|
50
|
+
if id.present?
|
|
51
|
+
Ductwork::Availability
|
|
52
|
+
.where(id: id, completed_at: nil)
|
|
53
|
+
.update_all(completed_at: Time.current, process_id: process_id)
|
|
54
|
+
else
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_state
|
|
61
|
+
Ductwork::Record.transaction do
|
|
62
|
+
execution = Ductwork::Execution
|
|
63
|
+
.joins(:availability)
|
|
64
|
+
.where(completed_at: nil)
|
|
65
|
+
.where(ductwork_availabilities: { id: })
|
|
66
|
+
.sole
|
|
67
|
+
@job = execution.job
|
|
68
|
+
|
|
69
|
+
execution.update!(process_id:)
|
|
70
|
+
job.step.in_progress!
|
|
71
|
+
job.step.pipeline.in_progress!
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/ductwork/version.rb
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateDuctworkAvailabilities <
|
|
3
|
+
class CreateDuctworkAvailabilities < Ductwork::Migration
|
|
4
4
|
def change
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
create_ductwork_table :ductwork_availabilities do |table|
|
|
6
|
+
belongs_to(
|
|
7
|
+
table,
|
|
8
|
+
:execution,
|
|
9
|
+
index: false,
|
|
10
|
+
null: false,
|
|
11
|
+
foreign_key: { to_table: :ductwork_executions }
|
|
12
|
+
)
|
|
7
13
|
table.timestamp :started_at, null: false
|
|
8
14
|
table.timestamp :completed_at
|
|
9
15
|
table.integer :process_id
|
|
16
|
+
table.string :pipeline_klass, null: false
|
|
10
17
|
table.timestamps null: false
|
|
11
18
|
end
|
|
12
19
|
|
|
13
20
|
add_index :ductwork_availabilities, :execution_id, unique: true
|
|
14
21
|
add_index :ductwork_availabilities, %i[id process_id]
|
|
15
22
|
add_index :ductwork_availabilities,
|
|
16
|
-
%i[
|
|
17
|
-
name: "index_ductwork_availabilities_on_claim_latest"
|
|
23
|
+
%i[pipeline_klass started_at],
|
|
24
|
+
name: "index_ductwork_availabilities_on_claim_latest",
|
|
25
|
+
where: "completed_at IS NULL"
|
|
18
26
|
end
|
|
19
27
|
end
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateDuctworkExecutions <
|
|
3
|
+
class CreateDuctworkExecutions < Ductwork::Migration
|
|
4
4
|
def change
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
create_ductwork_table :ductwork_executions do |table|
|
|
6
|
+
belongs_to(
|
|
7
|
+
table,
|
|
8
|
+
:job,
|
|
9
|
+
index: true,
|
|
10
|
+
null: false,
|
|
11
|
+
foreign_key: { to_table: :ductwork_jobs }
|
|
12
|
+
)
|
|
7
13
|
table.timestamp :started_at, null: false
|
|
8
14
|
table.timestamp :completed_at
|
|
9
15
|
table.integer :retry_count, null: false
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateDuctworkJobs <
|
|
3
|
+
class CreateDuctworkJobs < Ductwork::Migration
|
|
4
4
|
def change
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
create_ductwork_table :ductwork_jobs do |table|
|
|
6
|
+
belongs_to(
|
|
7
|
+
table,
|
|
8
|
+
:step,
|
|
9
|
+
index: false,
|
|
10
|
+
null: false,
|
|
11
|
+
foreign_key: { to_table: :ductwork_steps }
|
|
12
|
+
)
|
|
7
13
|
table.string :klass, null: false
|
|
8
14
|
table.timestamp :started_at, null: false
|
|
9
15
|
table.timestamp :completed_at
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateDuctworkPipelines <
|
|
3
|
+
class CreateDuctworkPipelines < Ductwork::Migration
|
|
4
4
|
def change
|
|
5
|
-
|
|
5
|
+
create_ductwork_table :ductwork_pipelines do |table|
|
|
6
6
|
table.string :klass, null: false
|
|
7
7
|
table.text :definition, null: false
|
|
8
8
|
table.string :definition_sha1, null: false
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateDuctworkProcesses <
|
|
3
|
+
class CreateDuctworkProcesses < Ductwork::Migration
|
|
4
4
|
def change
|
|
5
|
-
|
|
5
|
+
create_ductwork_table :ductwork_processes do |table|
|
|
6
6
|
table.integer :pid, null: false
|
|
7
7
|
table.string :machine_identifier, null: false
|
|
8
8
|
table.timestamp :last_heartbeat_at, null: false
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateDuctworkResults <
|
|
3
|
+
class CreateDuctworkResults < Ductwork::Migration
|
|
4
4
|
def change
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
create_ductwork_table :ductwork_results do |table|
|
|
6
|
+
belongs_to(
|
|
7
|
+
table,
|
|
8
|
+
:execution,
|
|
9
|
+
index: false,
|
|
10
|
+
null: false,
|
|
11
|
+
foreign_key: { to_table: :ductwork_executions }
|
|
12
|
+
)
|
|
7
13
|
table.string :result_type, null: false
|
|
8
14
|
table.string :error_klass
|
|
9
15
|
table.string :error_message
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateDuctworkRuns <
|
|
3
|
+
class CreateDuctworkRuns < Ductwork::Migration
|
|
4
4
|
def change
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
create_ductwork_table :ductwork_runs do |table|
|
|
6
|
+
belongs_to(
|
|
7
|
+
table,
|
|
8
|
+
:execution,
|
|
9
|
+
index: false,
|
|
10
|
+
null: false,
|
|
11
|
+
foreign_key: { to_table: :ductwork_executions }
|
|
12
|
+
)
|
|
7
13
|
table.timestamp :started_at, null: false
|
|
8
14
|
table.timestamp :completed_at
|
|
9
15
|
table.timestamps null: false
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateDuctworkSteps <
|
|
3
|
+
class CreateDuctworkSteps < Ductwork::Migration
|
|
4
4
|
def change
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
create_ductwork_table :ductwork_steps do |table|
|
|
6
|
+
belongs_to(
|
|
7
|
+
table,
|
|
8
|
+
:pipeline,
|
|
9
|
+
index: true,
|
|
10
|
+
null: false,
|
|
11
|
+
foreign_key: { to_table: :ductwork_pipelines }
|
|
12
|
+
)
|
|
7
13
|
table.string :node, null: false
|
|
8
14
|
table.string :klass, null: false
|
|
9
15
|
table.string :to_transition, null: false
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateDuctworkTuples <
|
|
3
|
+
class CreateDuctworkTuples < Ductwork::Migration
|
|
4
4
|
def change
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
create_ductwork_table :ductwork_tuples do |table|
|
|
6
|
+
belongs_to(
|
|
7
|
+
table,
|
|
8
|
+
:pipeline,
|
|
9
|
+
index: true,
|
|
10
|
+
null: false,
|
|
11
|
+
foreign_key: { to_table: :ductwork_pipelines }
|
|
12
|
+
)
|
|
7
13
|
table.string :key, null: false
|
|
8
14
|
table.string :serialized_value
|
|
9
15
|
table.datetime :first_set_at, null: false
|
data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DenormalizePipelineKlassOnAvailabilities < Ductwork::Migration
|
|
4
|
+
def change
|
|
5
|
+
add_column :ductwork_availabilities, :pipeline_klass, :string
|
|
6
|
+
|
|
7
|
+
# NOTE: change this how you see fit. everything is updated to a static,
|
|
8
|
+
# bogus value in case there are a lot of records.
|
|
9
|
+
Ductwork::Availability
|
|
10
|
+
.where(pipeline_klass: nil)
|
|
11
|
+
.update_all(pipeline_klass: "Pipeline")
|
|
12
|
+
|
|
13
|
+
change_column_null :ductwork_availabilities, :pipeline_klass, false
|
|
14
|
+
remove_index :ductwork_availabilities, name: "index_ductwork_availabilities_on_claim_latest"
|
|
15
|
+
add_index :ductwork_availabilities,
|
|
16
|
+
%i[pipeline_klass started_at],
|
|
17
|
+
name: "index_ductwork_availabilities_on_claim_latest",
|
|
18
|
+
where: "completed_at IS NULL"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class MigrateTablesToUuidPrimaryKey < Ductwork::Migration
|
|
4
|
+
TABLE_TO_FOREIGN_KEYS = {
|
|
5
|
+
ductwork_pipelines: [],
|
|
6
|
+
ductwork_steps: [:pipeline],
|
|
7
|
+
ductwork_jobs: [:step],
|
|
8
|
+
ductwork_executions: [:job],
|
|
9
|
+
ductwork_availabilities: [:execution],
|
|
10
|
+
ductwork_runs: [:execution],
|
|
11
|
+
ductwork_results: [:execution],
|
|
12
|
+
ductwork_tuples: [:pipeline],
|
|
13
|
+
ductwork_processes: [],
|
|
14
|
+
}.freeze
|
|
15
|
+
FOREIGN_KEY_TO_TABLES = {
|
|
16
|
+
pipeline: %i[ductwork_steps ductwork_tuples],
|
|
17
|
+
step: %i[ductwork_jobs],
|
|
18
|
+
job: %i[ductwork_executions],
|
|
19
|
+
execution: %i[ductwork_availabilities ductwork_runs ductwork_results],
|
|
20
|
+
availabilitie: [],
|
|
21
|
+
run: [],
|
|
22
|
+
result: [],
|
|
23
|
+
tuple: [],
|
|
24
|
+
processe: [],
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def up
|
|
28
|
+
create_primary_key_uuid_columns
|
|
29
|
+
create_foreign_key_uuid_columns
|
|
30
|
+
backfill_uuid_columns
|
|
31
|
+
drop_old_foreign_keys
|
|
32
|
+
swap_primary_keys
|
|
33
|
+
swap_foreign_keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def down
|
|
37
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
38
|
+
"Cannot revert UUID migration — old integer IDs have been dropped. Restore from backup."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def create_primary_key_uuid_columns
|
|
44
|
+
TABLE_TO_FOREIGN_KEYS.each_key do |table|
|
|
45
|
+
add_column table, :uuid, uuid_column_type
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_foreign_key_uuid_columns
|
|
50
|
+
TABLE_TO_FOREIGN_KEYS.each do |table, column_prefixes|
|
|
51
|
+
column_prefixes.each do |column_prefix|
|
|
52
|
+
add_column table, "#{column_prefix}_uuid", uuid_column_type
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def backfill_uuid_columns
|
|
58
|
+
FOREIGN_KEY_TO_TABLES.each do |prefix, tables|
|
|
59
|
+
select_all("SELECT id from ductwork_#{prefix}s WHERE uuid IS NULL").each do |row|
|
|
60
|
+
uuid = connection.quote(SecureRandom.uuid_v7)
|
|
61
|
+
id = row["id"]
|
|
62
|
+
|
|
63
|
+
execute("UPDATE ductwork_#{prefix}s SET uuid = #{uuid} WHERE id = #{id}")
|
|
64
|
+
tables.each do |table|
|
|
65
|
+
execute("UPDATE #{table} SET #{prefix}_uuid = #{uuid} WHERE #{prefix}_id = #{id}")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def drop_old_foreign_keys
|
|
72
|
+
FOREIGN_KEY_TO_TABLES.each do |prefix, tables|
|
|
73
|
+
tables.each do |table|
|
|
74
|
+
remove_foreign_key table, "ductwork_#{prefix}s", column: "#{prefix}_id", if_exists: true
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def swap_primary_keys # rubocop:disable Metrics/PerceivedComplexity
|
|
80
|
+
TABLE_TO_FOREIGN_KEYS.each_key do |table|
|
|
81
|
+
if postgresql?
|
|
82
|
+
execute "ALTER TABLE #{table} DROP CONSTRAINT IF EXISTS #{table}_pkey"
|
|
83
|
+
elsif mysql?
|
|
84
|
+
execute "ALTER TABLE #{table} MODIFY id BIGINT NOT NULL"
|
|
85
|
+
execute "ALTER TABLE #{table} DROP PRIMARY KEY"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
remove_column table, :id
|
|
89
|
+
rename_column table, :uuid, :id
|
|
90
|
+
|
|
91
|
+
if postgresql?
|
|
92
|
+
execute "ALTER TABLE #{table} ADD PRIMARY KEY (id)"
|
|
93
|
+
elsif mysql?
|
|
94
|
+
execute "ALTER TABLE #{table} MODIFY id VARCHAR(36) NOT NULL"
|
|
95
|
+
execute "ALTER TABLE #{table} ADD PRIMARY KEY (id)"
|
|
96
|
+
elsif !index_exists?(table, :id)
|
|
97
|
+
add_index table, :id, unique: true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
change_column_null table, :id, false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def swap_foreign_keys
|
|
105
|
+
FOREIGN_KEY_TO_TABLES.each do |prefix, tables|
|
|
106
|
+
tables.each do |table|
|
|
107
|
+
remove_index table, "#{prefix}_id", if_exists: true
|
|
108
|
+
remove_column table, "#{prefix}_id"
|
|
109
|
+
rename_column table, "#{prefix}_uuid", "#{prefix}_id"
|
|
110
|
+
if !index_exists?(table, "#{prefix}_id")
|
|
111
|
+
add_index table, "#{prefix}_id"
|
|
112
|
+
end
|
|
113
|
+
change_column_null table, "#{prefix}_id", false
|
|
114
|
+
add_foreign_key table, "ductwork_#{prefix}s", column: "#{prefix}_id"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/migration"
|
|
4
|
+
require "rails/generators/active_record/migration"
|
|
5
|
+
|
|
6
|
+
module Ductwork
|
|
7
|
+
class UpdateGenerator < Rails::Generators::Base
|
|
8
|
+
include ActiveRecord::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def create_files
|
|
13
|
+
if Ductwork::Availability.column_names.exclude?("pipeline_klass")
|
|
14
|
+
migration_template "db/denormalize_pipeline_klass_on_availabilities.rb",
|
|
15
|
+
"db/migrate/denormalize_pipeline_klass_on_availabilities.rb"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if Ductwork::Pipeline.column_for_attribute("id").type != :uuid
|
|
19
|
+
migration_template "db/migrate_tables_to_uuid_primary_key.rb",
|
|
20
|
+
"db/migrate/migrate_tables_to_uuid_primary_key.rb"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
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.
|
|
4
|
+
version: 0.21.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tyler Ewing
|
|
@@ -111,10 +111,14 @@ executables: []
|
|
|
111
111
|
extensions: []
|
|
112
112
|
extra_rdoc_files: []
|
|
113
113
|
files:
|
|
114
|
+
- ".saturnci/Dockerfile"
|
|
115
|
+
- ".saturnci/database.yml"
|
|
116
|
+
- ".saturnci/docker-compose.yml"
|
|
117
|
+
- ".saturnci/pre.sh"
|
|
114
118
|
- CHANGELOG-PRO.md
|
|
115
119
|
- CHANGELOG.md
|
|
116
|
-
- COMM-LICENSE.txt
|
|
117
120
|
- LICENSE.txt
|
|
121
|
+
- PRO-LICENSE.txt
|
|
118
122
|
- README.md
|
|
119
123
|
- Rakefile
|
|
120
124
|
- app/assets/images/ductwork/logo.png
|
|
@@ -141,7 +145,10 @@ files:
|
|
|
141
145
|
- lib/ductwork/dsl/branch_builder.rb
|
|
142
146
|
- lib/ductwork/dsl/definition_builder.rb
|
|
143
147
|
- lib/ductwork/engine.rb
|
|
148
|
+
- lib/ductwork/job_claim.rb
|
|
144
149
|
- lib/ductwork/machine_identifier.rb
|
|
150
|
+
- lib/ductwork/migration.rb
|
|
151
|
+
- lib/ductwork/migration_helper.rb
|
|
145
152
|
- lib/ductwork/models/availability.rb
|
|
146
153
|
- lib/ductwork/models/execution.rb
|
|
147
154
|
- lib/ductwork/models/job.rb
|
|
@@ -152,6 +159,7 @@ files:
|
|
|
152
159
|
- lib/ductwork/models/run.rb
|
|
153
160
|
- lib/ductwork/models/step.rb
|
|
154
161
|
- lib/ductwork/models/tuple.rb
|
|
162
|
+
- lib/ductwork/optimistic_locking_job_claim.rb
|
|
155
163
|
- lib/ductwork/processes/job_worker.rb
|
|
156
164
|
- lib/ductwork/processes/job_worker_runner.rb
|
|
157
165
|
- lib/ductwork/processes/launcher.rb
|
|
@@ -161,6 +169,7 @@ files:
|
|
|
161
169
|
- lib/ductwork/processes/process_supervisor_runner.rb
|
|
162
170
|
- lib/ductwork/processes/thread_supervisor.rb
|
|
163
171
|
- lib/ductwork/processes/thread_supervisor_runner.rb
|
|
172
|
+
- lib/ductwork/row_locking_job_claim.rb
|
|
164
173
|
- lib/ductwork/running_context.rb
|
|
165
174
|
- lib/ductwork/testing.rb
|
|
166
175
|
- lib/ductwork/testing/helpers.rb
|
|
@@ -180,6 +189,10 @@ files:
|
|
|
180
189
|
- lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb
|
|
181
190
|
- lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb
|
|
182
191
|
- lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb
|
|
192
|
+
- lib/generators/ductwork/update/USAGE
|
|
193
|
+
- lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb
|
|
194
|
+
- lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb
|
|
195
|
+
- lib/generators/ductwork/update/update_generator.rb
|
|
183
196
|
homepage: https://github.com/ductwork/ductwork
|
|
184
197
|
licenses:
|
|
185
198
|
- LGPL-3.0-or-later
|
|
@@ -197,14 +210,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
197
210
|
requirements:
|
|
198
211
|
- - ">="
|
|
199
212
|
- !ruby/object:Gem::Version
|
|
200
|
-
version: 3.
|
|
213
|
+
version: 3.3.0
|
|
201
214
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
202
215
|
requirements:
|
|
203
216
|
- - ">="
|
|
204
217
|
- !ruby/object:Gem::Version
|
|
205
218
|
version: '0'
|
|
206
219
|
requirements: []
|
|
207
|
-
rubygems_version: 4.0.
|
|
220
|
+
rubygems_version: 4.0.6
|
|
208
221
|
specification_version: 4
|
|
209
222
|
summary: A Ruby pipeline framework
|
|
210
223
|
test_files: []
|
|
File without changes
|