good_pipeline 0.3.1 → 0.5.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/Appraisals +25 -0
  3. data/CHANGELOG.md +67 -0
  4. data/README.md +66 -10
  5. data/app/controllers/good_pipeline/frontends_controller.rb +18 -1
  6. data/app/controllers/good_pipeline/pipelines_controller.rb +254 -36
  7. data/app/controllers/good_pipeline/themes_controller.rb +15 -0
  8. data/app/frontend/good_pipeline/dashboard.css +407 -0
  9. data/app/frontend/good_pipeline/dashboard.js +542 -0
  10. data/app/helpers/good_pipeline/mermaid_diagram_builder.rb +225 -66
  11. data/app/helpers/good_pipeline/pipelines_helper.rb +89 -5
  12. data/app/jobs/good_pipeline/pipeline_callback_job.rb +2 -2
  13. data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +1 -2
  14. data/app/jobs/good_pipeline/step_finished_job.rb +6 -2
  15. data/app/models/good_pipeline/pipeline_record.rb +7 -4
  16. data/app/models/good_pipeline/step_record.rb +6 -3
  17. data/app/views/good_pipeline/pipelines/_actions.html.erb +28 -0
  18. data/app/views/good_pipeline/pipelines/_chain_links.html.erb +17 -29
  19. data/app/views/good_pipeline/pipelines/_diagram.html.erb +101 -0
  20. data/app/views/good_pipeline/pipelines/_filter_bar.html.erb +51 -0
  21. data/app/views/good_pipeline/pipelines/_kpi_strip.html.erb +74 -0
  22. data/app/views/good_pipeline/pipelines/_pager.html.erb +31 -0
  23. data/app/views/good_pipeline/pipelines/_row.html.erb +103 -0
  24. data/app/views/good_pipeline/pipelines/_row_detail.html.erb +93 -0
  25. data/app/views/good_pipeline/pipelines/_sidebar.html.erb +115 -0
  26. data/app/views/good_pipeline/pipelines/_steps_table.html.erb +46 -33
  27. data/app/views/good_pipeline/pipelines/_table.html.erb +33 -0
  28. data/app/views/good_pipeline/pipelines/_timeline.html.erb +55 -0
  29. data/app/views/good_pipeline/pipelines/_topbar.html.erb +54 -0
  30. data/app/views/good_pipeline/pipelines/definitions.html.erb +88 -55
  31. data/app/views/good_pipeline/pipelines/index.html.erb +31 -42
  32. data/app/views/good_pipeline/pipelines/show.html.erb +54 -47
  33. data/app/views/layouts/good_pipeline/application.html.erb +24 -153
  34. data/config/routes.rb +4 -0
  35. data/demo/config/environments/development.rb +3 -0
  36. data/demo/config/environments/test.rb +2 -1
  37. data/demo/db/migrate/20260319205325_create_good_jobs.rb +1 -1
  38. data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +1 -1
  39. data/demo/db/migrate/20260810000000_add_good_pipeline_dashboard_indexes.rb +20 -0
  40. data/demo/db/seeds.rb +325 -11
  41. data/demo/docs/screenshots/definitions.png +0 -0
  42. data/demo/docs/screenshots/show.png +0 -0
  43. data/demo/test/good_pipeline/test_bulk_enqueue.rb +246 -0
  44. data/demo/test/good_pipeline/test_cancellation.rb +312 -0
  45. data/demo/test/good_pipeline/test_cleanup.rb +31 -1
  46. data/demo/test/good_pipeline/test_coordinator.rb +39 -0
  47. data/demo/test/good_pipeline/test_dashboard.rb +666 -0
  48. data/demo/test/good_pipeline/test_dashboard_rerun.rb +244 -0
  49. data/demo/test/good_pipeline/test_pipeline_callback_job.rb +11 -0
  50. data/demo/test/good_pipeline/test_pipeline_record.rb +30 -0
  51. data/demo/test/good_pipeline/test_queue_configuration.rb +157 -0
  52. data/demo/test/good_pipeline/test_runner.rb +40 -1
  53. data/demo/test/good_pipeline/test_step_record.rb +37 -0
  54. data/demo/test/integration/test_barrier_execution.rb +502 -0
  55. data/demo/test/integration/test_bulk_enqueue_end_to_end.rb +109 -0
  56. data/demo/test/integration/test_chain_locking.rb +54 -0
  57. data/demo/test/integration/test_end_to_end.rb +0 -15
  58. data/demo/test/integration/test_halt_execution.rb +0 -13
  59. data/demo/test/integration/test_pipeline_chaining.rb +31 -0
  60. data/demo/test/integration/test_queue_configuration.rb +82 -0
  61. data/demo/test/test_helper.rb +35 -0
  62. data/docs/architecture.md +53 -29
  63. data/docs/branching.md +6 -0
  64. data/docs/callbacks.md +10 -6
  65. data/docs/cleanup.md +2 -2
  66. data/docs/dag-validation.md +16 -0
  67. data/docs/dashboard.md +84 -22
  68. data/docs/defining-pipelines.md +38 -1
  69. data/docs/failure-strategies.md +19 -0
  70. data/docs/getting-started.md +21 -1
  71. data/docs/index.md +1 -1
  72. data/docs/introduction.md +22 -4
  73. data/docs/monitoring.md +7 -2
  74. data/docs/pipeline-chaining.md +10 -4
  75. data/docs/public/screenshots/definitions.png +0 -0
  76. data/docs/public/screenshots/index.png +0 -0
  77. data/docs/public/screenshots/show.png +0 -0
  78. data/docs/screenshots/definitions.png +0 -0
  79. data/docs/screenshots/index.png +0 -0
  80. data/docs/screenshots/show.png +0 -0
  81. data/gemfiles/rails_7_2.gemfile +24 -0
  82. data/gemfiles/rails_7_2.gemfile.lock +412 -0
  83. data/gemfiles/rails_8_0.gemfile +24 -0
  84. data/gemfiles/rails_8_0.gemfile.lock +411 -0
  85. data/gemfiles/rails_8_1.gemfile +24 -0
  86. data/gemfiles/rails_8_1.gemfile.lock +415 -0
  87. data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +9 -0
  88. data/lib/generators/good_pipeline/upgrade/templates/add_good_pipeline_dashboard_indexes.rb.erb +27 -0
  89. data/lib/generators/good_pipeline/upgrade/upgrade_generator.rb +32 -0
  90. data/lib/good_pipeline/barrier_compiler.rb +106 -0
  91. data/lib/good_pipeline/chain_coordinator.rb +3 -7
  92. data/lib/good_pipeline/constants.rb +2 -0
  93. data/lib/good_pipeline/coordinator.rb +332 -122
  94. data/lib/good_pipeline/dashboard/connection_info.rb +67 -0
  95. data/lib/good_pipeline/dashboard/definition_stages.rb +59 -0
  96. data/lib/good_pipeline/dashboard/filter_set.rb +77 -0
  97. data/lib/good_pipeline/dashboard/kpi_calculator.rb +143 -0
  98. data/lib/good_pipeline/dashboard/sparkline.rb +73 -0
  99. data/lib/good_pipeline/dashboard/stage_lanes.rb +307 -0
  100. data/lib/good_pipeline/dashboard/step_timings.rb +50 -0
  101. data/lib/good_pipeline/dashboard.rb +21 -0
  102. data/lib/good_pipeline/errors.rb +11 -0
  103. data/lib/good_pipeline/pipeline.rb +38 -3
  104. data/lib/good_pipeline/runner.rb +3 -3
  105. data/lib/good_pipeline/step_definition.rb +16 -1
  106. data/lib/good_pipeline/version.rb +1 -1
  107. data/lib/good_pipeline.rb +21 -0
  108. metadata +52 -13
  109. data/app/frontend/good_pipeline/style.css +0 -597
  110. data/app/views/good_pipeline/pipelines/_pagination.html.erb +0 -24
  111. data/app/views/good_pipeline/pipelines/_pipeline_row.html.erb +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9df8b5fbd57895f53adf1d3c5804ca9bd64ca792e880c2d4941957e4b8ca368
