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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +217 -0
  6. data/Rakefile +20 -0
  7. data/app/controllers/good_pipeline/application_controller.rb +9 -0
  8. data/app/controllers/good_pipeline/frontends_controller.rb +31 -0
  9. data/app/controllers/good_pipeline/pipelines_controller.rb +57 -0
  10. data/app/frontend/good_pipeline/style.css +518 -0
  11. data/app/helpers/good_pipeline/pipelines_helper.rb +119 -0
  12. data/app/jobs/good_pipeline/pipeline_callback_job.rb +52 -0
  13. data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +10 -0
  14. data/app/jobs/good_pipeline/step_finished_job.rb +10 -0
  15. data/app/models/good_pipeline/chain_record.rb +18 -0
  16. data/app/models/good_pipeline/dependency_record.rb +23 -0
  17. data/app/models/good_pipeline/pipeline_record.rb +73 -0
  18. data/app/models/good_pipeline/step_record.rb +74 -0
  19. data/app/views/good_pipeline/pipelines/_chain_links.html.erb +30 -0
  20. data/app/views/good_pipeline/pipelines/_pagination.html.erb +24 -0
  21. data/app/views/good_pipeline/pipelines/_pipeline_row.html.erb +7 -0
  22. data/app/views/good_pipeline/pipelines/_steps_table.html.erb +33 -0
  23. data/app/views/good_pipeline/pipelines/definitions.html.erb +49 -0
  24. data/app/views/good_pipeline/pipelines/index.html.erb +43 -0
  25. data/app/views/good_pipeline/pipelines/show.html.erb +40 -0
  26. data/app/views/layouts/good_pipeline/application.html.erb +40 -0
  27. data/config/routes.rb +13 -0
  28. data/demo/Rakefile +5 -0
  29. data/demo/app/jobs/always_failing_job.rb +12 -0
  30. data/demo/app/jobs/application_job.rb +4 -0
  31. data/demo/app/jobs/cleanup_job.rb +5 -0
  32. data/demo/app/jobs/download_job.rb +5 -0
  33. data/demo/app/jobs/failing_job.rb +12 -0
  34. data/demo/app/jobs/publish_job.rb +5 -0
  35. data/demo/app/jobs/retryable_job.rb +19 -0
  36. data/demo/app/jobs/thumbnail_job.rb +5 -0
  37. data/demo/app/jobs/transcode_job.rb +5 -0
  38. data/demo/app/pipelines/analytics_pipeline.rb +7 -0
  39. data/demo/app/pipelines/archive_pipeline.rb +7 -0
  40. data/demo/app/pipelines/continue_test_pipeline.rb +11 -0
  41. data/demo/app/pipelines/halt_test_pipeline.rb +10 -0
  42. data/demo/app/pipelines/notification_pipeline.rb +7 -0
  43. data/demo/app/pipelines/test_pipeline.rb +5 -0
  44. data/demo/app/pipelines/video_processing_pipeline.rb +14 -0
  45. data/demo/bin/rails +6 -0
  46. data/demo/config/application.rb +18 -0
  47. data/demo/config/boot.rb +5 -0
  48. data/demo/config/database.yml +15 -0
  49. data/demo/config/environment.rb +5 -0
  50. data/demo/config/environments/development.rb +9 -0
  51. data/demo/config/environments/test.rb +10 -0
  52. data/demo/config/routes.rb +6 -0
  53. data/demo/config.ru +5 -0
  54. data/demo/db/migrate/20260319205325_create_good_jobs.rb +112 -0
  55. data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +53 -0
  56. data/demo/db/seeds.rb +153 -0
  57. data/demo/test/good_pipeline/test_chain_record.rb +29 -0
  58. data/demo/test/good_pipeline/test_cleanup.rb +93 -0
  59. data/demo/test/good_pipeline/test_coordinator.rb +286 -0
  60. data/demo/test/good_pipeline/test_dependency_record.rb +46 -0
  61. data/demo/test/good_pipeline/test_failure_metadata.rb +77 -0
  62. data/demo/test/good_pipeline/test_introspection.rb +46 -0
  63. data/demo/test/good_pipeline/test_pipeline_callback_job.rb +132 -0
  64. data/demo/test/good_pipeline/test_pipeline_reconciliation_job.rb +33 -0
  65. data/demo/test/good_pipeline/test_pipeline_record.rb +183 -0
  66. data/demo/test/good_pipeline/test_runner.rb +86 -0
  67. data/demo/test/good_pipeline/test_step_finished_job.rb +37 -0
  68. data/demo/test/good_pipeline/test_step_record.rb +208 -0
  69. data/demo/test/integration/test_concurrent_fan_in.rb +109 -0
  70. data/demo/test/integration/test_end_to_end.rb +89 -0
  71. data/demo/test/integration/test_enqueue_atomicity.rb +59 -0
  72. data/demo/test/integration/test_pipeline_chaining.rb +183 -0
  73. data/demo/test/integration/test_retry_scenarios.rb +90 -0
  74. data/demo/test/integration/test_step_finished_idempotency.rb +38 -0
  75. data/demo/test/test_helper.rb +71 -0
  76. data/dev-docker-compose.yml +16 -0
  77. data/docs/.vitepress/config.mts +66 -0
  78. data/docs/.vitepress/theme/custom.css +21 -0
  79. data/docs/.vitepress/theme/index.ts +4 -0
  80. data/docs/architecture.md +184 -0
  81. data/docs/callbacks.md +66 -0
  82. data/docs/cleanup.md +45 -0
  83. data/docs/dag-validation.md +88 -0
  84. data/docs/dashboard.md +66 -0
  85. data/docs/defining-pipelines.md +167 -0
  86. data/docs/failure-strategies.md +138 -0
  87. data/docs/getting-started.md +77 -0
  88. data/docs/index.md +23 -0
  89. data/docs/introduction.md +42 -0
  90. data/docs/monitoring.md +103 -0
  91. data/docs/package-lock.json +2510 -0
  92. data/docs/package.json +11 -0
  93. data/docs/pipeline-chaining.md +104 -0
  94. data/docs/public/screenshots/definitions.png +0 -0
  95. data/docs/public/screenshots/index.png +0 -0
  96. data/docs/public/screenshots/show.png +0 -0
  97. data/docs/screenshots/definitions.png +0 -0
  98. data/docs/screenshots/index.png +0 -0
  99. data/docs/screenshots/show.png +0 -0
  100. data/lib/generators/good_pipeline/install/install_generator.rb +20 -0
  101. data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +51 -0
  102. data/lib/good_pipeline/chain.rb +54 -0
  103. data/lib/good_pipeline/chain_coordinator.rb +53 -0
  104. data/lib/good_pipeline/coordinator.rb +176 -0
  105. data/lib/good_pipeline/cycle_detector.rb +36 -0
  106. data/lib/good_pipeline/engine.rb +23 -0
  107. data/lib/good_pipeline/errors.rb +11 -0
  108. data/lib/good_pipeline/failure_metadata.rb +29 -0
  109. data/lib/good_pipeline/graph_validator.rb +71 -0
  110. data/lib/good_pipeline/pipeline.rb +122 -0
  111. data/lib/good_pipeline/runner.rb +63 -0
  112. data/lib/good_pipeline/step_definition.rb +18 -0
  113. data/lib/good_pipeline/version.rb +5 -0
  114. data/lib/good_pipeline.rb +45 -0
  115. data/mise.toml +10 -0
  116. data/sig/good_pipeline.rbs +4 -0
  117. metadata +204 -0
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __dir__)
4
+ require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
5
+ $LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "application"
4
+
5
+ Rails.application.initialize!
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.configure do
4
+ config.cache_classes = false
5
+ config.eager_load = false
6
+ config.consider_all_requests_local = true
7
+ config.active_support.deprecation = :log
8
+ config.good_job.execution_mode = :async
9
+ end
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ mount GoodJob::Engine => "/good_job"
5
+ mount GoodPipeline::Engine => "/good_pipeline"
6
+ end
data/demo/config.ru ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config/environment"
4
+
5
+ run Rails.application
@@ -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