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
|
@@ -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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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.
|