4
- data.tar.gz: c4e2a7c4edbe27a0e40e7ff62a061f5ade53c44a74c0b824ce21bdf019cb8781
3
+ metadata.gz: e8d45ee63fb102b327e2e847cd277c911c625711562117c172119b3384d08447
4
+ data.tar.gz: 888ac688b1e600a74fbc70b36c4ab0aa1d7747d4f986bd8cc9d688a6ab8c8dbc
5
5
  SHA512:
6
- metadata.gz: e3c6e6940034efbc8679ede33b352022deb64a36b7a16a9dd3f02fd6dc32a837caf545407541bd0ca629f690d38cfa60342e6aa756e53ea321fb754eebbd772c
7
- data.tar.gz: 878566f6d9ddc7b9ce6b4201f5bd241b21f2758f5475e3fa60e6c3fd629651bff06ff973af9f3f29ac4624bee58e78dc0f9628e8a1cdb503836e923a09e3944e
6
+ metadata.gz: 642f603b2991bb82256510cf3ea1a13f511f7009a7ba82f10d9fb8c264acdc27d213629842414293a6728c13d055ecf808ba71c555fc9bf4705f3b3b8a23ab5f
7
+ data.tar.gz: 7941dcef31ca4d86f5fb796803a61d3c3d9af3ed8442aaace983c5a6c5d62360c16a4458ab7130f34764d14e37d53cc79167e4296a52b31bc0381300a3bc74b6
data/Appraisals ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ appraise "rails-7-2" do
4
+ gem "minitest", "~> 5.25"
5
+ gem "railties", "~> 7.2.0"
6
+ gem "activerecord", "~> 7.2.0"
7
+ gem "activejob", "~> 7.2.0"
8
+ gem "activesupport", "~> 7.2.0"
9
+ end
10
+
11
+ appraise "rails-8-0" do
12
+ gem "minitest", "~> 6.0", ">= 6.0.2"
13
+ gem "railties", "~> 8.0.0"
14
+ gem "activerecord", "~> 8.0.0"
15
+ gem "activejob", "~> 8.0.0"
16
+ gem "activesupport", "~> 8.0.0"
17
+ end
18
+
19
+ appraise "rails-8-1" do
20
+ gem "minitest", "~> 6.0", ">= 6.0.2"
21
+ gem "railties", "~> 8.1.0"
22
+ gem "activerecord", "~> 8.1.0"
23
+ gem "activejob", "~> 8.1.0"
24
+ gem "activesupport", "~> 8.1.0"
25
+ end
data/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-09-01
4
+
5
+ ### Added
6
+
7
+ - **Pipeline phase barriers** — the new zero-argument `barrier` DSL verb inserts a persisted structural synchronization step between declaration phases. Every prior phase step converges on one barrier and following phase entries fan out from it, avoiding all-to-all dependency expansion. Barriers resolve synchronously without enqueuing an ActiveJob and appear as structural nodes in the dashboard.
8
+ - **Graceful dashboard cancellation** — administrators can stop future DAG scheduling without terminating already-enqueued, scheduled, or retrying GoodJob work; pipelines remain `canceling` until that work drains, then become terminal `canceled`.
9
+ - **Standalone dashboard re-runs** — administrators can start a fresh execution of a terminal pipeline from its stored parameters and current class definition. The original execution remains unchanged, and historical pipeline-chain relationships are not copied.
10
+ - **Read-only dashboard default** — pipeline mutation controls are hidden and rejected with `403 Forbidden` unless `GoodPipeline.dashboard_mutations_enabled = true` is configured explicitly. This setting does not replace authentication for the dashboard mount.
11
+ - **Redesigned dashboard** — a responsive execution shell with pipeline-type navigation, composable status/time/search filters, offset pagination, KPIs, expandable execution rows, stage timelines, and dedicated execution and definition views.
12
+ - **Persistent light and dark themes** — the dashboard now owns an isolated `data-gp-theme` attribute and persists the topbar toggle through a mount-aware Rails endpoint. Dark is now the default theme; existing users will see the dashboard change from light to dark after upgrading unless they select light.
13
+ - **Scale-aware graph views** — large DAGs default to aggregated stage views, with full Mermaid rendering available up to a 1,000-edge safety limit.
14
+ - **Dashboard upgrade generator** — `good_pipeline:upgrade` creates the three concurrent indexes used by type, status, and chronological dashboard queries, and safely no-ops when that migration already exists.
15
+ - **Rails compatibility matrix** — CI now exercises the Rails 7.2 support floor and the Rails 8.0 and 8.1 lines through Appraisal gemfiles.
16
+
17
+ ### Breaking changes
18
+
19
+ - **Coordinator activity hints removed** — `Coordinator.recompute_pipeline_status` no longer accepts `has_active_steps:` or `has_downstream_chains:`. Terminal status is always derived from fresh rows while the pipeline is locked; obsolete callers now fail loudly instead of having their hints ignored.
20
+ - **Bulk enqueue contract tightened** — `Coordinator.bulk_enqueue_steps` raises `ArgumentError` when existing step IDs span multiple pipelines and now consistently returns `nil`. Both bulk and single-step enqueue refuse to schedule work unless the owning pipeline is `running`; `Coordinator.try_enqueue_step` reports that refusal as `false`.
21
+ - **Rails 7.2 minimum** — Rails 7.1 is no longer supported. Rails 7.1 [reached upstream end-of-life in October 2025](https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement) and no longer receives bug fixes or security fixes.
22
+
23
+ ### Changed
24
+
25
+ - **Universal dependency normalization** — repeated keys in every `after:` list are deduplicated before validation and persistence, keeping dependency rows consistent with `pending_upstream_count` for pipelines with or without barriers.
26
+ - **Terminal edge-release invariant** — enqueue-time configuration failures and steps skipped by partial `:halt` propagation now release downstream dependency counters before eligible descendants are resolved. Terminalization, counter release, and downstream scheduling remain in one pipeline-locked transaction.
27
+ - **Pipeline-first coordination** — cancellation, completion, single enqueue, bulk enqueue, and status recomputation serialize on the owning pipeline row and then lock step rows in a consistent order. User jobs still execute concurrently; only coordination for the same pipeline is serialized, including downstream resolution and transactional GoodJob insertion.
28
+ - **Fresh terminal derivation** — completion and explicit status recomputation derive running/canceling outcomes through one locked path. A redelivered terminal step also recomputes a nonterminal pipeline, repairing stale terminal status left by older coordinator versions or manual intervention.
29
+ - **Post-commit chain handoff** — terminal chain propagation runs after all surrounding transactions commit, avoiding cross-pipeline lock nesting and making `GoodPipeline.run` safe inside a caller-managed transaction.
30
+ - **Step completion ownership metadata** — new step batches record their `pipeline_id`, avoiding an ownership lookup before completion locking. `StepFinishedJob` falls back to the step row for batches queued before this upgrade.
31
+ - **Callback delivery semantics clarified** — `callbacks_dispatched_at` guarantees one transactional callback-job enqueue, not exactly-once user callback execution. Retry behavior remains application-configured, and callbacks should be idempotent.
32
+ - **Execution pagination** — dashboard lists now use clamped offset pagination with a total page count instead of keyset cursors.
33
+ - **Step timings** — dashboard timing data is batch-loaded from GoodJob, removing per-step lookups while preserving the same retention boundary as job records.
34
+ - **Relative timestamps** — times under one minute now render as exact seconds such as `30s ago` instead of `just now`.
35
+ - **Dashboard dependencies** — versioned dashboard CSS and JavaScript ship with the gem; graph rendering remains build-free and is initialized client-side in strict mode.
36
+
37
+ ### Fixed
38
+
39
+ - **Branch-arm pruning with additional dependencies** — every non-selected arm step is reconsidered as soon as its branch resolves and becomes `skipped_by_branch` even when another incoming dependency is unresolved or failed. Branch continuation no longer depends on concurrent completion order.
40
+ - **All-empty branch continuation** — a branch whose arms are all empty now aliases to its structural sentinel, so `after: :branch_key` remains ordered after the branch decision with or without a preceding barrier.
41
+ - **Multiple ignored halt failures** — bulk enqueue-time failures that all override pipeline-level `:halt` with `:ignore` now protect the union of their downstream subtrees instead of allowing each halt pass to skip another ignored subtree.
42
+ - **Skipped dependencies under inherited ignore** — ordinary `skipped` and `canceled` steps are always treated as permanently unsatisfied, preventing descendants from remaining pending when a skipped step inherits pipeline-level `:ignore`.
43
+ - **Dropped chain fan-in wake-ups** — `ChainCoordinator` now waits on a blocking `FOR UPDATE` lock instead of silently skipping a contended downstream pipeline with `SKIP LOCKED`.
44
+ - **Database benchmark pipeline resolution** — dynamically generated benchmark pipeline classes are now registered as constants, allowing the enqueue, completion, recomputation, halt, and full-run sections of `bench/database_bench.rb` to execute.
45
+
46
+ ### Upgrade notes
47
+
48
+ - Barrier-aware deployments require two phases: first deploy this gem version to every web and worker process capable of running coordinator code; only after all old processes have stopped should application code using `barrier` be deployed. Older coordinators treat the new structural sentinel as an executable job class and cannot safely process barrier definitions.
49
+ - Existing dashboard mounts remain read-only after upgrading. Protect the engine mount with administrator authentication, then set `GoodPipeline.dashboard_mutations_enabled = true` to expose dashboard mutation controls, including cancellation and re-running terminal pipelines. The theme preference remains available in read-only mode.
50
+ - Coordinator completion and enqueue operations for one pipeline are now serialized for cancellation correctness. Wide fan-in increases coordination query volume because every upstream completion locks and recomputes pipeline state; many concurrent completions, such as leaves in a wide fan-out, can contend on the pipeline row. Recursive fan-out scheduling reuses that lock and issues fewer queries than before.
51
+ - Run `bin/rails generate good_pipeline:upgrade` and `bin/rails db:migrate` to add the dashboard indexes. See `docs/dashboard.md` for recovery steps if a concurrent index build is interrupted.
52
+ - GoodJob may remove timing rows for early steps of a still-running pipeline; those steps render `—` rather than raising or issuing individual lookups.
53
+
54
+ ## [0.4.0] - 2026-04-02
55
+
56
+ ### Performance
57
+
58
+ - **Bulk root step enqueuing** — pipelines with multiple root steps now enqueue all of them via `GoodJob::Batch.enqueue_all` in a fixed number of queries instead of ~9 queries per step. Both `Runner#enqueue_root_steps` and `ChainCoordinator#start_pipeline` use the new `Coordinator.bulk_enqueue_steps` method.
59
+
60
+ ### Added
61
+
62
+ - **Configurable queue names for internal jobs** — new `coordination_queue_name` and `callback_queue_name` settings control which queues `StepFinishedJob`, `PipelineReconciliationJob`, and `PipelineCallbackJob` run on. Configurable globally (`GoodPipeline.coordination_queue_name = "x"`) and per-pipeline via the class DSL. Defaults to `"good_pipeline_coordination"` and `"good_pipeline_callbacks"`.
63
+ - **`Coordinator.bulk_enqueue_steps`** — public method that loads pending steps, partitions branch steps for individual handling, and bulk-enqueues the rest via `Batch.enqueue_all`. Invalid job classes are failed individually without blocking valid steps.
64
+
65
+ ### Changed
66
+
67
+ - **Minimum GoodJob version** — bumped from `>= 3.10` to `>= 4.14` (required for `Batch.enqueue_all`).
68
+ - **`run_pipeline_to_completion` test helper** — extracted from 3 integration test files into `test_helper.rb`.
69
+
3
70
  ## [0.3.1] - 2026-03-26
