good_pipeline 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +217 -0
- data/Rakefile +20 -0
- data/app/controllers/good_pipeline/application_controller.rb +9 -0
- data/app/controllers/good_pipeline/frontends_controller.rb +31 -0
- data/app/controllers/good_pipeline/pipelines_controller.rb +57 -0
- data/app/frontend/good_pipeline/style.css +518 -0
- data/app/helpers/good_pipeline/pipelines_helper.rb +119 -0
- data/app/jobs/good_pipeline/pipeline_callback_job.rb +52 -0
- data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +10 -0
- data/app/jobs/good_pipeline/step_finished_job.rb +10 -0
- data/app/models/good_pipeline/chain_record.rb +18 -0
- data/app/models/good_pipeline/dependency_record.rb +23 -0
- data/app/models/good_pipeline/pipeline_record.rb +73 -0
- data/app/models/good_pipeline/step_record.rb +74 -0
- data/app/views/good_pipeline/pipelines/_chain_links.html.erb +30 -0
- data/app/views/good_pipeline/pipelines/_pagination.html.erb +24 -0
- data/app/views/good_pipeline/pipelines/_pipeline_row.html.erb +7 -0
- data/app/views/good_pipeline/pipelines/_steps_table.html.erb +33 -0
- data/app/views/good_pipeline/pipelines/definitions.html.erb +49 -0
- data/app/views/good_pipeline/pipelines/index.html.erb +43 -0
- data/app/views/good_pipeline/pipelines/show.html.erb +40 -0
- data/app/views/layouts/good_pipeline/application.html.erb +40 -0
- data/config/routes.rb +13 -0
- data/demo/Rakefile +5 -0
- data/demo/app/jobs/always_failing_job.rb +12 -0
- data/demo/app/jobs/application_job.rb +4 -0
- data/demo/app/jobs/cleanup_job.rb +5 -0
- data/demo/app/jobs/download_job.rb +5 -0
- data/demo/app/jobs/failing_job.rb +12 -0
- data/demo/app/jobs/publish_job.rb +5 -0
- data/demo/app/jobs/retryable_job.rb +19 -0
- data/demo/app/jobs/thumbnail_job.rb +5 -0
- data/demo/app/jobs/transcode_job.rb +5 -0
- data/demo/app/pipelines/analytics_pipeline.rb +7 -0
- data/demo/app/pipelines/archive_pipeline.rb +7 -0
- data/demo/app/pipelines/continue_test_pipeline.rb +11 -0
- data/demo/app/pipelines/halt_test_pipeline.rb +10 -0
- data/demo/app/pipelines/notification_pipeline.rb +7 -0
- data/demo/app/pipelines/test_pipeline.rb +5 -0
- data/demo/app/pipelines/video_processing_pipeline.rb +14 -0
- data/demo/bin/rails +6 -0
- data/demo/config/application.rb +18 -0
- data/demo/config/boot.rb +5 -0
- data/demo/config/database.yml +15 -0
- data/demo/config/environment.rb +5 -0
- data/demo/config/environments/development.rb +9 -0
- data/demo/config/environments/test.rb +10 -0
- data/demo/config/routes.rb +6 -0
- data/demo/config.ru +5 -0
- data/demo/db/migrate/20260319205325_create_good_jobs.rb +112 -0
- data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +53 -0
- data/demo/db/seeds.rb +153 -0
- data/demo/test/good_pipeline/test_chain_record.rb +29 -0
- data/demo/test/good_pipeline/test_cleanup.rb +93 -0
- data/demo/test/good_pipeline/test_coordinator.rb +286 -0
- data/demo/test/good_pipeline/test_dependency_record.rb +46 -0
- data/demo/test/good_pipeline/test_failure_metadata.rb +77 -0
- data/demo/test/good_pipeline/test_introspection.rb +46 -0
- data/demo/test/good_pipeline/test_pipeline_callback_job.rb +132 -0
- data/demo/test/good_pipeline/test_pipeline_reconciliation_job.rb +33 -0
- data/demo/test/good_pipeline/test_pipeline_record.rb +183 -0
- data/demo/test/good_pipeline/test_runner.rb +86 -0
- data/demo/test/good_pipeline/test_step_finished_job.rb +37 -0
- data/demo/test/good_pipeline/test_step_record.rb +208 -0
- data/demo/test/integration/test_concurrent_fan_in.rb +109 -0
- data/demo/test/integration/test_end_to_end.rb +89 -0
- data/demo/test/integration/test_enqueue_atomicity.rb +59 -0
- data/demo/test/integration/test_pipeline_chaining.rb +183 -0
- data/demo/test/integration/test_retry_scenarios.rb +90 -0
- data/demo/test/integration/test_step_finished_idempotency.rb +38 -0
- data/demo/test/test_helper.rb +71 -0
- data/dev-docker-compose.yml +16 -0
- data/docs/.vitepress/config.mts +66 -0
- data/docs/.vitepress/theme/custom.css +21 -0
- data/docs/.vitepress/theme/index.ts +4 -0
- data/docs/architecture.md +184 -0
- data/docs/callbacks.md +66 -0
- data/docs/cleanup.md +45 -0
- data/docs/dag-validation.md +88 -0
- data/docs/dashboard.md +66 -0
- data/docs/defining-pipelines.md +167 -0
- data/docs/failure-strategies.md +138 -0
- data/docs/getting-started.md +77 -0
- data/docs/index.md +23 -0
- data/docs/introduction.md +42 -0
- data/docs/monitoring.md +103 -0
- data/docs/package-lock.json +2510 -0
- data/docs/package.json +11 -0
- data/docs/pipeline-chaining.md +104 -0
- data/docs/public/screenshots/definitions.png +0 -0
- data/docs/public/screenshots/index.png +0 -0
- data/docs/public/screenshots/show.png +0 -0
- data/docs/screenshots/definitions.png +0 -0
- data/docs/screenshots/index.png +0 -0
- data/docs/screenshots/show.png +0 -0
- data/lib/generators/good_pipeline/install/install_generator.rb +20 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +51 -0
- data/lib/good_pipeline/chain.rb +54 -0
- data/lib/good_pipeline/chain_coordinator.rb +53 -0
- data/lib/good_pipeline/coordinator.rb +176 -0
- data/lib/good_pipeline/cycle_detector.rb +36 -0
- data/lib/good_pipeline/engine.rb +23 -0
- data/lib/good_pipeline/errors.rb +11 -0
- data/lib/good_pipeline/failure_metadata.rb +29 -0
- data/lib/good_pipeline/graph_validator.rb +71 -0
- data/lib/good_pipeline/pipeline.rb +122 -0
- data/lib/good_pipeline/runner.rb +63 -0
- data/lib/good_pipeline/step_definition.rb +18 -0
- data/lib/good_pipeline/version.rb +5 -0
- data/lib/good_pipeline.rb +45 -0
- data/mise.toml +10 -0
- data/sig/good_pipeline.rbs +4 -0
- metadata +204 -0
data/demo/config/boot.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
default: &default
|
|
2
|
+
adapter: postgresql
|
|
3
|
+
encoding: unicode
|
|
4
|
+
host: localhost
|
|
5
|
+
username: postgres
|
|
6
|
+
password: postgres
|
|
7
|
+
pool: 20
|
|
8
|
+
|
|
9
|
+
development:
|
|
10
|
+
<<: *default
|
|
11
|
+
database: good_pipeline_development
|
|
12
|
+
|
|
13
|
+
test:
|
|
14
|
+
<<: *default
|
|
15
|
+
database: good_pipeline_test
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Rails.application.configure do
|
|
4
|
+
config.cache_classes = true
|
|
5
|
+
config.eager_load = false
|
|
6
|
+
config.consider_all_requests_local = true
|
|
7
|
+
config.action_controller.perform_caching = false if config.respond_to?(:action_controller)
|
|
8
|
+
config.active_support.deprecation = :stderr
|
|
9
|
+
config.good_job.execution_mode = :external
|
|
10
|
+
end
|
data/demo/config.ru
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateGoodJobs < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
# Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support
|
|
6
|
+
# enable_extension 'pgcrypto'
|
|
7
|
+
|
|
8
|
+
create_table :good_jobs, id: :uuid do |t|
|
|
9
|
+
t.text :queue_name
|
|
10
|
+
t.integer :priority
|
|
11
|
+
t.jsonb :serialized_params
|
|
12
|
+
t.datetime :scheduled_at
|
|
13
|
+
t.datetime :performed_at
|
|
14
|
+
t.datetime :finished_at
|
|
15
|
+
t.text :error
|
|
16
|
+
|
|
17
|
+
t.timestamps
|
|
18
|
+
|
|
19
|
+
t.uuid :active_job_id
|
|
20
|
+
t.text :concurrency_key
|
|
21
|
+
t.text :cron_key
|
|
22
|
+
t.uuid :retried_good_job_id
|
|
23
|
+
t.datetime :cron_at
|
|
24
|
+
|
|
25
|
+
t.uuid :batch_id
|
|
26
|
+
t.uuid :batch_callback_id
|
|
27
|
+
|
|
28
|
+
t.boolean :is_discrete
|
|
29
|
+
t.integer :executions_count
|
|
30
|
+
t.text :job_class
|
|
31
|
+
t.integer :error_event, limit: 2
|
|
32
|
+
t.text :labels, array: true
|
|
33
|
+
t.uuid :locked_by_id
|
|
34
|
+
t.datetime :locked_at
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
create_table :good_job_batches, id: :uuid do |t|
|
|
38
|
+
t.timestamps
|
|
39
|
+
t.text :description
|
|
40
|
+
t.jsonb :serialized_properties
|
|
41
|
+
t.text :on_finish
|
|
42
|
+
t.text :on_success
|
|
43
|
+
t.text :on_discard
|
|
44
|
+
t.text :callback_queue_name
|
|
45
|
+
t.integer :callback_priority
|
|
46
|
+
t.datetime :enqueued_at
|
|
47
|
+
t.datetime :discarded_at
|
|
48
|
+
t.datetime :finished_at
|
|
49
|
+
t.datetime :jobs_finished_at
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
create_table :good_job_executions, id: :uuid do |t|
|
|
53
|
+
t.timestamps
|
|
54
|
+
|
|
55
|
+
t.uuid :active_job_id, null: false
|
|
56
|
+
t.text :job_class
|
|
57
|
+
t.text :queue_name
|
|
58
|
+
t.jsonb :serialized_params
|
|
59
|
+
t.datetime :scheduled_at
|
|
60
|
+
t.datetime :finished_at
|
|
61
|
+
t.text :error
|
|
62
|
+
t.integer :error_event, limit: 2
|
|
63
|
+
t.text :error_backtrace, array: true
|
|
64
|
+
t.uuid :process_id
|
|
65
|
+
t.interval :duration
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
create_table :good_job_processes, id: :uuid do |t|
|
|
69
|
+
t.timestamps
|
|
70
|
+
t.jsonb :state
|
|
71
|
+
t.integer :lock_type, limit: 2
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
create_table :good_job_settings, id: :uuid do |t|
|
|
75
|
+
t.timestamps
|
|
76
|
+
t.text :key
|
|
77
|
+
t.jsonb :value
|
|
78
|
+
t.index :key, unique: true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: :index_good_jobs_on_scheduled_at
|
|
82
|
+
add_index :good_jobs, %i[queue_name scheduled_at], where: "(finished_at IS NULL)",
|
|
83
|
+
name: :index_good_jobs_on_queue_name_and_scheduled_at
|
|
84
|
+
add_index :good_jobs, %i[active_job_id created_at], name: :index_good_jobs_on_active_job_id_and_created_at
|
|
85
|
+
add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)",
|
|
86
|
+
name: :index_good_jobs_on_concurrency_key_when_unfinished
|
|
87
|
+
add_index :good_jobs, %i[concurrency_key created_at], name: :index_good_jobs_on_concurrency_key_and_created_at
|
|
88
|
+
add_index :good_jobs, %i[cron_key created_at], where: "(cron_key IS NOT NULL)",
|
|
89
|
+
name: :index_good_jobs_on_cron_key_and_created_at_cond
|
|
90
|
+
add_index :good_jobs, %i[cron_key cron_at], where: "(cron_key IS NOT NULL)", unique: true,
|
|
91
|
+
name: :index_good_jobs_on_cron_key_and_cron_at_cond
|
|
92
|
+
add_index :good_jobs, [:finished_at], where: "finished_at IS NOT NULL",
|
|
93
|
+
name: :index_good_jobs_jobs_on_finished_at_only
|
|
94
|
+
add_index :good_jobs, %i[priority created_at], order: { priority: "DESC NULLS LAST", created_at: :asc },
|
|
95
|
+
where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished
|
|
96
|
+
add_index :good_jobs, %i[priority created_at], order: { priority: "ASC NULLS LAST", created_at: :asc },
|
|
97
|
+
where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup
|
|
98
|
+
add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL"
|
|
99
|
+
add_index :good_jobs, [:batch_callback_id], where: "batch_callback_id IS NOT NULL"
|
|
100
|
+
add_index :good_jobs, :job_class, name: :index_good_jobs_on_job_class
|
|
101
|
+
add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", name: :index_good_jobs_on_labels
|
|
102
|
+
|
|
103
|
+
add_index :good_job_executions, %i[active_job_id created_at],
|
|
104
|
+
name: :index_good_job_executions_on_active_job_id_and_created_at
|
|
105
|
+
add_index :good_jobs, %i[priority scheduled_at], order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
|
|
106
|
+
where: "finished_at IS NULL AND locked_by_id IS NULL", name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked
|
|
107
|
+
add_index :good_jobs, :locked_by_id,
|
|
108
|
+
where: "locked_by_id IS NOT NULL", name: "index_good_jobs_on_locked_by_id"
|
|
109
|
+
add_index :good_job_executions, %i[process_id created_at],
|
|
110
|
+
name: :index_good_job_executions_on_process_id_and_created_at
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateGoodPipelineTables < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
# Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support
|
|
6
|
+
# enable_extension 'pgcrypto'
|
|
7
|
+
|
|
8
|
+
create_table :good_pipeline_pipelines, id: :uuid do |t|
|
|
9
|
+
t.string :type, null: false
|
|
10
|
+
t.jsonb :params, null: false, default: {}
|
|
11
|
+
t.string :status, null: false, default: "pending"
|
|
12
|
+
t.boolean :halt_triggered, null: false, default: false
|
|
13
|
+
t.uuid :good_job_batch_id
|
|
14
|
+
t.string :on_failure_strategy, null: false, default: "halt"
|
|
15
|
+
t.datetime :callbacks_dispatched_at
|
|
16
|
+
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
add_index :good_pipeline_pipelines, :status
|
|
21
|
+
|
|
22
|
+
create_table :good_pipeline_steps, id: :uuid do |t|
|
|
23
|
+
t.references :pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
24
|
+
t.string :key, null: false
|
|
25
|
+
t.string :job_class, null: false
|
|
26
|
+
t.jsonb :params, null: false, default: {}
|
|
27
|
+
t.string :coordination_status, null: false, default: "pending"
|
|
28
|
+
t.string :on_failure_strategy
|
|
29
|
+
t.string :queue
|
|
30
|
+
t.integer :priority
|
|
31
|
+
t.uuid :good_job_batch_id
|
|
32
|
+
t.uuid :good_job_id
|
|
33
|
+
t.integer :attempts
|
|
34
|
+
t.string :error_class
|
|
35
|
+
t.text :error_message
|
|
36
|
+
|
|
37
|
+
t.timestamps
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
add_index :good_pipeline_steps, %i[pipeline_id key], unique: true
|
|
41
|
+
|
|
42
|
+
create_table :good_pipeline_dependencies do |t|
|
|
43
|
+
t.references :pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
44
|
+
t.references :step, null: false, foreign_key: { to_table: :good_pipeline_steps }, type: :uuid
|
|
45
|
+
t.references :depends_on_step, null: false, foreign_key: { to_table: :good_pipeline_steps }, type: :uuid
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
create_table :good_pipeline_chains, id: :uuid do |t|
|
|
49
|
+
t.references :upstream_pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
50
|
+
t.references :downstream_pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/demo/db/seeds.rb
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
puts "Seeding GoodPipeline demo data..."
|
|
4
|
+
|
|
5
|
+
GoodPipeline::ChainRecord.delete_all
|
|
6
|
+
GoodPipeline::DependencyRecord.delete_all
|
|
7
|
+
GoodPipeline::StepRecord.delete_all
|
|
8
|
+
GoodPipeline::PipelineRecord.delete_all
|
|
9
|
+
|
|
10
|
+
NOW = Time.current # rubocop:disable Lint/ConstantDefinitionInBlock
|
|
11
|
+
|
|
12
|
+
def create_pipeline(type:, status:, params:, strategy: "halt", age:, duration: nil, halt_triggered: false) # rubocop:disable Metrics/ParameterLists
|
|
13
|
+
pipeline = GoodPipeline::PipelineRecord.create!(
|
|
14
|
+
type: type, status: status, params: params,
|
|
15
|
+
on_failure_strategy: strategy, halt_triggered: halt_triggered
|
|
16
|
+
)
|
|
17
|
+
pipeline.update_columns(
|
|
18
|
+
created_at: NOW - age,
|
|
19
|
+
updated_at: duration ? NOW - age + duration : NOW - age
|
|
20
|
+
)
|
|
21
|
+
pipeline
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add_steps(pipeline, *step_defs)
|
|
25
|
+
records = {}
|
|
26
|
+
step_defs.each do |definition|
|
|
27
|
+
records[definition[:key]] = GoodPipeline::StepRecord.create!(
|
|
28
|
+
pipeline: pipeline, key: definition[:key], job_class: definition[:job],
|
|
29
|
+
coordination_status: definition.fetch(:status, "succeeded"),
|
|
30
|
+
error_class: definition[:error_class], error_message: definition[:error_message]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
records
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add_edges(pipeline, steps, *edges)
|
|
37
|
+
edges.each do |from, to|
|
|
38
|
+
GoodPipeline::DependencyRecord.create!(pipeline: pipeline, step: steps[to], depends_on_step: steps[from])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 1. VideoProcessingPipeline (succeeded) — fan-out + fan-in DAG
|
|
43
|
+
video = create_pipeline(type: "VideoProcessingPipeline", status: "succeeded",
|
|
44
|
+
params: { video_id: 8842, format: "mp4", resolution: "1080p" }, age: 2.hours, duration: 1.hour)
|
|
45
|
+
video_steps = add_steps(video,
|
|
46
|
+
{ key: "download", job: "DownloadJob" },
|
|
47
|
+
{ key: "transcode", job: "TranscodeJob" },
|
|
48
|
+
{ key: "thumbnail", job: "ThumbnailJob" },
|
|
49
|
+
{ key: "publish", job: "PublishJob" },
|
|
50
|
+
{ key: "cleanup", job: "CleanupJob" })
|
|
51
|
+
add_edges(video, video_steps,
|
|
52
|
+
%w[download transcode], %w[download thumbnail],
|
|
53
|
+
%w[transcode publish], %w[thumbnail publish],
|
|
54
|
+
%w[publish cleanup])
|
|
55
|
+
|
|
56
|
+
# 2. DataIngestionPipeline (succeeded) — linear chain
|
|
57
|
+
ingest = create_pipeline(type: "DataIngestionPipeline", status: "succeeded",
|
|
58
|
+
params: { source: "s3://data-lake/events/2026-03-20", dataset: "user_events" },
|
|
59
|
+
strategy: "continue", age: 5.hours, duration: 1.hour)
|
|
60
|
+
ingest_steps = add_steps(ingest,
|
|
61
|
+
{ key: "extract", job: "ExtractJob" }, { key: "validate", job: "ValidateJob" },
|
|
62
|
+
{ key: "transform", job: "TransformJob" }, { key: "load", job: "LoadJob" })
|
|
63
|
+
add_edges(ingest, ingest_steps, %w[extract validate], %w[validate transform], %w[transform load])
|
|
64
|
+
|
|
65
|
+
# 3. ReportGenerationPipeline (running) — partially complete
|
|
66
|
+
report = create_pipeline(type: "ReportGenerationPipeline", status: "running",
|
|
67
|
+
params: { report_type: "monthly_revenue", month: "2026-02" }, age: 15.minutes)
|
|
68
|
+
report_steps = add_steps(report,
|
|
69
|
+
{ key: "query_data", job: "QueryDataJob" },
|
|
70
|
+
{ key: "aggregate", job: "AggregateJob", status: "enqueued" },
|
|
71
|
+
{ key: "render_pdf", job: "RenderPdfJob", status: "pending" },
|
|
72
|
+
{ key: "email_report", job: "EmailReportJob", status: "pending" })
|
|
73
|
+
add_edges(report, report_steps, %w[query_data aggregate], %w[aggregate render_pdf], %w[render_pdf email_report])
|
|
74
|
+
|
|
75
|
+
# 4. UserOnboardingPipeline (succeeded) — fan-out
|
|
76
|
+
onboarding = create_pipeline(type: "UserOnboardingPipeline", status: "succeeded",
|
|
77
|
+
params: { user_id: 29_451, plan: "pro" }, age: 30.minutes, duration: 5.minutes)
|
|
78
|
+
onboarding_steps = add_steps(onboarding,
|
|
79
|
+
{ key: "provision_account", job: "ProvisionAccountJob" },
|
|
80
|
+
{ key: "send_welcome_email", job: "SendWelcomeEmailJob" },
|
|
81
|
+
{ key: "sync_crm", job: "SyncCrmJob" })
|
|
82
|
+
add_edges(onboarding, onboarding_steps, %w[provision_account send_welcome_email], %w[provision_account sync_crm])
|
|
83
|
+
|
|
84
|
+
# 5. PaymentReconciliationPipeline (failed) — with error
|
|
85
|
+
payment = create_pipeline(type: "PaymentReconciliationPipeline", status: "failed",
|
|
86
|
+
params: { batch_date: "2026-03-19", gateway: "stripe" },
|
|
87
|
+
strategy: "continue", age: 1.hour, duration: 15.minutes)
|
|
88
|
+
payment_steps = add_steps(payment,
|
|
89
|
+
{ key: "fetch_transactions", job: "FetchTransactionsJob" },
|
|
90
|
+
{ key: "match_records", job: "MatchRecordsJob", status: "failed",
|
|
91
|
+
error_class: "ReconciliationError",
|
|
92
|
+
error_message: "Found 23 unmatched transactions totaling $4,892.50" },
|
|
93
|
+
{ key: "generate_report", job: "GenerateReportJob", status: "skipped" })
|
|
94
|
+
add_edges(payment, payment_steps, %w[fetch_transactions match_records], %w[match_records generate_report])
|
|
95
|
+
|
|
96
|
+
# 6. OrderFulfillmentPipeline → CustomerNotificationPipeline (chain)
|
|
97
|
+
order = create_pipeline(type: "OrderFulfillmentPipeline", status: "succeeded",
|
|
98
|
+
params: { order_id: 78_332, warehouse: "us-east-1" }, age: 3.hours, duration: 30.minutes)
|
|
99
|
+
order_steps = add_steps(order,
|
|
100
|
+
{ key: "reserve_inventory", job: "ReserveInventoryJob" },
|
|
101
|
+
{ key: "pick_and_pack", job: "PickAndPackJob" },
|
|
102
|
+
{ key: "ship", job: "ShipJob" },
|
|
103
|
+
{ key: "update_tracking", job: "UpdateTrackingJob" })
|
|
104
|
+
add_edges(order, order_steps,
|
|
105
|
+
%w[reserve_inventory pick_and_pack], %w[pick_and_pack ship], %w[ship update_tracking])
|
|
106
|
+
|
|
107
|
+
notify = create_pipeline(type: "CustomerNotificationPipeline", status: "succeeded",
|
|
108
|
+
params: { order_id: 78_332, channel: "email" }, age: 2.5.hours, duration: 30.minutes)
|
|
109
|
+
add_steps(notify, { key: "send_shipping_email", job: "SendShippingEmailJob" }, { key: "send_sms", job: "SendSmsJob" })
|
|
110
|
+
GoodPipeline::ChainRecord.create!(upstream_pipeline: order, downstream_pipeline: notify)
|
|
111
|
+
|
|
112
|
+
# 7. ImageResizePipeline (halted) — first step failed
|
|
113
|
+
image = create_pipeline(type: "ImageResizePipeline", status: "halted", halt_triggered: true,
|
|
114
|
+
params: { image_id: 55_210, sizes: %w[sm md lg xl] }, age: 45.minutes, duration: 5.minutes)
|
|
115
|
+
image_steps = add_steps(image,
|
|
116
|
+
{ key: "download_original", job: "DownloadOriginalJob", status: "failed",
|
|
117
|
+
error_class: "Aws::S3::Errors::NoSuchKey", error_message: "The specified key does not exist." },
|
|
118
|
+
{ key: "resize", job: "ResizeJob", status: "skipped" },
|
|
119
|
+
{ key: "upload_resized", job: "UploadResizedJob", status: "skipped" })
|
|
120
|
+
add_edges(image, image_steps, %w[download_original resize], %w[resize upload_resized])
|
|
121
|
+
|
|
122
|
+
# 8. InvoiceProcessingPipeline (succeeded) — linear chain
|
|
123
|
+
invoice = create_pipeline(type: "InvoiceProcessingPipeline", status: "succeeded",
|
|
124
|
+
params: { invoice_id: 11_298, vendor: "Acme Corp" }, age: 6.hours, duration: 30.minutes)
|
|
125
|
+
invoice_steps = add_steps(invoice,
|
|
126
|
+
{ key: "parse_pdf", job: "ParsePdfJob" }, { key: "validate_line_items", job: "ValidateLineItemsJob" },
|
|
127
|
+
{ key: "auto_approve", job: "AutoApproveJob" }, { key: "post_to_ledger", job: "PostToLedgerJob" })
|
|
128
|
+
add_edges(invoice, invoice_steps, %w[parse_pdf validate_line_items], %w[validate_line_items auto_approve], %w[auto_approve post_to_ledger])
|
|
129
|
+
|
|
130
|
+
# 9. VideoProcessingPipeline (succeeded, older) — second execution
|
|
131
|
+
video2 = create_pipeline(type: "VideoProcessingPipeline", status: "succeeded",
|
|
132
|
+
params: { video_id: 7_651, format: "webm", resolution: "720p" }, age: 8.hours, duration: 1.hour)
|
|
133
|
+
video2_steps = add_steps(video2,
|
|
134
|
+
{ key: "download", job: "DownloadJob" }, { key: "transcode", job: "TranscodeJob" },
|
|
135
|
+
{ key: "thumbnail", job: "ThumbnailJob" }, { key: "publish", job: "PublishJob" },
|
|
136
|
+
{ key: "cleanup", job: "CleanupJob" })
|
|
137
|
+
add_edges(video2, video2_steps,
|
|
138
|
+
%w[download transcode], %w[download thumbnail],
|
|
139
|
+
%w[transcode publish], %w[thumbnail publish],
|
|
140
|
+
%w[publish cleanup])
|
|
141
|
+
|
|
142
|
+
# 10. DataIngestionPipeline (running) — just started
|
|
143
|
+
ingest2 = create_pipeline(type: "DataIngestionPipeline", status: "running",
|
|
144
|
+
params: { source: "s3://data-lake/events/2026-03-21", dataset: "page_views" },
|
|
145
|
+
strategy: "continue", age: 3.minutes)
|
|
146
|
+
ingest2_steps = add_steps(ingest2,
|
|
147
|
+
{ key: "extract", job: "ExtractJob" },
|
|
148
|
+
{ key: "validate", job: "ValidateJob", status: "enqueued" },
|
|
149
|
+
{ key: "transform", job: "TransformJob", status: "pending" },
|
|
150
|
+
{ key: "load", job: "LoadJob", status: "pending" })
|
|
151
|
+
add_edges(ingest2, ingest2_steps, %w[extract validate], %w[validate transform], %w[transform load])
|
|
152
|
+
|
|
153
|
+
puts "Done! #{GoodPipeline::PipelineRecord.count} pipelines seeded."
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestChainRecord < ActiveSupport::TestCase
|
|
6
|
+
# --- Pipeline chain navigation ---
|
|
7
|
+
#
|
|
8
|
+
# A -> B -> C
|
|
9
|
+
|
|
10
|
+
def test_serial_chain_navigation
|
|
11
|
+
pipeline_a = create_pipeline(type: "PipelineA")
|
|
12
|
+
pipeline_b = create_pipeline(type: "PipelineB")
|
|
13
|
+
pipeline_c = create_pipeline(type: "PipelineC")
|
|
14
|
+
|
|
15
|
+
GoodPipeline::ChainRecord.create!(upstream_pipeline: pipeline_a, downstream_pipeline: pipeline_b)
|
|
16
|
+
GoodPipeline::ChainRecord.create!(upstream_pipeline: pipeline_b, downstream_pipeline: pipeline_c)
|
|
17
|
+
|
|
18
|
+
# A's downstream: B
|
|
19
|
+
assert_equal [pipeline_b.id], pipeline_a.downstream_chains.map(&:downstream_pipeline_id)
|
|
20
|
+
|
|
21
|
+
# B's upstream: A, B's downstream: C
|
|
22
|
+
assert_equal [pipeline_a.id], pipeline_b.upstream_chains.map(&:upstream_pipeline_id)
|
|
23
|
+
assert_equal [pipeline_c.id], pipeline_b.downstream_chains.map(&:downstream_pipeline_id)
|
|
24
|
+
|
|
25
|
+
# C's upstream: B
|
|
26
|
+
assert_equal [pipeline_b.id], pipeline_c.upstream_chains.map(&:upstream_pipeline_id)
|
|
27
|
+
assert_empty pipeline_c.downstream_chains
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class TestCleanup < ActiveSupport::TestCase
|
|
6
|
+
setup do
|
|
7
|
+
@now = Time.current
|
|
8
|
+
|
|
9
|
+
# Old terminal pipeline (should be cleaned up)
|
|
10
|
+
@old_pipeline = GoodPipeline::PipelineRecord.create!(
|
|
11
|
+
type: "TestPipeline", status: "succeeded", on_failure_strategy: "halt"
|
|
12
|
+
)
|
|
13
|
+
@old_pipeline.update_columns(updated_at: @now - 30.days)
|
|
14
|
+
|
|
15
|
+
@old_step = GoodPipeline::StepRecord.create!(
|
|
16
|
+
pipeline: @old_pipeline, key: "step_a", job_class: "DownloadJob", coordination_status: "succeeded"
|
|
17
|
+
)
|
|
18
|
+
@old_dependency = GoodPipeline::DependencyRecord.create!(
|
|
19
|
+
pipeline: @old_pipeline, step: @old_step, depends_on_step: @old_step
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Old running pipeline (should NOT be cleaned up)
|
|
23
|
+
@running_pipeline = GoodPipeline::PipelineRecord.create!(
|
|
24
|
+
type: "TestPipeline", status: "running", on_failure_strategy: "halt"
|
|
25
|
+
)
|
|
26
|
+
@running_pipeline.update_columns(updated_at: @now - 30.days)
|
|
27
|
+
GoodPipeline::StepRecord.create!(
|
|
28
|
+
pipeline: @running_pipeline, key: "step_a", job_class: "DownloadJob", coordination_status: "enqueued"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Recent terminal pipeline (should NOT be cleaned up)
|
|
32
|
+
@recent_pipeline = GoodPipeline::PipelineRecord.create!(
|
|
33
|
+
type: "TestPipeline", status: "failed", on_failure_strategy: "halt"
|
|
34
|
+
)
|
|
35
|
+
GoodPipeline::StepRecord.create!(
|
|
36
|
+
pipeline: @recent_pipeline, key: "step_a", job_class: "DownloadJob", coordination_status: "failed"
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
test "cleans old terminal pipelines" do
|
|
41
|
+
GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 14.days)
|
|
42
|
+
|
|
43
|
+
assert_nil GoodPipeline::PipelineRecord.find_by(id: @old_pipeline.id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
test "cleans associated steps and dependencies" do
|
|
47
|
+
GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 14.days)
|
|
48
|
+
|
|
49
|
+
assert_nil GoodPipeline::StepRecord.find_by(id: @old_step.id)
|
|
50
|
+
assert_nil GoodPipeline::DependencyRecord.find_by(id: @old_dependency.id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
test "preserves running pipelines" do
|
|
54
|
+
GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 14.days)
|
|
55
|
+
|
|
56
|
+
assert_not_nil GoodPipeline::PipelineRecord.find_by(id: @running_pipeline.id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
test "preserves recent terminal pipelines" do
|
|
60
|
+
GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 14.days)
|
|
61
|
+
|
|
62
|
+
assert_not_nil GoodPipeline::PipelineRecord.find_by(id: @recent_pipeline.id)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
test "cleans chain records" do
|
|
66
|
+
downstream = GoodPipeline::PipelineRecord.create!(
|
|
67
|
+
type: "TestPipeline", status: "succeeded", on_failure_strategy: "halt"
|
|
68
|
+
)
|
|
69
|
+
downstream.update_columns(updated_at: @now - 30.days)
|
|
70
|
+
|
|
71
|
+
chain = GoodPipeline::ChainRecord.create!(
|
|
72
|
+
upstream_pipeline: @old_pipeline, downstream_pipeline: downstream
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 14.days)
|
|
76
|
+
|
|
77
|
+
assert_nil GoodPipeline::ChainRecord.find_by(id: chain.id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
test "noop when nothing to clean" do
|
|
81
|
+
GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 365.days)
|
|
82
|
+
|
|
83
|
+
assert_equal 3, GoodPipeline::PipelineRecord.count
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
test "triggers cleanup when GoodJob cleans preserved jobs" do
|
|
87
|
+
GoodJob.cleanup_preserved_jobs(older_than: 14.days)
|
|
88
|
+
|
|
89
|
+
assert_nil GoodPipeline::PipelineRecord.find_by(id: @old_pipeline.id)
|
|
90
|
+
assert_not_nil GoodPipeline::PipelineRecord.find_by(id: @running_pipeline.id)
|
|
91
|
+
assert_not_nil GoodPipeline::PipelineRecord.find_by(id: @recent_pipeline.id)
|
|
92
|
+
end
|
|
93
|
+
end
|