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,184 @@
1
+ # Architecture
2
+
3
+ This page describes GoodPipeline's internal architecture for contributors and advanced users who want to understand how the system works.
4
+
5
+ ## Layer diagram
6
+
7
+ ```
8
+ ┌─────────────────────────────────────────────────────────────┐
9
+ │ DSL Layer │
10
+ │ Pipeline.configure defines the DAG topology │
11
+ │ Step keys are graph identity; job classes are impl │
12
+ └─────────────────────────┬───────────────────────────────────┘
13
+
14
+ ┌─────────────────────────▼───────────────────────────────────┐
15
+ │ Validation Layer │
16
+ │ DAG validated at instantiation time, before DB writes │
17
+ │ Cycles, unknown keys, duplicates rejected upfront │
18
+ └─────────────────────────┬───────────────────────────────────┘
19
+
20
+ ┌─────────────────────────▼───────────────────────────────────┐
21
+ │ State Layer │
22
+ │ Postgres tables are the authoritative source of truth │
23
+ │ coordination_status is the sole input for decisions │
24
+ │ halt_triggered flag drives :halted derivation │
25
+ └─────────────────────────┬───────────────────────────────────┘
26
+
27
+ ┌─────────────────────────▼───────────────────────────────────┐
28
+ │ Execution Layer │
29
+ │ One GoodJob::Batch per step │
30
+ │ User jobs enqueued via perform_later — fully untouched │
31
+ │ Batch on_finish is the sole terminal signal │
32
+ │ Enqueue is transactionally coupled to row transition │
33
+ └─────────────────────────┬───────────────────────────────────┘
34
+
35
+ ┌─────────────────────────▼───────────────────────────────────┐
36
+ │ Coordination Layer │
37
+ │ Coordinator owns ALL coordination_status transitions │
38
+ │ Atomic row-locked transitions (FOR UPDATE SKIP LOCKED) │
39
+ │ Explicit transaction boundaries per atomic unit │
40
+ │ recompute_pipeline_status is the sole derivation path │
41
+ └─────────────────────────┬───────────────────────────────────┘
42
+
43
+ ┌─────────────────────────▼───────────────────────────────────┐
44
+ │ Chain Layer │
45
+ │ .then() wires pipeline-level DAG dependencies │
46
+ │ Same coordinator pattern, one level up │
47
+ └─────────────────────────────────────────────────────────────┘
48
+ ```
49
+
50
+ ## Data model
51
+
52
+ GoodPipeline uses four Postgres tables:
53
+
54
+ ### `good_pipeline_pipelines`
55
+
56
+ | Column | Type | Notes |
57
+ |---|---|---|
58
+ | `id` | uuid | Primary key |
59
+ | `type` | string | Pipeline class name (e.g. `"VideoProcessingPipeline"`) |
60
+ | `params` | jsonb | Arguments passed to `.run()` |
61
+ | `status` | string | `pending`, `running`, `succeeded`, `failed`, `halted`, `skipped` |
62
+ | `halt_triggered` | boolean | Set to `true` when `:halt` strategy is applied |
63
+ | `good_job_batch_id` | uuid | Pipeline-level GoodJob::Batch for grouping |
64
+ | `on_failure_strategy` | string | `halt`, `continue`, or `ignore` |
65
+ | `callbacks_dispatched_at` | timestamp | Exactly-once callback dispatch guard |
66
+ | `created_at` | timestamp | |
67
+ | `updated_at` | timestamp | |
68
+
69
+ ### `good_pipeline_steps`
70
+
71
+ | Column | Type | Notes |
72
+ |---|---|---|
73
+ | `id` | uuid | Primary key |
74
+ | `pipeline_id` | uuid | Foreign key |
75
+ | `key` | string | Step key — graph identity |
76
+ | `job_class` | string | ActiveJob class name |
77
+ | `params` | jsonb | Arguments passed to `with:` |
78
+ | `coordination_status` | string | `pending`, `enqueued`, `succeeded`, `failed`, `skipped` |
79
+ | `on_failure_strategy` | string | Step-level override (nullable) |
80
+ | `queue` | string | Optional queue override |
81
+ | `priority` | integer | Optional priority override |
82
+ | `good_job_batch_id` | uuid | Step's own GoodJob::Batch |
83
+ | `good_job_id` | uuid | GoodJob record ID (nil until enqueued) |
84
+ | `attempts` | integer | Execution attempt count |
85
+ | `error_class` | string | Terminal failure error class |
86
+ | `error_message` | text | Terminal failure error message |
87
+ | `created_at` | timestamp | |
88
+ | `updated_at` | timestamp | |
89
+
90
+ Unique constraint: `(pipeline_id, key)` — enforces step key uniqueness within a pipeline.
91
+
92
+ ### `good_pipeline_dependencies`
93
+
94
+ | Column | Type | Notes |
95
+ |---|---|---|
96
+ | `id` | bigint | Primary key |
97
+ | `pipeline_id` | uuid | Denormalized for fast querying |
98
+ | `step_id` | uuid | The dependent step |
99
+ | `depends_on_step_id` | uuid | The step that must complete first |
100
+
101
+ ### `good_pipeline_chains`
102
+
103
+ | Column | Type | Notes |
104
+ |---|---|---|
105
+ | `id` | uuid | Primary key |
106
+ | `upstream_pipeline_id` | uuid | The pipeline that must finish first |
107
+ | `downstream_pipeline_id` | uuid | The pipeline to start after |
108
+
109
+ ## One batch per step
110
+
111
+ Each step has its own `GoodJob::Batch`. The user's job is enqueued via `perform_later` into that batch, preserving all ActiveJob semantics (instrumentation, callbacks, serialization, queue routing, retries, `discard_on`).
112
+
113
+ When the batch's `on_finish` fires, `StepFinishedJob` receives the signal and delegates to the coordinator. `StepFinishedJob` is a thin dispatcher — it does not own any state transitions.
114
+
115
+ ## The coordinator
116
+
117
+ The `Coordinator` class is the sole owner of all `coordination_status` transitions. Its `complete_step` method uses explicit transaction boundaries around three atomic units:
118
+
119
+ 1. **Terminal step transition** — marks the step `succeeded` or `failed` with metadata
120
+ 2. **Halt propagation** — sets `halt_triggered` and skips all pending steps (if `:halt` strategy)
121
+ 3. **Downstream unblocking** — checks and enqueues each downstream step independently via `try_enqueue_step`, which acquires a per-step row lock
122
+
123
+ These three units are intentionally **not** wrapped in a single outer transaction to avoid holding locks across multiple downstream step enqueues under high-parallelism pipelines.
124
+
125
+ After the three units complete, `complete_step` calls `recompute_pipeline_status` to derive the pipeline's terminal state from the current database state.
126
+
127
+ ## Terminal state derivation
128
+
129
+ Pipeline terminal status is **never inferred from a single event**. It is always derived by `recompute_pipeline_status`, which reads all step `coordination_status` values and the `halt_triggered` flag:
130
+
131
+ | Condition | Derived status |
132
+ |---|---|
133
+ | Any step is `pending` or `enqueued` | Not terminal — still running |
134
+ | All steps terminal, none `failed` | `succeeded` |
135
+ | All steps terminal, at least one `failed`, `halt_triggered` is `true` | `halted` |
136
+ | All steps terminal, at least one `failed`, `halt_triggered` is `false` | `failed` |
137
+
138
+ This function is safe to call from multiple code paths (coordinator, batch reconciliation) because it is idempotent on terminal pipelines.
139
+
140
+ ## Enqueue transaction contract
141
+
142
+ The transition of a step from `pending` to `enqueued` and the insertion of the corresponding GoodJob record happen inside a **single database transaction**. This is possible because GoodJob stores jobs in Postgres — the same database as GoodPipeline's tables.
143
+
144
+ If the transaction rolls back, both the step status revert and the GoodJob record insertion are cancelled atomically. No stuck-enqueued steps, no ghost jobs.
145
+
146
+ ## Concurrency safety
147
+
148
+ ### The double-enqueue problem
149
+
150
+ If two upstream steps of a shared downstream complete simultaneously, both coordinator invocations may try to enqueue the downstream step. GoodPipeline prevents this with:
151
+
152
+ 1. **`FOR UPDATE SKIP LOCKED`** — one coordinator acquires the row lock; the other skips silently
153
+ 2. **`good_job_id` null guard** — a non-null `good_job_id` is conclusive proof the step was already enqueued (only valid inside a row lock)
154
+ 3. **Status guard** — the coordinator checks `coordination_status == "pending"` inside the lock
155
+
156
+ ### Callback exactly-once
157
+
158
+ `dispatch_callbacks_once` uses a `FOR UPDATE` locked transaction with the `callbacks_dispatched_at` timestamp as a guard. Even if `recompute_pipeline_status` is called concurrently from multiple code paths, the callback bundle fires exactly once.
159
+
160
+ ## Retry model
161
+
162
+ GoodPipeline never inspects exceptions during retry attempts. It only responds to the **terminal signal** from the step batch's `on_finish` callback:
163
+
164
+ | GoodJob outcome | GoodPipeline response |
165
+ |---|---|
166
+ | Job completed successfully | Step → `succeeded` |
167
+ | Job raised, retries remaining | No action — `on_finish` hasn't fired |
168
+ | Job exhausted retries | Step → `failed` |
169
+ | Job discarded via `discard_on` | Step → `failed` |
170
+
171
+ This ensures a step is never prematurely marked `failed` on attempt 1 of 5.
172
+
173
+ ## Key design decisions
174
+
175
+ 1. **Postgres only** — all state in Postgres, enabling atomic enqueue transactions
176
+ 2. **One batch per step** — user jobs enqueued via `perform_later`, preserving all ActiveJob semantics
177
+ 3. **Terminal signal via `batch.succeeded?`** — not exception rescue
178
+ 4. **`coordination_status` is the sole decision input** — the coordinator reads only this column when making decisions
179
+ 5. **`:halted` is policy-driven** — set imperatively via `halt_triggered` flag, not pattern-derived
180
+ 6. **Coordinator owns all transitions** — `StepFinishedJob` is a thin dispatcher
181
+ 7. **Explicit transaction boundaries** — separate atomic units to minimize lock contention
182
+ 8. **DAG validation at instantiation** — before any database writes
183
+ 9. **`run` is the only DSL verb** — all topology from `after:`
184
+ 10. **`failure_strategy` and `on_failure` are distinct** — strategy vs. callback, no naming collision
data/docs/callbacks.md ADDED
@@ -0,0 +1,66 @@
1
+ # Lifecycle Callbacks
2
+
3
+ GoodPipeline supports three lifecycle callbacks that fire when a pipeline reaches a terminal state.
4
+
5
+ ## Defining callbacks
6
+
7
+ Register callbacks as class-level methods that name instance methods to invoke:
8
+
9
+ ```ruby
10
+ class VideoProcessingPipeline < GoodPipeline::Pipeline
11
+ on_complete :notify_complete # fires on any terminal state
12
+ on_success :notify_success # fires on succeeded
13
+ on_failure :notify_failure # fires on failed or halted
14
+
15
+ def configure(video_id:)
16
+ run :download, DownloadJob, with: { video_id: video_id }
17
+ end
18
+
19
+ private
20
+
21
+ def notify_complete
22
+ Rails.logger.info("Pipeline #{id} finished with status: #{status}")
23
+ end
24
+
25
+ def notify_success
26
+ Slack.notify("Pipeline #{id} succeeded for video #{params[:video_id]}")
27
+ end
28
+
29
+ def notify_failure
30
+ Slack.notify("Pipeline #{id} failed for video #{params[:video_id]}")
31
+ end
32
+ end
33
+ ```
34
+
35
+ ## When each callback fires
36
+
37
+ | Callback | Fires when pipeline status is |
38
+ |---|---|
39
+ | `on_complete` | `succeeded`, `failed`, `halted`, or `skipped` |
40
+ | `on_success` | `succeeded` |
41
+ | `on_failure` | `failed` or `halted` |
42
+
43
+ Note: `on_failure` does **not** fire for `skipped` pipelines. Being skipped by a chain is not considered a failure — only `on_complete` fires in that case.
44
+
45
+ ## Asynchronous dispatch
46
+
47
+ Callbacks are dispatched via `PipelineCallbackJob`, a GoodJob job enqueued after the terminal state transaction commits. This means:
48
+
49
+ - A slow external call (Slack, webhooks) cannot stall the coordinator
50
+ - Callback execution cannot corrupt pipeline state
51
+ - Callbacks benefit from GoodJob's retry mechanism if they fail
52
+
53
+ ## Exactly-once guarantee
54
+
55
+ The callback bundle (`on_complete` + one of `on_success`/`on_failure`) is dispatched as a **single unit**. A `callbacks_dispatched_at` timestamp is set atomically inside a `FOR UPDATE` locked transaction, ensuring the bundle fires exactly once even if `recompute_pipeline_status` is called from multiple code paths (coordinator or batch reconciliation).
56
+
57
+ ## Callback failure isolation
58
+
59
+ If a callback method raises an error:
60
+
61
+ - The `PipelineCallbackJob` fails and is retried by GoodJob
62
+ - Pipeline status and step statuses are **not** affected
63
+ - The pipeline remains in its terminal state
64
+ - Other callback methods in the same bundle are still attempted
65
+
66
+ Callback delivery failure is an isolated concern that never reopens or alters the terminal pipeline record.
data/docs/cleanup.md ADDED
@@ -0,0 +1,45 @@
1
+ # Cleanup
2
+
3
+ GoodPipeline automatically cleans up old terminal pipelines when GoodJob runs its own cleanup cycle. No configuration is needed beyond what GoodJob already provides.
4
+
5
+ ## Automatic cleanup
6
+
7
+ GoodPipeline subscribes to GoodJob's `cleanup_preserved_jobs` ActiveSupport notification. When GoodJob cleans its old job records, GoodPipeline deletes terminal pipelines older than the same timestamp.
8
+
9
+ This means cleanup is:
10
+
11
+ - **Zero-config** — it uses GoodJob's existing retention period (default 14 days)
12
+ - **Automatic** — it runs whenever GoodJob's cleanup runs
13
+ - **Safe** — only terminal pipelines (`succeeded`, `failed`, `halted`, `skipped`) are deleted; running and pending pipelines are never touched
14
+
15
+ ## What gets cleaned
16
+
17
+ When a pipeline is cleaned up, the following records are deleted:
18
+
19
+ 1. `good_pipeline_dependencies` — step dependency edges
20
+ 2. `good_pipeline_steps` — step records
21
+ 3. `good_pipeline_chains` — pipeline chain links
22
+ 4. `good_pipeline_pipelines` — pipeline records
23
+
24
+ Records are deleted in dependency order using `delete_all` (no callbacks) for performance.
25
+
26
+ ## Configuring the retention period
27
+
28
+ The retention period is controlled by GoodJob's configuration:
29
+
30
+ ```ruby
31
+ # config/application.rb
32
+ config.good_job.cleanup_preserved_jobs_before_seconds_ago = 30.days.to_i
33
+ ```
34
+
35
+ The default is 14 days. GoodPipeline uses the same threshold.
36
+
37
+ ## Manual cleanup
38
+
39
+ You can trigger cleanup manually at any time:
40
+
41
+ ```ruby
42
+ GoodPipeline.cleanup_preserved_pipelines(older_than: 7.days.ago)
43
+ ```
44
+
45
+ This deletes all terminal pipelines (and their associated steps, dependencies, and chains) created before the given timestamp.
@@ -0,0 +1,88 @@
1
+ # DAG Validation
2
+
3
+ GoodPipeline validates the directed acyclic graph at instantiation time — **before** any step records are persisted or any jobs are enqueued. If validation fails, a `GoodPipeline::InvalidPipelineError` is raised with a descriptive message and nothing is written to the database.
4
+
5
+ ## What is validated
6
+
7
+ ### 1. Empty pipelines
8
+
9
+ A pipeline with no steps is rejected:
10
+
11
+ ```ruby
12
+ class EmptyPipeline < GoodPipeline::Pipeline
13
+ def configure(id:); end
14
+ end
15
+
16
+ EmptyPipeline.run(id: 1)
17
+ # => GoodPipeline::InvalidPipelineError: pipeline has no steps
18
+ ```
19
+
20
+ ### 2. Duplicate step keys
21
+
22
+ Each step key must be unique within a pipeline:
23
+
24
+ ```ruby
25
+ run :download, DownloadJob
26
+ run :download, AnotherJob # same key
27
+ # => GoodPipeline::InvalidPipelineError: duplicate step key :download
28
+ ```
29
+
30
+ ### 3. Unknown `after:` references
31
+
32
+ Every key in `after:` must correspond to a declared step:
33
+
34
+ ```ruby
35
+ run :publish, PublishJob, after: :missing
36
+ # => GoodPipeline::InvalidPipelineError: unknown step key :missing
37
+ ```
38
+
39
+ Forward references are allowed — the full graph is validated after all `run` calls are collected, not incrementally.
40
+
41
+ ### 4. Self-dependencies
42
+
43
+ A step cannot depend on itself:
44
+
45
+ ```ruby
46
+ run :transcode, TranscodeJob, after: :transcode
47
+ # => GoodPipeline::InvalidPipelineError: step :transcode depends on itself
48
+ ```
49
+
50
+ ### 5. Cycles
51
+
52
+ The graph is checked for cycles using depth-first search. Any cycle causes validation to fail with the cycle path in the error message:
53
+
54
+ ```ruby
55
+ run :a, JobA, after: :b
56
+ run :b, JobB, after: :a
57
+ # => GoodPipeline::InvalidPipelineError: cycle detected: :a -> :b -> :a
58
+ ```
59
+
60
+ Indirect cycles are also detected:
61
+
62
+ ```ruby
63
+ run :a, JobA, after: :c
64
+ run :b, JobB, after: :a
65
+ run :c, JobC, after: :b
66
+ # => GoodPipeline::InvalidPipelineError: cycle detected: :a -> :c -> :b -> :a
67
+ ```
68
+
69
+ ## Cycle detection algorithm
70
+
71
+ GoodPipeline uses a recursive depth-first search with a three-color marking scheme:
72
+
73
+ - **White** — unvisited
74
+ - **Grey** — currently in the DFS stack (an ancestor of the current node)
75
+ - **Black** — fully processed, confirmed acyclic
76
+
77
+ If a grey node is encountered during traversal, a cycle is present. The algorithm collects the path for the error message.
78
+
79
+ ## No database writes on failure
80
+
81
+ Validation runs before any database interaction. If validation fails:
82
+
83
+ - No `PipelineRecord` is created
84
+ - No `StepRecord` rows are inserted
85
+ - No `DependencyRecord` edges are written
86
+ - No GoodJob batches or jobs are enqueued
87
+
88
+ This is an intentional design decision: a DAG pipeline gem that allows invalid graphs is not useful.
data/docs/dashboard.md ADDED
@@ -0,0 +1,66 @@
1
+ # Web Dashboard
2
+
3
+ GoodPipeline includes a mountable Rails engine that provides a web dashboard for inspecting pipeline executions. No build step is required — it uses Pico CSS and Mermaid.js from CDN.
4
+
5
+ ## Mounting the engine
6
+
7
+ ```ruby
8
+ # config/routes.rb
9
+ mount GoodPipeline::Engine => "/good_pipeline"
10
+ ```
11
+
12
+ The dashboard is then available at `/good_pipeline`.
13
+
14
+ ## Pages
15
+
16
+ ### Pipeline Executions
17
+
18
+ The index page lists all pipeline executions with:
19
+
20
+ - Status filter tabs with per-status counts
21
+ - Pipeline type dropdown filter
22
+ - Execution ID, pipeline name, status badge, start time, and duration
23
+ - Keyset pagination (25 per page)
24
+
25
+ ![Pipeline Executions](/screenshots/index.png)
26
+
27
+ ### Pipeline Details
28
+
29
+ The show page displays a single pipeline execution with:
30
+
31
+ - Pipeline metadata: status, failure strategy, params, created time, duration
32
+ - **Mermaid DAG visualization** with color-coded step statuses
33
+ - Steps table with coordination status, job class, duration, error info, and links to the GoodJob dashboard
34
+ - Upstream and downstream chain links (if the pipeline is part of a chain)
35
+
36
+ ![Pipeline Details](/screenshots/show.png)
37
+
38
+ ### Pipeline Definitions
39
+
40
+ The definitions page catalogs all pipeline types found in the database:
41
+
42
+ - Sidebar listing all pipeline types with step counts
43
+ - Selected pipeline shows its Mermaid DAG structure, failure strategy, and a link to filter executions by that type
44
+
45
+ ![Pipeline Definitions](/screenshots/definitions.png)
46
+
47
+ ## GoodJob integration
48
+
49
+ The dashboard links to GoodJob's dashboard for individual step jobs when a `good_job_id` is present on a step. It automatically discovers GoodJob's mount path from your application's routes.
50
+
51
+ ## Securing the dashboard
52
+
53
+ GoodPipeline's engine is a standard Rails engine mount. Secure it the same way you would any admin interface:
54
+
55
+ ```ruby
56
+ # config/routes.rb
57
+
58
+ # With Devise
59
+ authenticate :user, ->(user) { user.admin? } do
60
+ mount GoodPipeline::Engine => "/good_pipeline"
61
+ end
62
+
63
+ # With Rails routing constraints
64
+ mount GoodPipeline::Engine => "/good_pipeline",
65
+ constraints: AdminConstraint.new
66
+ ```
@@ -0,0 +1,167 @@
1
+ # Defining Pipelines
2
+
3
+ ## Pipeline class structure
4
+
5
+ Every pipeline is a subclass of `GoodPipeline::Pipeline` that implements `configure`:
6
+
7
+ ```ruby
8
+ class VideoProcessingPipeline < GoodPipeline::Pipeline
9
+ description "Downloads, transcodes and publishes a video"
10
+ failure_strategy :halt
11
+
12
+ on_complete :notify
13
+ on_success :celebrate
14
+ on_failure :alert
15
+
16
+ def configure(video_id:)
17
+ run :download, DownloadJob, with: { video_id: video_id }
18
+ run :transcode, TranscodeJob, after: :download
19
+ run :thumbnail, ThumbnailJob, after: :download
20
+ run :publish, PublishJob, after: %i[transcode thumbnail]
21
+ run :cleanup, CleanupJob, after: :publish
22
+ end
23
+
24
+ private
25
+
26
+ def notify = Rails.logger.info("Pipeline complete")
27
+ def celebrate = Rails.logger.info("All steps succeeded!")
28
+ def alert = Rails.logger.warn("Pipeline had failures")
29
+ end
30
+ ```
31
+
32
+ **Class-level methods:**
33
+
34
+ | Method | Purpose | Default |
35
+ |---|---|---|
36
+ | `description` | Human-readable label for the dashboard | `nil` |
37
+ | `failure_strategy` | Pipeline-level failure handling policy | `:halt` |
38
+ | `on_complete` | Callback for any terminal state | `nil` |
39
+ | `on_success` | Callback for succeeded | `nil` |
40
+ | `on_failure` | Callback for failed or halted | `nil` |
41
+
42
+ ## The `run` DSL verb
43
+
44
+ `run` is the **only** DSL verb. All DAG topology is expressed through `after:` edges:
45
+
46
+ ```ruby
47
+ run :step_key, JobClass,
48
+ with: { keyword: args }, # keyword args passed to job's perform method
49
+ after: :other_step, # single dependency (symbol or array of symbols)
50
+ on_failure: :ignore, # step-level failure strategy override
51
+ queue: :media, # optional queue override
52
+ priority: 10 # optional priority override
53
+ ```
54
+
55
+ ### Parameters
56
+
57
+ | Parameter | Type | Required | Description |
58
+ |---|---|---|---|
59
+ | `key` | Symbol | Yes | Unique step identifier within this pipeline |
60
+ | `job_class` | Class | Yes | The ActiveJob class to execute |
61
+ | `with:` | Hash | No | Keyword arguments forwarded to the job's `perform` method |
62
+ | `after:` | Symbol or Array | No | Step key(s) this step depends on |
63
+ | `on_failure:` | Symbol | No | Override: `:halt`, `:continue`, or `:ignore` |
64
+ | `queue:` | String | No | Queue name for this step's job |
65
+ | `priority:` | Integer | No | Priority for this step's job |
66
+
67
+ ## Step keys vs job classes
68
+
69
+ The **step key** is the graph identity — it's what you reference in `after:`. The **job class** is the implementation — what code runs:
70
+
71
+ | Concept | Purpose | Example |
72
+ |---|---|---|
73
+ | Step key | Graph identity — used in `after:` | `:transcode_720p` |
74
+ | Job class | Implementation — what code runs | `TranscodeJob` |
75
+
76
+ The same job class can appear multiple times in one pipeline under different keys:
77
+
78
+ ```ruby
79
+ run :resize_small, ResizeImageJob, with: { size: "small" }
80
+ run :resize_large, ResizeImageJob, with: { size: "large" }
81
+ run :combine, CombineJob, after: [:resize_small, :resize_large]
82
+ ```
83
+
84
+ ## DAG topology via `after:`
85
+
86
+ All topology flows from `after:`. Steps with no `after:` are **root steps** and start immediately. Steps with `after:` wait for all listed dependencies to be satisfied.
87
+
88
+ ### Fan-out
89
+
90
+ One step feeds into multiple parallel steps:
91
+
92
+ ```ruby
93
+ run :download, DownloadJob
94
+ run :transcode, TranscodeJob, after: :download
95
+ run :thumbnail, ThumbnailJob, after: :download
96
+ ```
97
+
98
+ ```
99
+ :download
100
+
101
+ ┌────┴────┐
102
+ ↓ ↓
103
+ :transcode :thumbnail
104
+ ```
105
+
106
+ ### Fan-in
107
+
108
+ Multiple steps converge into one:
109
+
110
+ ```ruby
111
+ run :transcode, TranscodeJob, after: :download
112
+ run :thumbnail, ThumbnailJob, after: :download
113
+ run :publish, PublishJob, after: [:transcode, :thumbnail]
114
+ ```
115
+
116
+ `:publish` waits for **both** `:transcode` and `:thumbnail` to succeed.
117
+
118
+ ### Complex DAGs
119
+
120
+ Combine fan-out and fan-in freely:
121
+
122
+ ```ruby
123
+ def configure(video_id:)
124
+ run :download, DownloadJob, with: { video_id: video_id }
125
+ run :transcode, TranscodeJob, after: :download
126
+ run :thumbnail, ThumbnailJob, after: :download
127
+ run :publish, PublishJob, after: [:transcode, :thumbnail]
128
+ run :cleanup, CleanupJob, after: :publish
129
+ end
130
+ ```
131
+
132
+ ```
133
+ :download
134
+
135
+ ┌────┴────┐
136
+ ↓ ↓
137
+ :transcode :thumbnail
138
+ └────┬────┘
139
+
140
+ :publish
141
+
142
+ :cleanup
143
+ ```
144
+
145
+ ## Running a pipeline
146
+
147
+ ```ruby
148
+ # Fire and forget
149
+ VideoProcessingPipeline.run(video_id: 123)
150
+
151
+ # Capture the result for monitoring or chaining
152
+ pipeline = VideoProcessingPipeline.run(video_id: 123)
153
+ pipeline.id # => "uuid-string"
154
+ pipeline.status # => "running"
155
+ ```
156
+
157
+ `.run` returns a `GoodPipeline::Chain` object that delegates common methods (`id`, `status`, `params`, `steps`, etc.) to the underlying pipeline record.
158
+
159
+ ## Definition freeze semantics
160
+
161
+ Once a pipeline instance is created and validated:
162
+
163
+ 1. The graph topology is **immutable** — steps and edges are written to the database and cannot be modified
164
+ 2. All step definitions are frozen
165
+ 3. The params hash is frozen
166
+
167
+ This ensures the pipeline definition is a snapshot — later changes to the pipeline class don't affect running instances.