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,518 @@
1
+ /* GoodPipeline Dashboard Styles */
2
+
3
+ /* Page background */
4
+ html {
5
+ background-color: #f5f6fa;
6
+ }
7
+
8
+ body {
9
+ background-color: #f5f6fa;
10
+ min-height: 100vh;
11
+ }
12
+
13
+ /* Full-width layout */
14
+ .dashboard-content {
15
+ padding: 0 2rem;
16
+ }
17
+
18
+ /* Header bar */
19
+ .dashboard-header {
20
+ background: #1a1a2e;
21
+ padding: 0.55rem 2rem;
22
+ margin-bottom: 1.5rem;
23
+ display: flex;
24
+ justify-content: space-between;
25
+ align-items: center;
26
+ }
27
+
28
+ .header-right {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 0.75rem;
32
+ }
33
+
34
+ .header-version {
35
+ color: rgba(255, 255, 255, 0.5);
36
+ font-size: 0.78rem;
37
+ }
38
+
39
+ .header-github {
40
+ color: rgba(255, 255, 255, 0.5);
41
+ display: flex;
42
+ transition: color 0.15s ease;
43
+ }
44
+
45
+ .header-github:hover {
46
+ color: #fff;
47
+ }
48
+
49
+ .dashboard-header a {
50
+ color: #fff;
51
+ text-decoration: none;
52
+ font-size: 1rem;
53
+ font-weight: 700;
54
+ }
55
+
56
+ .dashboard-header a:hover {
57
+ color: rgba(255, 255, 255, 0.85);
58
+ }
59
+
60
+ .header-left {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 1.5rem;
64
+ }
65
+
66
+ .header-nav-link {
67
+ font-weight: 400;
68
+ font-size: 0.85rem;
69
+ color: rgba(255, 255, 255, 0.6);
70
+ }
71
+
72
+ .header-nav-link:hover {
73
+ color: #fff;
74
+ }
75
+
76
+ /* Page title */
77
+ .page-title {
78
+ margin-bottom: 0.5rem;
79
+ font-size: 1.5rem;
80
+ }
81
+
82
+ /* Filters row — tabs + dropdown */
83
+ .filters-row {
84
+ display: flex;
85
+ justify-content: space-between;
86
+ align-items: center;
87
+ gap: 1rem;
88
+ margin-bottom: 1rem;
89
+ }
90
+
91
+ .filters-row .status-tabs {
92
+ margin-bottom: 0;
93
+ flex: 1;
94
+ }
95
+
96
+ /* Pipeline type dropdown */
97
+ .filters-row .pipeline-type-select {
98
+ width: auto !important;
99
+ min-width: 200px;
100
+ max-width: 250px;
101
+ margin: 0;
102
+ padding: 0.3rem 0.65rem;
103
+ font-size: 0.82rem;
104
+ flex-shrink: 0;
105
+ }
106
+
107
+ /* Status badges — pastel outlined style */
108
+ .badge {
109
+ display: inline-block;
110
+ padding: 0.15em 0.55em;
111
+ border-radius: 20px;
112
+ font-size: 0.78em;
113
+ font-weight: 600;
114
+ white-space: nowrap;
115
+ }
116
+
117
+ .badge-pending { background-color: #f5f5f5; color: #757575; border: 1px solid #e0e0e0; }
118
+ .badge-running { background-color: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
119
+ .badge-enqueued { background-color: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
120
+ .badge-succeeded { background-color: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
121
+ .badge-failed { background-color: #ffebee; color: #c62828; border: 1px solid #ffcdd2; }
122
+ .badge-halted { background-color: #fff3e0; color: #e65100; border: 1px solid #ffe0b2; }
123
+ .badge-skipped { background-color: #f5f5f5; color: #757575; border: 1px solid #e0e0e0; }
124
+
125
+ /* Strategy label */
126
+ .strategy-label {
127
+ display: inline-block;
128
+ padding: 0.15em 0.5em;
129
+ border-radius: 4px;
130
+ font-size: 0.85em;
131
+ background-color: #e9ecef;
132
+ color: #495057;
133
+ }
134
+
135
+ /* Status tabs */
136
+ .status-tabs {
137
+ display: flex;
138
+ gap: 0.35rem;
139
+ flex-wrap: wrap;
140
+ margin-bottom: 1rem;
141
+ padding: 0;
142
+ list-style-type: none;
143
+ }
144
+
145
+ .status-tabs li {
146
+ margin: 0;
147
+ list-style-type: none;
148
+ }
149
+
150
+ .status-tabs a {
151
+ display: inline-block;
152
+ padding: 0.25em 0.6em;
153
+ border-radius: 20px;
154
+ text-decoration: none;
155
+ font-size: 0.78em;
156
+ font-weight: 600;
157
+ border: 1px solid #e0e0e0;
158
+ color: var(--pico-color);
159
+ background: #fff;
160
+ transition: all 0.15s ease;
161
+ }
162
+
163
+ .status-tabs a:hover {
164
+ background-color: #f5f5f5;
165
+ }
166
+
167
+ .status-tabs a.active {
168
+ background-color: #1a1a2e;
169
+ color: #fff;
170
+ border-color: #1a1a2e;
171
+ }
172
+
173
+ /* Colored tab text per status */
174
+ .status-tabs a[data-status="running"] { color: #2e7d32; }
175
+ .status-tabs a[data-status="succeeded"] { color: #2e7d32; }
176
+ .status-tabs a[data-status="failed"] { color: #c62828; }
177
+ .status-tabs a[data-status="halted"] { color: #e65100; }
178
+ .status-tabs a[data-status="skipped"] { color: #757575; }
179
+
180
+ /* Active colored tabs */
181
+ .status-tabs a[data-status="running"].active { background-color: #2e7d32; border-color: #2e7d32; color: #fff; }
182
+ .status-tabs a[data-status="succeeded"].active { background-color: #2e7d32; border-color: #2e7d32; color: #fff; }
183
+ .status-tabs a[data-status="failed"].active { background-color: #c62828; border-color: #c62828; color: #fff; }
184
+ .status-tabs a[data-status="halted"].active { background-color: #e65100; border-color: #e65100; color: #fff; }
185
+ .status-tabs a[data-status="skipped"].active { background-color: #757575; border-color: #757575; color: #fff; }
186
+
187
+ /* Table card */
188
+ .table-card {
189
+ background: #fff;
190
+ border-radius: 10px;
191
+ overflow: hidden;
192
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
193
+ border: 1px solid #eeeef2;
194
+ margin-bottom: 1.5rem;
195
+ }
196
+
197
+ .table-card table {
198
+ margin: 0;
199
+ border-collapse: collapse;
200
+ width: 100%;
201
+ }
202
+
203
+ .table-card table :is(th, td) {
204
+ padding: 0.45rem 0.9rem;
205
+ font-size: 0.85rem;
206
+ border: none;
207
+ border-bottom: 1px solid #f2f2f6;
208
+ text-align: left;
209
+ }
210
+
211
+ .table-card table thead th {
212
+ font-size: 0.68rem;
213
+ text-transform: uppercase;
214
+ letter-spacing: 0.07em;
215
+ color: #9e9e9e;
216
+ font-weight: 700;
217
+ border-bottom: 1px solid #eeeef2;
218
+ background: #fafbfc;
219
+ padding: 0.55rem 0.9rem;
220
+ }
221
+
222
+ .table-card table tbody tr:last-child td {
223
+ border-bottom: none;
224
+ }
225
+
226
+ /* Alternating row stripes */
227
+ .table-card table tbody tr:nth-child(even) {
228
+ background-color: #fafbfd;
229
+ }
230
+
231
+ /* Clickable pipeline rows */
232
+ .table-card tr[data-href] {
233
+ cursor: pointer;
234
+ transition: background-color 0.1s ease;
235
+ }
236
+
237
+ .table-card tr[data-href]:hover {
238
+ background-color: #f0f1f8;
239
+ }
240
+
241
+ /* Running row highlight */
242
+ .table-card tr.row-running,
243
+ .table-card tr.row-running:nth-child(even) {
244
+ background-color: #e3eeff;
245
+ }
246
+
247
+ .table-card tr.row-running:hover {
248
+ background-color: #d6e5ff;
249
+ }
250
+
251
+ /* Execution ID */
252
+ .execution-id {
253
+ font-family: var(--pico-font-family-monospace, monospace);
254
+ font-size: 0.82em;
255
+ color: #616161;
256
+ }
257
+
258
+ /* Pipeline name in table */
259
+ .pipeline-name {
260
+ font-weight: 700;
261
+ color: #1a1a2e;
262
+ }
263
+
264
+ /* Text muted */
265
+ .text-muted {
266
+ color: var(--pico-muted-color);
267
+ }
268
+
269
+ /* Pipeline detail grid */
270
+ .pipeline-detail-grid {
271
+ display: grid;
272
+ grid-template-columns: 3fr 2fr;
273
+ gap: 1.5rem;
274
+ align-items: start;
275
+ }
276
+
277
+ @media (max-width: 768px) {
278
+ .pipeline-detail-grid {
279
+ grid-template-columns: 1fr;
280
+ }
281
+ }
282
+
283
+ /* Detail page cards */
284
+ .pipeline-detail-grid article {
285
+ background: #fff;
286
+ border-radius: 10px;
287
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
288
+ border: 1px solid #eeeef2;
289
+ }
290
+
291
+ .pipeline-detail-grid article > header :is(h2, h3, h4) {
292
+ margin-bottom: 0;
293
+ }
294
+
295
+ /* Detail page definition list */
296
+ .pipeline-details dl {
297
+ display: grid;
298
+ grid-template-columns: auto 1fr;
299
+ gap: 0.4rem 1rem;
300
+ margin: 0;
301
+ }
302
+
303
+ .pipeline-details dt {
304
+ font-weight: 600;
305
+ color: var(--pico-muted-color);
306
+ font-size: 0.85rem;
307
+ }
308
+
309
+ .pipeline-details dd {
310
+ margin: 0;
311
+ }
312
+
313
+ /* Steps table on show page */
314
+ article.table-card {
315
+ padding: 0;
316
+ }
317
+
318
+ article.table-card > header {
319
+ padding: 0.75rem 0.9rem;
320
+ margin: 0;
321
+ }
322
+
323
+ .pipeline-detail-left .table-card table :is(th, td) {
324
+ padding: 0.4rem 0.7rem;
325
+ font-size: 0.82rem;
326
+ }
327
+
328
+ /* Mermaid diagram container */
329
+ .mermaid {
330
+ display: flex;
331
+ justify-content: center;
332
+ background: transparent;
333
+ margin-bottom: 0;
334
+ }
335
+
336
+ /* Empty state */
337
+ .empty-state {
338
+ text-align: center;
339
+ padding: 3rem 1rem;
340
+ color: var(--pico-muted-color);
341
+ }
342
+
343
+ /* Chain cards */
344
+ .chain-cards {
345
+ display: flex;
346
+ gap: 1rem;
347
+ margin-bottom: 0;
348
+ }
349
+
350
+ .chain-cards > article {
351
+ flex: 1;
352
+ }
353
+
354
+ .chain-chips {
355
+ display: flex;
356
+ flex-wrap: wrap;
357
+ gap: 0.4rem;
358
+ }
359
+
360
+ .chain-empty {
361
+ margin: 0;
362
+ font-size: 0.82rem;
363
+ }
364
+
365
+ .chain-chip {
366
+ display: inline-flex;
367
+ align-items: center;
368
+ gap: 0.4rem;
369
+ padding: 0.35rem 0.7rem;
370
+ background: #fff;
371
+ border: 1px solid #eeeef2;
372
+ border-radius: 8px;
373
+ text-decoration: none;
374
+ font-size: 0.82rem;
375
+ color: #1a1a2e;
376
+ transition: border-color 0.15s ease;
377
+ }
378
+
379
+ .chain-chip:hover {
380
+ border-color: var(--pico-primary);
381
+ }
382
+
383
+ .chain-arrow {
384
+ color: #9e9e9e;
385
+ font-size: 0.9em;
386
+ }
387
+
388
+ .chain-label {
389
+ font-weight: 600;
390
+ }
391
+
392
+ /* Definitions layout — sidebar + detail */
393
+ .definitions-layout {
394
+ display: grid;
395
+ grid-template-columns: 280px 1fr;
396
+ gap: 1rem;
397
+ align-items: start;
398
+ }
399
+
400
+ @media (max-width: 768px) {
401
+ .definitions-layout {
402
+ grid-template-columns: 1fr;
403
+ }
404
+ }
405
+
406
+ /* Sidebar list */
407
+ .definitions-sidebar {
408
+ background: #fff;
409
+ border-radius: 10px;
410
+ border: 1px solid #eeeef2;
411
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
412
+ overflow: hidden;
413
+ }
414
+
415
+ .definition-item {
416
+ display: flex;
417
+ justify-content: space-between;
418
+ align-items: baseline;
419
+ gap: 0.5rem;
420
+ width: 100%;
421
+ padding: 0.45rem 0.75rem;
422
+ border: none;
423
+ border-bottom: 1px solid #f2f2f6;
424
+ background: transparent;
425
+ cursor: pointer;
426
+ text-align: left;
427
+ transition: background-color 0.1s ease;
428
+ font-family: inherit;
429
+ font-size: 0.82rem;
430
+ color: #1a1a2e;
431
+ outline: none;
432
+ margin: 0;
433
+ border-radius: 0;
434
+ }
435
+
436
+ .definition-item:last-child {
437
+ border-bottom: none;
438
+ }
439
+
440
+ .definition-item:hover {
441
+ background-color: #f8f9ff;
442
+ }
443
+
444
+ .definition-item.active {
445
+ background-color: #e8f0fe;
446
+ border-left: 3px solid #4a90d9;
447
+ }
448
+
449
+ .definition-item:focus,
450
+ .definition-item:focus-visible {
451
+ outline: none;
452
+ box-shadow: none;
453
+ }
454
+
455
+ .definition-item-name {
456
+ font-weight: 600;
457
+ white-space: nowrap;
458
+ overflow: hidden;
459
+ text-overflow: ellipsis;
460
+ }
461
+
462
+ .definition-item-meta {
463
+ font-size: 0.7rem;
464
+ color: #9e9e9e;
465
+ white-space: nowrap;
466
+ flex-shrink: 0;
467
+ }
468
+
469
+ /* Detail panel */
470
+ .definitions-detail {
471
+ min-height: 400px;
472
+ position: relative;
473
+ }
474
+
475
+ .definition-panel {
476
+ position: absolute;
477
+ visibility: hidden;
478
+ width: 100%;
479
+ }
480
+
481
+ .definition-panel.active {
482
+ position: static;
483
+ visibility: visible;
484
+ }
485
+
486
+ .definition-panel article {
487
+ background: #fff;
488
+ border-radius: 10px;
489
+ border: 1px solid #eeeef2;
490
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
491
+ }
492
+
493
+ .definition-panel article > header :is(h2) {
494
+ margin-bottom: 0;
495
+ }
496
+
497
+ .definition-header {
498
+ display: flex;
499
+ justify-content: space-between;
500
+ align-items: flex-start;
501
+ }
502
+
503
+ .definition-subtitle {
504
+ font-size: 0.8rem;
505
+ color: #9e9e9e;
506
+ }
507
+
508
+ .definition-executions-link {
509
+ font-size: 0.82rem;
510
+ white-space: nowrap;
511
+ text-decoration: none;
512
+ }
513
+
514
+ /* Params preview */
515
+ .params-preview code {
516
+ font-size: 0.8em;
517
+ padding: 0.1em 0.35em;
518
+ }
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ module PipelinesHelper
5
+ STATUS_BADGES = {
6
+ "pending" => "\u25CB Pending",
7
+ "running" => "\u25CF Running",
8
+ "enqueued" => "\u25CF Enqueued",
9
+ "succeeded" => "\u2713 Succeeded",
10
+ "failed" => "\u2717 Failed",
11
+ "halted" => "\u2298 Halted",
12
+ "skipped" => "\u2298 Skipped"
13
+ }.freeze
14
+
15
+ def status_badge(status)
16
+ label = STATUS_BADGES.fetch(status.to_s, status.to_s)
17
+ tag.span(label, class: "badge badge-#{status}")
18
+ end
19
+
20
+ def humanized_type(pipeline_type)
21
+ pipeline_type.to_s.underscore.titleize
22
+ end
23
+
24
+ def relative_time_tag(datetime)
25
+ return "" unless datetime
26
+
27
+ tag.time(relative_time(datetime),
28
+ datetime: datetime.iso8601,
29
+ title: datetime.strftime("%Y-%m-%d %H:%M:%S %Z"))
30
+ end
31
+
32
+ def pipeline_duration(pipeline)
33
+ return nil unless pipeline.terminal?
34
+
35
+ total = (pipeline.updated_at - pipeline.created_at).to_f
36
+ return "< 1s" if total < 1
37
+
38
+ minutes, seconds = total.to_i.divmod(60)
39
+ hours, minutes = minutes.divmod(60)
40
+ [("#{hours}h" if hours.positive?), ("#{minutes}m" if minutes.positive?), "#{seconds}s"].compact.join(" ")
41
+ end
42
+
43
+ def mermaid_definition_diagram(pipeline)
44
+ lines = ["graph TD"]
45
+ pipeline.steps.each do |step|
46
+ lines << " #{step.key}(\"#{step.key}\"):::step"
47
+ end
48
+ pipeline.dependencies.each do |dependency|
49
+ lines << " #{dependency.depends_on_step.key} --> #{dependency.step.key}"
50
+ end
51
+ lines << " classDef step fill:#4a90d9,color:#fff,stroke:#3a7bc8"
52
+ lines.join("\n")
53
+ end
54
+
55
+ def mermaid_diagram(pipeline)
56
+ lines = ["graph TD"]
57
+ pipeline.steps.each do |step|
58
+ lines << " #{step.key}(\"#{step.key}\"):::#{step.coordination_status}"
59
+ end
60
+ pipeline.dependencies.each do |dependency|
61
+ lines << " #{dependency.depends_on_step.key} --> #{dependency.step.key}"
62
+ end
63
+ lines.concat(mermaid_status_classes)
64
+ lines.join("\n")
65
+ end
66
+
67
+ def good_job_step_url(step)
68
+ return nil unless step.good_job_id
69
+
70
+ mount_path = good_job_mount_path
71
+ return nil unless mount_path
72
+
73
+ "#{mount_path}/jobs/#{step.good_job_id}"
74
+ end
75
+
76
+ def relative_time(datetime)
77
+ return "" unless datetime
78
+
79
+ distance = (Time.current - datetime).to_i
80
+ case distance
81
+ when 0..59 then "just now"
82
+ when 60..3599 then "#{distance / 60}m ago"
83
+ when 3600..86_399 then "#{distance / 3600}h ago"
84
+ else "#{distance / 86_400}d ago"
85
+ end
86
+ end
87
+
88
+ def truncated_params(pipeline_params)
89
+ return "" if pipeline_params.blank?
90
+
91
+ json = pipeline_params.to_json
92
+ json.length > 100 ? "#{json[0..97]}..." : json
93
+ end
94
+
95
+ private
96
+
97
+ def mermaid_status_classes
98
+ [
99
+ " classDef pending fill:#9e9e9e,color:#fff",
100
+ " classDef enqueued fill:#2196f3,color:#fff",
101
+ " classDef succeeded fill:#4caf50,color:#fff",
102
+ " classDef failed fill:#f44336,color:#fff",
103
+ " classDef skipped fill:#bdbdbd,color:#333"
104
+ ]
105
+ end
106
+
107
+ def good_job_mount_path
108
+ return nil unless defined?(GoodJob::Engine)
109
+
110
+ route = Rails.application.routes.routes.detect do |r|
111
+ r.app.respond_to?(:app) && r.app.app == GoodJob::Engine
112
+ end
113
+
114
+ return nil unless route
115
+
116
+ route.path.spec.to_s.delete_suffix("(.:format)")
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class PipelineCallbackJob < ActiveJob::Base
5
+ CALLBACK_STATUSES = PipelineRecord::TERMINAL_STATUSES
6
+
7
+ def perform(pipeline_id, terminal_status) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
8
+ unless CALLBACK_STATUSES.include?(terminal_status)
9
+ raise ArgumentError, "invalid terminal_status '#{terminal_status}'"
10
+ end
11
+
12
+ pipeline_record = GoodPipeline::PipelineRecord.find(pipeline_id)
13
+ pipeline = pipeline_record.type.constantize.for_callback(pipeline_record)
14
+
15
+ errors = []
16
+
17
+ invoke_callback(pipeline, pipeline.on_complete_callback, errors)
18
+
19
+ case terminal_status
20
+ when PipelineRecord.statuses[:succeeded]
21
+ invoke_callback(pipeline, pipeline.on_success_callback, errors)
22
+ when PipelineRecord.statuses[:failed], PipelineRecord.statuses[:halted]
23
+ invoke_callback(pipeline, pipeline.on_failure_callback, errors)
24
+ when PipelineRecord.statuses[:skipped]
25
+ # Skipped pipelines only trigger on_complete (already called above)
26
+ end
27
+
28
+ raise_callback_errors(errors) if errors.any?
29
+ end
30
+
31
+ private
32
+
33
+ def invoke_callback(pipeline, callback, errors)
34
+ return unless callback
35
+
36
+ pipeline.send(callback)
37
+ rescue StandardError => e
38
+ errors << e
39
+ end
40
+
41
+ def raise_callback_errors(errors)
42
+ return if errors.empty?
43
+
44
+ primary = errors.first
45
+ if errors.size > 1
46
+ suppressed = errors[1..].map { |error| "#{error.class}: #{error.message}" }.join("; ")
47
+ raise primary.class, "#{primary.message} (suppressed #{errors.size - 1} additional error(s): #{suppressed})"
48
+ end
49
+ raise primary
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class PipelineReconciliationJob < ActiveJob::Base
5
+ def perform(batch, _context)
6
+ pipeline = GoodPipeline::PipelineRecord.find(batch.properties[:pipeline_id])
7
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class StepFinishedJob < ActiveJob::Base
5
+ def perform(batch, _context)
6
+ step = GoodPipeline::StepRecord.find(batch.properties[:step_id])
7
+ GoodPipeline::Coordinator.complete_step(step, succeeded: batch.succeeded?)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class ChainRecord < ActiveRecord::Base
5
+ self.table_name = "good_pipeline_chains"
6
+ self.record_timestamps = false
7
+
8
+ belongs_to :upstream_pipeline,
9
+ class_name: "GoodPipeline::PipelineRecord",
10
+ foreign_key: :upstream_pipeline_id,
11
+ inverse_of: :downstream_chains
12
+
13
+ belongs_to :downstream_pipeline,
14
+ class_name: "GoodPipeline::PipelineRecord",
15
+ foreign_key: :downstream_pipeline_id,
16
+ inverse_of: :upstream_chains
17
+ end
18
+ end