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,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class DependencyRecord < ActiveRecord::Base
5
+ self.table_name = "good_pipeline_dependencies"
6
+ self.record_timestamps = false
7
+
8
+ belongs_to :pipeline,
9
+ class_name: "GoodPipeline::PipelineRecord",
10
+ foreign_key: :pipeline_id,
11
+ inverse_of: :dependencies
12
+
13
+ belongs_to :step,
14
+ class_name: "GoodPipeline::StepRecord",
15
+ foreign_key: :step_id,
16
+ inverse_of: :upstream_dependencies
17
+
18
+ belongs_to :depends_on_step,
19
+ class_name: "GoodPipeline::StepRecord",
20
+ foreign_key: :depends_on_step_id,
21
+ inverse_of: :downstream_dependencies
22
+ end
23
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class PipelineRecord < ActiveRecord::Base
5
+ self.table_name = "good_pipeline_pipelines"
6
+ self.inheritance_column = nil
7
+
8
+ TERMINAL_STATUSES = %w[succeeded failed halted skipped].freeze
9
+
10
+ VALID_TRANSITIONS = {
11
+ "pending" => %w[running skipped],
12
+ "running" => %w[succeeded failed halted]
13
+ }.freeze
14
+
15
+ enum :status, {
16
+ pending: "pending",
17
+ running: "running",
18
+ succeeded: "succeeded",
19
+ failed: "failed",
20
+ halted: "halted",
21
+ skipped: "skipped"
22
+ }
23
+
24
+ enum :on_failure_strategy, { halt: "halt", continue: "continue", ignore: "ignore" }
25
+
26
+ has_many :steps,
27
+ class_name: "GoodPipeline::StepRecord",
28
+ foreign_key: :pipeline_id,
29
+ inverse_of: :pipeline,
30
+ dependent: :delete_all
31
+
32
+ has_many :dependencies,
33
+ class_name: "GoodPipeline::DependencyRecord",
34
+ foreign_key: :pipeline_id,
35
+ inverse_of: :pipeline,
36
+ dependent: :delete_all
37
+
38
+ has_many :downstream_chains,
39
+ class_name: "GoodPipeline::ChainRecord",
40
+ foreign_key: :upstream_pipeline_id,
41
+ inverse_of: :upstream_pipeline,
42
+ dependent: :delete_all
43
+
44
+ has_many :downstream_pipelines,
45
+ through: :downstream_chains,
46
+ source: :downstream_pipeline
47
+
48
+ has_many :upstream_chains,
49
+ class_name: "GoodPipeline::ChainRecord",
50
+ foreign_key: :downstream_pipeline_id,
51
+ inverse_of: :downstream_pipeline,
52
+ dependent: :delete_all
53
+
54
+ has_many :upstream_pipelines,
55
+ through: :upstream_chains,
56
+ source: :upstream_pipeline
57
+
58
+ def terminal?
59
+ TERMINAL_STATUSES.include?(status)
60
+ end
61
+
62
+ def transition_to!(new_status)
63
+ new_status = new_status.to_s
64
+ allowed = VALID_TRANSITIONS.fetch(status, [])
65
+
66
+ unless allowed.include?(new_status)
67
+ raise InvalidTransition, "cannot transition pipeline from '#{status}' to '#{new_status}'"
68
+ end
69
+
70
+ update!(status: new_status)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class StepRecord < ActiveRecord::Base
5
+ self.table_name = "good_pipeline_steps"
6
+
7
+ TERMINAL_COORDINATION_STATUSES = %w[succeeded failed skipped].freeze
8
+
9
+ VALID_COORDINATION_TRANSITIONS = {
10
+ "pending" => %w[enqueued skipped],
11
+ "enqueued" => %w[succeeded failed]
12
+ }.freeze
13
+
14
+ enum :coordination_status, {
15
+ pending: "pending",
16
+ enqueued: "enqueued",
17
+ succeeded: "succeeded",
18
+ failed: "failed",
19
+ skipped: "skipped"
20
+ }
21
+
22
+ enum :on_failure_strategy, { halt: "halt", continue: "continue", ignore: "ignore" }
23
+
24
+ belongs_to :pipeline,
25
+ class_name: "GoodPipeline::PipelineRecord",
26
+ foreign_key: :pipeline_id,
27
+ inverse_of: :steps
28
+
29
+ has_many :upstream_dependencies,
30
+ class_name: "GoodPipeline::DependencyRecord",
31
+ foreign_key: :step_id,
32
+ inverse_of: :step,
33
+ dependent: :delete_all
34
+
35
+ has_many :upstream_steps,
36
+ through: :upstream_dependencies,
37
+ source: :depends_on_step
38
+
39
+ has_many :downstream_dependencies,
40
+ class_name: "GoodPipeline::DependencyRecord",
41
+ foreign_key: :depends_on_step_id,
42
+ inverse_of: :depends_on_step,
43
+ dependent: :delete_all
44
+
45
+ has_many :downstream_steps,
46
+ through: :downstream_dependencies,
47
+ source: :step
48
+
49
+ def duration
50
+ return nil unless good_job_id
51
+
52
+ good_job = GoodJob::Job.find_by(id: good_job_id)
53
+ return nil unless good_job&.performed_at && good_job.finished_at
54
+
55
+ good_job.finished_at - good_job.performed_at
56
+ end
57
+
58
+ def terminal_coordination_status?
59
+ TERMINAL_COORDINATION_STATUSES.include?(coordination_status)
60
+ end
61
+
62
+ def transition_coordination_status_to!(new_status)
63
+ new_status = new_status.to_s
64
+ allowed = VALID_COORDINATION_TRANSITIONS.fetch(coordination_status, [])
65
+
66
+ unless allowed.include?(new_status)
67
+ raise InvalidTransition,
68
+ "cannot transition step '#{key}' coordination_status from '#{coordination_status}' to '#{new_status}'"
69
+ end
70
+
71
+ update!(coordination_status: new_status)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,30 @@
1
+ <% if pipeline.upstream_pipelines.any? || pipeline.downstream_pipelines.any? %>
2
+ <div class="chain-cards">
3
+ <% if pipeline.upstream_pipelines.any? %>
4
+ <article>
5
+ <header><h4>Upstream</h4></header>
6
+ <div class="chain-chips">
7
+ <% pipeline.upstream_pipelines.each do |upstream| %>
8
+ <%= link_to pipeline_path(upstream), class: "chain-chip" do %>
9
+ <span class="chain-label"><%= humanized_type(upstream.type) %></span>
10
+ <%= status_badge(upstream.status) %>
11
+ <% end %>
12
+ <% end %>
13
+ </div>
14
+ </article>
15
+ <% end %>
16
+ <% if pipeline.downstream_pipelines.any? %>
17
+ <article>
18
+ <header><h4>Downstream</h4></header>
19
+ <div class="chain-chips">
20
+ <% pipeline.downstream_pipelines.each do |downstream| %>
21
+ <%= link_to pipeline_path(downstream), class: "chain-chip" do %>
22
+ <span class="chain-label"><%= humanized_type(downstream.type) %></span>
23
+ <%= status_badge(downstream.status) %>
24
+ <% end %>
25
+ <% end %>
26
+ </div>
27
+ </article>
28
+ <% end %>
29
+ </div>
30
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <% if params[:after_created_at].present? || @has_next_page %>
2
+ <nav>
3
+ <ul>
4
+ <li>
5
+ <% if params[:after_created_at].present? %>
6
+ <%= link_to "\u2190 First page", pipelines_path(status: @status, pipeline_type: @pipeline_type) %>
7
+ <% end %>
8
+ </li>
9
+ </ul>
10
+ <ul>
11
+ <li>
12
+ <% if @has_next_page %>
13
+ <% last_pipeline = @pipelines.last %>
14
+ <%= link_to "Next \u2192", pipelines_path(
15
+ status: @status,
16
+ pipeline_type: @pipeline_type,
17
+ after_created_at: last_pipeline.created_at.iso8601(6),
18
+ after_id: last_pipeline.id
19
+ ) %>
20
+ <% end %>
21
+ </li>
22
+ </ul>
23
+ </nav>
24
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <tr data-href="<%= pipeline_path(pipeline) %>" class="<%= 'row-running' if pipeline.running? %>">
2
+ <td><span class="execution-id"><%= pipeline.id.to_s.first(8) %></span></td>
3
+ <td><span class="pipeline-name"><%= humanized_type(pipeline.type) %></span></td>
4
+ <td><%= status_badge(pipeline.status) %></td>
5
+ <td><%= pipeline.created_at.strftime("%Y-%m-%d %I:%M %p") %></td>
6
+ <td><%= pipeline_duration(pipeline) || "\u2014" %></td>
7
+ </tr>
@@ -0,0 +1,33 @@
1
+ <header><h3>Steps</h3></header>
2
+ <table>
3
+ <thead>
4
+ <tr>
5
+ <th>Key</th>
6
+ <th>Job Class</th>
7
+ <th>Status</th>
8
+ <th>Duration</th>
9
+ <th>Error</th>
10
+ </tr>
11
+ </thead>
12
+ <tbody>
13
+ <% pipeline.steps.each do |step| %>
14
+ <tr>
15
+ <td>
16
+ <% if (url = good_job_step_url(step)) %>
17
+ <%= link_to step.key, url, target: "_blank", rel: "noopener" %>
18
+ <% else %>
19
+ <%= step.key %>
20
+ <% end %>
21
+ </td>
22
+ <td><%= step.job_class %></td>
23
+ <td><%= status_badge(step.coordination_status) %></td>
24
+ <td><%= step.duration ? "#{step.duration.round(2)}s" : "\u2014" %></td>
25
+ <td>
26
+ <% if step.error_class %>
27
+ <small><%= step.error_class %>: <%= step.error_message %></small>
28
+ <% end %>
29
+ </td>
30
+ </tr>
31
+ <% end %>
32
+ </tbody>
33
+ </table>
@@ -0,0 +1,49 @@
1
+ <h1 class="page-title">Pipeline Definitions</h1>
2
+
3
+ <% if @pipelines.any? %>
4
+ <div class="definitions-layout">
5
+ <div class="definitions-sidebar">
6
+ <% @pipelines.each_with_index do |pipeline, index| %>
7
+ <button type="button" class="definition-item <%= 'active' if index.zero? %>" data-pipeline="<%= pipeline.type %>">
8
+ <span class="definition-item-name"><%= humanized_type(pipeline.type) %></span>
9
+ <span class="definition-item-meta"><%= pipeline.steps.size %> steps</span>
10
+ </button>
11
+ <% end %>
12
+ </div>
13
+
14
+ <div class="definitions-detail">
15
+ <% @pipelines.each_with_index do |pipeline, index| %>
16
+ <div class="definition-panel <%= 'active' if index.zero? %>" data-pipeline="<%= pipeline.type %>">
17
+ <article>
18
+ <header class="definition-header">
19
+ <div>
20
+ <h2><%= humanized_type(pipeline.type) %></h2>
21
+ <span class="definition-subtitle">on failure: <%= pipeline.on_failure_strategy %></span>
22
+ </div>
23
+ <%= link_to "View Executions \u2192", pipelines_path(pipeline_type: pipeline.type), class: "definition-executions-link" %>
24
+ </header>
25
+ <% if pipeline.steps.any? %>
26
+ <pre class="mermaid"><%= raw mermaid_definition_diagram(pipeline) %></pre>
27
+ <% else %>
28
+ <p class="empty-state">No steps defined.</p>
29
+ <% end %>
30
+ </article>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+ </div>
35
+
36
+ <script>
37
+ document.querySelectorAll('.definition-item').forEach(function(button) {
38
+ button.addEventListener('click', function() {
39
+ var type = this.dataset.pipeline;
40
+ document.querySelectorAll('.definition-item').forEach(function(b) { b.classList.remove('active'); });
41
+ document.querySelectorAll('.definition-panel').forEach(function(p) { p.classList.remove('active'); });
42
+ this.classList.add('active');
43
+ document.querySelector('.definition-panel[data-pipeline="' + type + '"]').classList.add('active');
44
+ });
45
+ });
46
+ </script>
47
+ <% else %>
48
+ <p class="empty-state">No pipeline executions found yet. Run a pipeline to see its definition here.</p>
49
+ <% end %>
@@ -0,0 +1,43 @@
1
+ <h1 class="page-title">Pipeline Executions</h1>
2
+
3
+ <div class="filters-row">
4
+ <ul class="status-tabs">
5
+ <li><%= link_to "All (#{@total_count})", pipelines_path(pipeline_type: @pipeline_type), class: ("active" if @status.nil?) %></li>
6
+ <% %w[running succeeded failed halted skipped].each do |status| %>
7
+ <li><%= link_to "#{status.titleize} (#{@status_counts[status] || 0})", pipelines_path(status: status, pipeline_type: @pipeline_type), class: ("active" if @status == status), data: { status: status } %></li>
8
+ <% end %>
9
+ </ul>
10
+ <select class="pipeline-type-select" onchange="window.location = this.value;">
11
+ <option value="<%= pipelines_path(status: @status) %>" <%= "selected" if @pipeline_type.nil? %>>All Pipelines</option>
12
+ <% @pipeline_types.each do |pipeline_type| %>
13
+ <option value="<%= pipelines_path(status: @status, pipeline_type: pipeline_type) %>" <%= "selected" if @pipeline_type == pipeline_type %>>
14
+ <%= humanized_type(pipeline_type) %>
15
+ </option>
16
+ <% end %>
17
+ </select>
18
+ </div>
19
+
20
+ <% if @pipelines.any? %>
21
+ <div class="table-card">
22
+ <table>
23
+ <thead>
24
+ <tr>
25
+ <th>Execution ID</th>
26
+ <th>Pipeline Name</th>
27
+ <th>Status</th>
28
+ <th>Start Time</th>
29
+ <th>Duration</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody>
33
+ <% @pipelines.each do |pipeline| %>
34
+ <%= render "pipeline_row", pipeline: pipeline %>
35
+ <% end %>
36
+ </tbody>
37
+ </table>
38
+ </div>
39
+
40
+ <%= render "pagination" %>
41
+ <% else %>
42
+ <p class="empty-state">No pipelines found.</p>
43
+ <% end %>
@@ -0,0 +1,40 @@
1
+ <p><%= link_to "\u2190 All Pipelines", pipelines_path %></p>
2
+
3
+ <div class="pipeline-detail-grid">
4
+ <div class="pipeline-detail-left">
5
+ <article class="pipeline-details">
6
+ <header><h2><%= humanized_type(@pipeline.type) %></h2></header>
7
+ <dl>
8
+ <dt>Status</dt>
9
+ <dd><%= status_badge(@pipeline.status) %></dd>
10
+ <dt>Failure Strategy</dt>
11
+ <dd><span class="strategy-label"><%= @pipeline.on_failure_strategy %></span></dd>
12
+ <dt>Params</dt>
13
+ <dd><code><%= @pipeline.params&.to_json %></code></dd>
14
+ <dt>Created</dt>
15
+ <dd><%= relative_time_tag(@pipeline.created_at) %></dd>
16
+ <% if pipeline_duration(@pipeline) %>
17
+ <dt>Duration</dt>
18
+ <dd><%= pipeline_duration(@pipeline) %></dd>
19
+ <% end %>
20
+ </dl>
21
+ </article>
22
+
23
+ <%= render "chain_links", pipeline: @pipeline %>
24
+
25
+ <article class="table-card">
26
+ <%= render "steps_table", pipeline: @pipeline %>
27
+ </article>
28
+ </div>
29
+
30
+ <div class="pipeline-detail-right">
31
+ <article>
32
+ <header><h3>Pipeline Graph</h3></header>
33
+ <% if @pipeline.steps.any? %>
34
+ <pre class="mermaid"><%= raw mermaid_diagram(@pipeline) %></pre>
35
+ <% else %>
36
+ <p class="empty-state">No steps defined.</p>
37
+ <% end %>
38
+ </article>
39
+ </div>
40
+ </div>
@@ -0,0 +1,40 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>GoodPipeline</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
8
+ <link rel="stylesheet" href="<%= frontend_static_path(id: 'style', format: :css) %>">
9
+ </head>
10
+ <body>
11
+ <header class="dashboard-header">
12
+ <span class="header-left">
13
+ <%= link_to "GoodPipeline", root_path %>
14
+ <%= link_to "Definitions", definitions_pipelines_path, class: "header-nav-link" %>
15
+ </span>
16
+ <span class="header-right">
17
+ <span class="header-version">v<%= GoodPipeline::VERSION %></span>
18
+ <a href="https://github.com/milkstrawai/good_pipeline" target="_blank" rel="noopener" class="header-github" title="GitHub">
19
+ <svg height="18" width="18" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
20
+ </a>
21
+ </span>
22
+ </header>
23
+ <main class="dashboard-content">
24
+ <%= yield %>
25
+ </main>
26
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
27
+ <script>
28
+ mermaid.initialize({
29
+ startOnLoad: true,
30
+ theme: 'neutral',
31
+ flowchart: { curve: 'basis', nodeSpacing: 30, rankSpacing: 50 }
32
+ });
33
+ document.querySelectorAll('tr[data-href]').forEach(function(row) {
34
+ row.addEventListener('click', function() {
35
+ window.location = this.dataset.href;
36
+ });
37
+ });
38
+ </script>
39
+ </body>
40
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ GoodPipeline::Engine.routes.draw do
4
+ root "pipelines#index"
5
+
6
+ resources :pipelines, only: %i[index show] do
7
+ get :definitions, on: :collection
8
+ end
9
+
10
+ scope :frontend, controller: :frontends, defaults: { version: GoodPipeline::VERSION.tr(".", "-") } do
11
+ get "static/:version/:id", action: :static, as: :frontend_static
12
+ end
13
+ end
data/demo/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config/application"
4
+
5
+ Rails.application.load_tasks
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AlwaysFailingJob < ApplicationJob
4
+ class AlwaysFailingError < StandardError
5
+ end
6
+
7
+ retry_on AlwaysFailingError, wait: 0, attempts: 3
8
+
9
+ def perform(**_kwargs)
10
+ raise AlwaysFailingError, "always fails"
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CleanupJob < ApplicationJob
4
+ def perform(**); end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DownloadJob < ApplicationJob
4
+ def perform(**); end
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class FailingJob < ApplicationJob
4
+ class FailingError < StandardError
5
+ end
6
+
7
+ discard_on FailingError
8
+
9
+ def perform(**)
10
+ raise FailingError, "intentional failure"
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PublishJob < ApplicationJob
4
+ def perform(**); end
5
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RetryableJob < ApplicationJob
4
+ class RetryableError < StandardError
5
+ end
6
+
7
+ retry_on RetryableError, wait: 0, attempts: 5
8
+
9
+ def perform(tracker_key:)
10
+ result = ActiveRecord::Base.connection.execute(
11
+ ActiveRecord::Base.sanitize_sql(
12
+ ["UPDATE attempt_trackers SET count = count + 1 WHERE key = ? RETURNING count", tracker_key]
13
+ )
14
+ )
15
+ count = result.first["count"]
16
+
17
+ raise RetryableError, "attempt #{count}" if count < 3
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ThumbnailJob < ApplicationJob
4
+ def perform(**); end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TranscodeJob < ApplicationJob
4
+ def perform(**); end
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AnalyticsPipeline < GoodPipeline::Pipeline
4
+ def configure(**_kwargs)
5
+ run :analyze, DownloadJob
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ArchivePipeline < GoodPipeline::Pipeline
4
+ def configure(**_kwargs)
5
+ run :archive, DownloadJob
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ContinueTestPipeline < GoodPipeline::Pipeline
4
+ failure_strategy :continue
5
+
6
+ def configure(**_kwargs)
7
+ run :step_a, FailingJob
8
+ run :step_b, DownloadJob
9
+ run :step_c, DownloadJob, after: %i[step_a step_b]
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HaltTestPipeline < GoodPipeline::Pipeline
4
+ failure_strategy :halt
5
+
6
+ def configure(**_kwargs)
7
+ run :step_a, FailingJob
8
+ run :step_b, DownloadJob, after: :step_a
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NotificationPipeline < GoodPipeline::Pipeline
4
+ def configure(**_kwargs)
5
+ run :notify, DownloadJob
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TestPipeline < GoodPipeline::Pipeline
4
+ def configure(**) = run(:default, DownloadJob)
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class VideoProcessingPipeline < GoodPipeline::Pipeline
4
+ description "Downloads, transcodes and publishes a video"
5
+ failure_strategy :halt
6
+
7
+ def configure(video_id:, **)
8
+ run :download, DownloadJob, with: { video_id: video_id }
9
+ run :transcode, TranscodeJob, after: :download
10
+ run :thumbnail, ThumbnailJob, after: :download
11
+ run :publish, PublishJob, after: %i[transcode thumbnail]
12
+ run :cleanup, CleanupJob, after: :publish
13
+ end
14
+ end
data/demo/bin/rails ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ APP_PATH = File.expand_path("../config/application", __dir__)
5
+ require_relative "../config/boot"
6
+ require "rails/commands"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "boot"
4
+ require "rails/all"
5
+ require "good_job"
6
+ require "good_job/engine"
7
+ require "good_pipeline"
8
+ require "good_pipeline/engine"
9
+
10
+ Bundler.require(*Rails.groups)
11
+
12
+ module TestApp
13
+ class Application < Rails::Application
14
+ config.load_defaults Rails::VERSION::STRING.to_f
15
+
16
+ config.active_job.queue_adapter = :good_job
17
+ end
18
+ end