4
71
 
5
72
  ### Added
data/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  DAG-based job pipeline orchestration for Rails, built on [GoodJob](https://github.com/bensheldon/good_job).
4
4
 
5
- Define multi-step workflows as directed acyclic graphs, where each step is a GoodJob job. GoodPipeline handles dependency resolution, parallel execution, failure strategies, pipeline chaining, and lifecycle callbacks. It also ships with a web dashboard.
5
+ Define multi-step workflows as directed acyclic graphs not linear chains. Steps run in parallel when they can and wait for dependencies when they must. GoodPipeline handles dependency resolution, parallel execution, failure strategies, conditional branching, pipeline chaining, and lifecycle callbacks. It also ships with a web dashboard.
6
6
 
7
7
  ## Requirements
8
8
 
9
9
  - Ruby >= 3.2
10
- - Rails >= 7.1
10
+ - Rails >= 7.2
11
11
  - PostgreSQL
12
- - GoodJob >= 3.10 with `preserve_job_records = true`
12
+ - GoodJob >= 4.14 with `preserve_job_records = true`
13
13
 
14
14
  ## Installation
15
15
 
@@ -26,6 +26,15 @@ bin/rails generate good_pipeline:install
26
26
  bin/rails db:migrate
27
27
  ```
28
28
 
29
+ When upgrading an existing application to GoodPipeline 0.5, add the dashboard indexes before serving a large execution history:
30
+
31
+ ```bash
32
+ bin/rails generate good_pipeline:upgrade
33
+ bin/rails db:migrate
34
+ ```
35
+
36
+ The upgrade generator is idempotent: if its dashboard-index migration already exists, a second invocation reports a no-op instead of creating another file.
37
+
29
38
  GoodPipeline requires GoodJob to preserve job records. Add this to your GoodJob configuration:
30
39
 
31
40
  ```ruby
@@ -93,6 +102,22 @@ run :step_key, JobClass,
93
102
  enqueue: { queue: :media, priority: 10 } # options passed to job.enqueue()
94
103
  ```
95
104
 
105
+ ### Phase barriers
106
+
107
+ Use `barrier` when every entry into the next declaration phase must wait for the whole preceding phase:
108
+
109
+ ```ruby
110
+ run :fetch_users, FetchUsersJob
111
+ run :fetch_orders, FetchOrdersJob
112
+
113
+ barrier
114
+
115
+ run :index_users, IndexUsersJob
116
+ run :index_orders, IndexOrdersJob
117
+ ```
118
+
119
+ The barrier is a persisted structural step, not an ActiveJob. It is resolved synchronously after every step in the preceding phase becomes terminal, creates no GoodJob record, and appears as a Barrier node in the dashboard. Normal failure semantics apply: `:ignore` failures permit progress, while a permanently unsatisfied dependency skips the barrier and the later phase.
120
+
96
121
  ### Failure strategies
97
122
 
98
123
  Set at the pipeline level with `failure_strategy`:
@@ -149,7 +174,7 @@ branch :quality_check, after: :analyze, by: :needs_processing do
149
174
  end
150
175
  ```
151
176
 
152
- The dashboard renders branches as diamond decision nodes with labeled edges.
177
+ The dashboard renders branches as diamond decision nodes with labeled edges and barriers as structural synchronization nodes.
153
178
 
154
179
  ### Pipeline chaining
155
180
 
@@ -176,7 +201,7 @@ GoodPipeline.run(
176
201
  ).then(MergeMediaPipeline, with: { video_id: 123, audio_id: 456 })
177
202
  ```
178
203
 
179
- If an upstream pipeline fails or halts, downstream pipelines are automatically skipped.
204
+ If an upstream pipeline fails, halts, is canceled, or is skipped, downstream pipelines are automatically skipped.
180
205
 
181
206
  ### Monitoring
182
207
 
@@ -211,14 +236,45 @@ GoodPipeline includes a mountable web dashboard for inspecting pipeline executio
211
236
 
212
237
  ```ruby
213
238
  # config/routes.rb
239
+ # Protect this mount with your application's administrator authentication.
214
240
  mount GoodPipeline::Engine => "/good_pipeline"
215
241
  ```
216
242
 
243
+ The engine does not provide authentication. Treat the dashboard as an admin-only interface and see [Securing the dashboard](docs/dashboard.md#securing-the-dashboard) for mount examples.
244
+
245
+ Pipeline mutation controls are read-only by default. After protecting the mount, enable cancellation and re-running explicitly:
246
+
247
+ ```ruby
248
+ # config/initializers/good_pipeline.rb
249
+ GoodPipeline.dashboard_mutations_enabled = true
250
+ ```
251
+
252
+ This setting only enables dashboard actions; it does not authenticate or authorize visitors. The theme preference remains available while pipeline mutations are disabled.
253
+
217
254
  The dashboard provides:
218
255
 
219
- - Pipeline Executions: filterable list with status tabs and pipeline type dropdown
220
- - Pipeline Details: steps table, DAG visualization, chain links, error info
221
- - Pipeline Definitions: catalog of all pipeline types with their DAG structure
256
+ - Pipeline executions with composable type, status, time, and text filters, offset pagination, live status counts, and expandable step timelines
257
+ - Operational KPIs for the selected pipeline type, including current activity, fixed-window failure and duration statistics, and a 14-day sparkline
258
+ - Pipeline details with GoodJob links, step errors, chain links, a stage timeline, and an interactive DAG
259
+ - A definition catalog with declared dependencies and structural DAG or stage views
260
+
261
+ Dark is the default theme in 0.5. The topbar toggle persists a light or dark preference in a permanent same-site cookie. Dashboard styles and JavaScript ship with the gem; Mermaid and web fonts are loaded from their CDNs, so there is no application-side asset build step.
262
+
263
+ Large executions remain readable: rows with more than 12 steps use an aggregate status bar, and DAGs with more than 60 steps initially show a stage view. A full graph remains available up to Mermaid's 1,000-edge safety limit. Above that limit, the stage view stays available and full rendering is disabled explicitly.
264
+
265
+ ### Canceling a pipeline
266
+
267
+ Dashboard cancellation is graceful: it stops future DAG scheduling, not work already handed to GoodJob. A pending pipeline becomes `canceled` immediately. A running pipeline becomes `canceling`, and its `pending` steps become `canceled`; jobs that are already enqueued, scheduled, or retrying run normally and retain their actual `succeeded`, `failed`, or `halted` outcomes. No GoodJob records are changed and no worker is force-terminated.
268
+
269
+ After the last enqueued job finishes, the pipeline becomes `canceled`. Until then, `canceling` is an active, nonterminal state. A pipeline can remain `canceling` indefinitely if an enqueued job never reaches a terminal outcome.
270
+
271
+ ### Re-running a pipeline
272
+
273
+ The dashboard can re-run a terminal pipeline (`succeeded`, `failed`, `halted`, `skipped`, or `canceled`). A re-run is a new standalone execution, not a retry or resumption of the historical one. It starts from the root steps using the source execution's stored JSON parameters and the pipeline's current Ruby definition, so code changes made since the source ran are applied.
274
+
275
+ The source execution, its steps, jobs, callbacks, and status remain unchanged. Pipeline-chain relationships are not copied: upstream pipelines are not attached to the new execution, and downstream pipelines are not re-run. Normal branching and failure rules still apply, and jobs, callbacks, and external side effects may run again. Each confirmed submission intentionally creates another execution.
276
+
277
+ If the pipeline class has been removed, the stored parameters are no longer compatible, or the current definition is invalid, the dashboard creates nothing and reports that the pipeline could not be re-run. Re-run controls remain disabled while an execution is `pending`, `running`, or `canceling`.
222
278
 
223
279
  ### Pipeline Executions
224
280
 
@@ -232,12 +288,12 @@ The dashboard provides:
232
288
 
233
289
  ![Pipeline Definitions](docs/screenshots/definitions.png)
234
290
 
235
- No build step. Uses Pico CSS and Mermaid.js from CDN.
236
-
237
291
  ## Cleanup
238
292
 
239
293
  GoodPipeline automatically cleans up old terminal pipelines when GoodJob runs its own cleanup cycle. No configuration needed, it uses GoodJob's retention period (default 14 days).
240
294
 
295
+ Pending, running, and canceling pipelines are intentionally retained; canceled pipelines are terminal and follow the normal retention window. GoodJob may still remove old job rows belonging to a long-running or canceling pipeline, so timing for those steps appears as `—` in the dashboard after the retention window; the pipeline and step coordination records remain available.
296
+
241
297
  To configure the retention period, set GoodJob's option:
242
298
 
243
299
  ```ruby
@@ -2,12 +2,17 @@
2
2
 
3
3
  module GoodPipeline
4
4
  class FrontendsController < ApplicationController
5
+ # Rails' same-origin JavaScript response check treats an engine asset served
6
+ # through a controller like an executable action response. These files are
7
+ # public, versioned static assets and never read or mutate session state.
8
+ skip_forgery_protection only: :static
9
+
5
10
  def static
6
11
  file_name = "#{sanitized_id}.#{sanitized_format}"
7
12
  file_path = assets_directory.join(file_name)
8
13
 
9
14
  if file_path.exist?
10
- expires_in 1.year, public: true
15
+ apply_cache_headers
11
16
  send_file file_path, disposition: :inline
12
17
  else
13
18
  head :not_found
@@ -16,6 +21,18 @@ module GoodPipeline
16
21
 
17
22
  private
18
23
 
24
+ # The asset URL embeds GoodPipeline::VERSION, so a released build is immutable
25
+ # and safe to cache for a year. In development that version does not change
26
+ # between edits, which would otherwise pin the browser to a stale stylesheet
27
+ # for the life of the release.
28
+ def apply_cache_headers
29
+ if Rails.env.development?
30
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
31
+ else
32
+ expires_in 1.year, public: true
33
+ end
34
+ end
35
+
19
36
  def sanitized_id
20
37
  params[:id].to_s.gsub(/[^a-zA-Z0-9_-]/, "")
21
38
  end
@@ -1,58 +1,276 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GoodPipeline
4
- class PipelinesController < ApplicationController
4
+ class PipelinesController < ApplicationController # rubocop:disable Metrics/ClassLength
5
5
  PAGE_SIZE = 25
6
6
 
7
- def index
8
- @status = params[:status].presence
9
- @pipeline_type = params[:pipeline_type].presence
10
- @pipeline_types = PipelineRecord.distinct.pluck(:type).sort
11
- counts_scope = @pipeline_type ? PipelineRecord.where(type: @pipeline_type) : PipelineRecord
12
- @status_counts = counts_scope.group(:status).count
13
- @total_count = @status_counts.values.sum
14
- load_pipelines
7
+ before_action :require_dashboard_mutations_enabled!, only: %i[cancel rerun]
8
+
9
+ SidebarEntry = Data.define(
10
+ :type,
11
+ :id,
12
+ :on_failure_strategy,
13
+ :run_count,
14
+ :step_count,
15
+ :has_branch,
16
+ :large
17
+ ) do
18
+ alias_method :strategy, :on_failure_strategy
19
+ end
20
+
21
+ def index # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
22
+ @filters = Dashboard::FilterSet.from_params(params)
23
+ @now = Time.current
24
+
25
+ base_scope = apply_type_time_search(PipelineRecord.all)
26
+ @status_counts = base_scope.group(:status).count
27
+ @total = @filters.status == "all" ? @status_counts.values.sum : @status_counts.fetch(@filters.status, 0)
28
+
29
+ last_page = [(@total.to_f / PAGE_SIZE).ceil, 1].max
30
+ current_page = @filters.page.clamp(1, last_page)
31
+ @page = { current: current_page, last: last_page, total: @total, per_page: PAGE_SIZE }
32
+
33
+ list_scope = @filters.status == "all" ? base_scope : base_scope.where(status: @filters.status)
34
+ @pipelines = list_scope.order(created_at: :desc, id: :desc)
35
+ .offset((current_page - 1) * PAGE_SIZE)
36
+ .limit(PAGE_SIZE)
37
+ .to_a
38
+
39
+ load_page_steps
40
+ load_expanded_row
41
+ @sidebar_entries = load_sidebar_entries
42
+ @pipeline_types = @sidebar_entries.map(&:type)
43
+ @retention_seconds = GoodJob.configuration.cleanup_preserved_jobs_before_seconds_ago
44
+ @kpis = Dashboard::KpiCalculator.new(pipeline_type: @filters.pipeline_type, now: @now).call
45
+ @connection_info = Dashboard::ConnectionInfo.fetch
15
46
  end
16
47
 
17
- def definitions
18
- pipeline_types = PipelineRecord.distinct.pluck(:type).sort
19
- pipeline_ids = pipeline_types.filter_map do |type|
20
- PipelineRecord.where(type: type).order(created_at: :desc).pick(:id)
48
+ def definitions # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
49
+ @now = Time.current
50
+ @pipelines = PipelineRecord
51
+ .select("DISTINCT ON (type) good_pipeline_pipelines.*")
52
+ .order(:type, created_at: :desc, id: :desc)
53
+ .to_a
54
+ load_graph_records(@pipelines)
55
+ @run_counts = PipelineRecord.group(:type).count
56
+ @definition_runs = @run_counts
57
+ @definition_stages_by_pipeline = @pipelines.to_h do |pipeline|
58
+ stages = Dashboard::DefinitionStages.new(
59
+ steps: @steps_by_pipeline.fetch(pipeline.id, []),
60
+ dependencies: @dependencies_by_pipeline.fetch(pipeline.id, [])
61
+ ).call
62
+ [pipeline.id, stages]
21
63
  end
22
- @pipelines = PipelineRecord.includes(steps: :upstream_steps, dependencies: %i[step depends_on_step])
23
- .where(id: pipeline_ids)
24
- .sort_by(&:type)
64
+ @definition_stages = @definition_stages_by_pipeline
65
+ @connection_info = Dashboard::ConnectionInfo.fetch
25
66
  end
26
67
 
27
- def show
28
- scope = PipelineRecord.includes(
29
- :upstream_pipelines, :downstream_pipelines,
30
- steps: :upstream_steps,
31
- dependencies: %i[step depends_on_step]
32
- )
33
- @pipeline = scope.find(params[:id])
68
+ def show # rubocop:disable Metrics/MethodLength
69
+ @now = Time.current
70
+ @pipeline = PipelineRecord.find(params[:id])
71
+ load_graph_records([@pipeline])
72
+ @steps = @steps_by_pipeline.fetch(@pipeline.id, [])
73
+ @dependencies = @dependencies_by_pipeline.fetch(@pipeline.id, [])
74
+ @step_timings = Dashboard::StepTimings.new(@steps).call
75
+ @stage_lanes = Dashboard::StageLanes.new(
76
+ pipeline: @pipeline,
77
+ steps: @steps,
78
+ dependencies: @dependencies,
79
+ timings: @step_timings,
80
+ now: @now
81
+ ).call
82
+ load_chain_records
83
+ @connection_info = Dashboard::ConnectionInfo.fetch
84
+ end
85
+
86
+ def cancel # rubocop:disable Metrics/MethodLength
87
+ @pipeline = Coordinator.cancel_pipeline(params[:id])
88
+
89
+ notice = if @pipeline.status.to_s == "canceled"
90
+ "Pipeline canceled."
91
+ else
92
+ "Pipeline cancellation requested."
93
+ end
94
+ redirect_back fallback_location: pipeline_path(@pipeline), allow_other_host: false,
95
+ notice: notice, status: :see_other
96
+ rescue GoodPipeline::CancellationConflict => error
97
+ redirect_back fallback_location: pipeline_path(error.pipeline_id),
98
+ allow_other_host: false,
99
+ alert: "Pipeline is already #{error.status} and cannot be canceled.",
100
+ status: :see_other
101
+ end
102
+
103
+ def rerun # rubocop:disable Metrics/MethodLength
104
+ source = PipelineRecord.find(params[:id])
105
+
106
+ unless source.terminal?
107
+ return redirect_back fallback_location: pipeline_path(source), allow_other_host: false,
108
+ alert: "Only finished pipelines can be re-run.", status: :see_other
109
+ end
110
+
111
+ pipeline = build_rerun_pipeline(source)
112
+ unless pipeline
113
+ return redirect_back fallback_location: pipeline_path(source), allow_other_host: false,
114
+ alert: "Pipeline could not be re-run with its stored parameters and current definition.",
115
+ status: :see_other
116
+ end
117
+
118
+ rerun = Runner.call(pipeline)
119
+ redirect_to pipeline_path(rerun), notice: "Pipeline re-run started.", status: :see_other
34
120
  end
35
121
 
36
122
  private
37
123
 
38
- def load_pipelines
39
- scope = PipelineRecord.order(created_at: :desc, id: :desc)
40
- scope = scope.where(status: @status) if @status
41
- scope = scope.where(type: @pipeline_type) if @pipeline_type
42
- scope = apply_keyset_pagination(scope)
43
- records = scope.limit(PAGE_SIZE + 1).to_a
44
- @has_next_page = records.size > PAGE_SIZE
45
- @pipelines = records.first(PAGE_SIZE)
124
+ def build_rerun_pipeline(source)
125
+ pipeline_class = source.type.safe_constantize
126
+ unless pipeline_class.is_a?(Class) && pipeline_class < GoodPipeline::Pipeline
127
+ raise InvalidPipelineError, "#{source.type} is not an available GoodPipeline::Pipeline"
128
+ end
129
+ raise InvalidPipelineError, "stored pipeline parameters must be an object" unless source.params.is_a?(Hash)
130
+
131
+ pipeline_class.build(**source.params.symbolize_keys)
132
+ rescue ArgumentError, NameError, NotImplementedError, GoodPipeline::Error => error
133
+ Rails.error.report(error, handled: true, context: { good_pipeline_source_id: source.id })
134
+ nil
135
+ end
136
+
137
+ def require_dashboard_mutations_enabled!
138
+ head :forbidden unless GoodPipeline.dashboard_mutations_enabled?
46
139
  end
47
140
 
48
- def apply_keyset_pagination(scope)
49
- return scope unless params[:after_created_at].present? && params[:after_id].present?
141
+ def apply_type_time_search(scope)
142
+ scope = scope.where(type: @filters.pipeline_type) if @filters.pipeline_type
143
+ cutoff = @filters.time_cutoff(@now)
144
+ scope = scope.where("created_at >= ?", cutoff) if cutoff
145
+ return scope if @filters.query.blank?
50
146
 
147
+ escaped = ActiveRecord::Base.sanitize_sql_like(@filters.query.strip)
51
148
  scope.where(
52
- "(created_at, id) < (?, ?)",
53
- params[:after_created_at],
54
- params[:after_id]
149
+ "CAST(good_pipeline_pipelines.id AS text) ILIKE ? OR good_pipeline_pipelines.type ILIKE ?",
150
+ "#{escaped}%",
151
+ "%#{escaped}%"
55
152
  )
56
153
  end
154
+
155
+ def load_page_steps
156
+ page_ids = @pipelines.map(&:id)
157
+ steps = StepRecord.where(pipeline_id: page_ids).order(:created_at, :id).to_a
158
+ @steps_by_pipeline = steps.group_by(&:pipeline_id)
159
+ attach_steps(@pipelines, @steps_by_pipeline)
160
+ end
161
+
162
+ def load_expanded_row # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
163
+ @expanded_pipeline = @pipelines.find { |pipeline| pipeline.id.to_s == @filters.expanded.to_s }
164
+ return unless @expanded_pipeline
165
+
166
+ @expanded_steps = @steps_by_pipeline.fetch(@expanded_pipeline.id, [])
167
+ @expanded_dependencies = DependencyRecord.where(pipeline_id: @expanded_pipeline.id).order(:id).to_a
168
+ hydrate_dependencies(@expanded_dependencies, @expanded_steps)
169
+ @step_timings = Dashboard::StepTimings.new(@expanded_steps).call
170
+ @stage_lanes = Dashboard::StageLanes.new(
171
+ pipeline: @expanded_pipeline,
172
+ steps: @expanded_steps,
173
+ dependencies: @expanded_dependencies,
174
+ timings: @step_timings,
175
+ now: @now
176
+ ).call
177
+ end
178
+
179
+ def load_sidebar_entries # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
180
+ latest = PipelineRecord
181
+ .select(<<~SQL.squish)
182
+ DISTINCT ON (type)
183
+ type, id, on_failure_strategy,
184
+ COUNT(*) OVER (PARTITION BY type) AS run_count
185
+ SQL
186
+ .order(:type, created_at: :desc, id: :desc)
187
+ .to_a
188
+
189
+ ids = latest.map(&:id)
190
+ quoted_branch_class = StepRecord.connection.quote(GoodPipeline::BRANCH_JOB_CLASS)
191
+ metadata = StepRecord.where(pipeline_id: ids)
192
+ .group(:pipeline_id)
193
+ .pluck(
194
+ :pipeline_id,
195
+ Arel.sql("COUNT(*)"),
196
+ Arel.sql("BOOL_OR(job_class = #{quoted_branch_class})")
197
+ )
198
+ .to_h { |pipeline_id, count, has_branch| [pipeline_id, [count.to_i, has_branch]] }
199
+
200
+ latest.map do |pipeline|
201
+ step_count, has_branch = metadata.fetch(pipeline.id, [0, false])
202
+ SidebarEntry.new(
203
+ type: pipeline.type,
204
+ id: pipeline.id,
205
+ on_failure_strategy: pipeline.on_failure_strategy,
206
+ run_count: pipeline.attributes.fetch("run_count").to_i,
207
+ step_count: step_count,
208
+ has_branch: has_branch,
209
+ large: step_count > 60
210
+ )
211
+ end
212
+ end
213
+
214
+ def load_graph_records(pipelines) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
215
+ pipeline_ids = pipelines.map(&:id)
216
+ steps = StepRecord.where(pipeline_id: pipeline_ids).order(:created_at, :id).to_a
217
+ dependencies = DependencyRecord.where(pipeline_id: pipeline_ids).order(:id).to_a
218
+
219
+ @steps_by_pipeline = steps.group_by(&:pipeline_id)
220
+ @dependencies_by_pipeline = dependencies.group_by(&:pipeline_id)
221
+ attach_steps(pipelines, @steps_by_pipeline)
222
+
223
+ pipelines.each do |pipeline|
224
+ pipeline_dependencies = @dependencies_by_pipeline.fetch(pipeline.id, [])
225
+ hydrate_dependencies(pipeline_dependencies, @steps_by_pipeline.fetch(pipeline.id, []))
226
+ association = pipeline.association(:dependencies)
227
+ association.target = pipeline_dependencies
228
+ association.loaded!
229
+ end
230
+ end
231
+
232
+ def attach_steps(pipelines, steps_by_pipeline)
233
+ pipelines.each do |pipeline|
234
+ association = pipeline.association(:steps)
235
+ association.target = steps_by_pipeline.fetch(pipeline.id, [])
236
+ association.loaded!
237
+ end
238
+ end
239
+
240
+ def hydrate_dependencies(dependencies, steps)
241
+ by_id = steps.index_by(&:id)
242
+ dependencies.each do |dependency|
243
+ step_association = dependency.association(:step)
244
+ step_association.target = by_id[dependency.step_id]
245
+ step_association.loaded!
246
+ upstream_association = dependency.association(:depends_on_step)
247
+ upstream_association.target = by_id[dependency.depends_on_step_id]
248
+ upstream_association.loaded!
249
+ end
250
+ end
251
+
252
+ def load_chain_records # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
253
+ chains = ChainRecord.where(upstream_pipeline_id: @pipeline.id)
254
+ .or(ChainRecord.where(downstream_pipeline_id: @pipeline.id))
255
+ .to_a
256
+ other_ids = chains.flat_map { |chain| [chain.upstream_pipeline_id, chain.downstream_pipeline_id] }
257
+ .uniq
258
+ .excluding(@pipeline.id)
259
+ related = PipelineRecord.where(id: other_ids).index_by(&:id)
260
+
261
+ @upstream_pipelines = chains.filter_map do |chain|
262
+ related[chain.upstream_pipeline_id] if chain.downstream_pipeline_id == @pipeline.id
263
+ end
264
+ @downstream_pipelines = chains.filter_map do |chain|
265
+ related[chain.downstream_pipeline_id] if chain.upstream_pipeline_id == @pipeline.id
266
+ end
267
+
268
+ upstream = @pipeline.association(:upstream_pipelines)
269
+ upstream.target = @upstream_pipelines
270
+ upstream.loaded!
271
+ downstream = @pipeline.association(:downstream_pipelines)
272
+ downstream.target = @downstream_pipelines
273
+ downstream.loaded!
274
+ end
57
275
  end
58
276
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class ThemesController < ApplicationController
5
+ THEMES = %w[dark light].freeze
6
+
7
+ def update
8
+ theme = params[:theme].to_s
9
+ return head :unprocessable_entity unless THEMES.include?(theme)
10
+
11
+ cookies.permanent[:good_pipeline_theme] = { value: theme, same_site: :lax }
12
+ head :no_content
13
+ end
14
+ end
15
+ end