ductwork 0.26.0 → 1.0.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-clock-drift/SKILL.md +11 -0
  3. data/.claude/skills/audit-database-indexes/SKILL.md +11 -0
  4. data/.claude/skills/audit-database-support/SKILL.md +18 -0
  5. data/.claude/skills/audit-durability/SKILL.md +17 -0
  6. data/.saturnci/database.yml +1 -0
  7. data/CHANGELOG-PRO.md +60 -0
  8. data/CHANGELOG.md +144 -1
  9. data/CLAUDE.md +34 -0
  10. data/README.md +24 -14
  11. data/app/controllers/ductwork/application_controller.rb +3 -3
  12. data/app/controllers/ductwork/dashboards_controller.rb +1 -1
  13. data/app/controllers/ductwork/pipelines_controller.rb +4 -3
  14. data/app/helpers/ductwork/application_helper.rb +2 -2
  15. data/app/views/ductwork/dashboards/show.html.erb +12 -12
  16. data/app/views/ductwork/pipelines/index.html.erb +12 -12
  17. data/app/views/ductwork/pipelines/show.html.erb +13 -13
  18. data/app/views/ductwork/step_errors/index.html.erb +2 -2
  19. data/lib/ductwork/branch_claim.rb +185 -0
  20. data/lib/ductwork/cli.rb +73 -24
  21. data/lib/ductwork/configuration.rb +73 -3
  22. data/lib/ductwork/context.rb +17 -19
  23. data/lib/ductwork/database_clock.rb +84 -0
  24. data/lib/ductwork/dsl/branch_builder.rb +6 -1
  25. data/lib/ductwork/dsl/definition_builder.rb +24 -1
  26. data/lib/ductwork/{job_claim.rb → execution_claim.rb} +7 -6
  27. data/lib/ductwork/fault_injection.rb +35 -0
  28. data/lib/ductwork/migration_helper.rb +1 -1
  29. data/lib/ductwork/models/advancement.rb +48 -0
  30. data/lib/ductwork/models/attempt.rb +9 -0
  31. data/lib/ductwork/models/availability.rb +2 -0
  32. data/lib/ductwork/models/branch.rb +800 -0
  33. data/lib/ductwork/models/branch_link.rb +10 -0
  34. data/lib/ductwork/models/execution.rb +199 -2
  35. data/lib/ductwork/models/job.rb +8 -121
  36. data/lib/ductwork/models/pipeline.rb +150 -272
  37. data/lib/ductwork/models/process.rb +177 -5
  38. data/lib/ductwork/models/result.rb +2 -1
  39. data/lib/ductwork/models/run.rb +119 -1
  40. data/lib/ductwork/models/step.rb +26 -6
  41. data/lib/ductwork/models/transition.rb +15 -0
  42. data/lib/ductwork/models/tuple.rb +1 -4
  43. data/lib/ductwork/models/workflow.rb +3 -0
  44. data/lib/ductwork/optimistic_locking_execution_claim.rb +97 -0
  45. data/lib/ductwork/processes/health_check.rb +87 -0
  46. data/lib/ductwork/processes/job_worker.rb +86 -20
  47. data/lib/ductwork/processes/job_worker_runner.rb +25 -37
  48. data/lib/ductwork/processes/pipeline_advancer.rb +48 -76
  49. data/lib/ductwork/processes/pipeline_advancer_runner.rb +34 -44
  50. data/lib/ductwork/processes/process_supervisor.rb +87 -10
  51. data/lib/ductwork/processes/thread_supervisor.rb +55 -21
  52. data/lib/ductwork/processes/thread_supervisor_runner.rb +8 -5
  53. data/lib/ductwork/processes/worker_health_check.rb +55 -0
  54. data/lib/ductwork/row_locking_execution_claim.rb +77 -0
  55. data/lib/ductwork/testing/rspec.rb +28 -11
  56. data/lib/ductwork/version.rb +1 -1
  57. data/lib/ductwork.rb +5 -0
  58. data/lib/generators/ductwork/install/install_generator.rb +14 -4
  59. data/lib/generators/ductwork/install/templates/config/ductwork.yml +4 -0
  60. data/lib/generators/ductwork/install/templates/db/create_ductwork_advancements.rb +29 -0
  61. data/lib/generators/ductwork/install/templates/db/create_ductwork_attempts.rb +20 -0
  62. data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +18 -5
  63. data/lib/generators/ductwork/install/templates/db/create_ductwork_branch_links.rb +27 -0
  64. data/lib/generators/ductwork/install/templates/db/create_ductwork_branches.rb +37 -0
  65. data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +8 -1
  66. data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +0 -8
  67. data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -0
  68. data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +1 -0
  69. data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +45 -6
  70. data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +20 -5
  71. data/lib/generators/ductwork/install/templates/db/create_ductwork_transitions.rb +43 -0
  72. data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +3 -3
  73. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_advancements.rb +7 -0
  74. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_executions.rb +14 -0
  75. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_results.rb +7 -0
  76. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_runs.rb +9 -0
  77. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_transitions.rb +16 -0
  78. data/lib/generators/ductwork/update/templates/db/add_pipeline_started_index_to_ductwork_runs.rb +7 -0
  79. data/lib/generators/ductwork/update/templates/db/add_process_id_to_ductwork_executions.rb +35 -0
  80. data/lib/generators/ductwork/update/templates/db/add_role_to_ductwork_processes.rb +20 -0
  81. data/lib/generators/ductwork/update/templates/db/associate_branches_to_runs.rb +74 -0
  82. data/lib/generators/ductwork/update/templates/db/associate_steps_to_branches.rb +24 -0
  83. data/lib/generators/ductwork/update/templates/db/associate_steps_to_runs.rb +72 -0
  84. data/lib/generators/ductwork/update/templates/db/associate_tuples_to_runs.rb +73 -0
  85. data/lib/generators/ductwork/update/templates/db/backfill_branch_ids_on_steps.rb +21 -0
  86. data/lib/generators/ductwork/update/templates/db/create_ductwork_advancements.rb +28 -0
  87. data/lib/generators/ductwork/update/templates/db/create_ductwork_branch_links.rb +27 -0
  88. data/lib/generators/ductwork/update/templates/db/create_ductwork_branches.rb +37 -0
  89. data/lib/generators/ductwork/update/templates/db/create_ductwork_runs.rb +55 -0
  90. data/lib/generators/ductwork/update/templates/db/create_ductwork_transitions.rb +32 -0
  91. data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +11 -4
  92. data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +5 -0
  93. data/lib/generators/ductwork/update/templates/db/rename_runs_to_attempts.rb +7 -0
  94. data/lib/generators/ductwork/update/templates/db/update_process_associations.rb +29 -0
  95. data/lib/generators/ductwork/update/update_generator.rb +99 -0
  96. metadata +47 -6
  97. data/lib/ductwork/optimistic_locking_job_claim.rb +0 -88
  98. data/lib/ductwork/row_locking_job_claim.rb +0 -75
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad0ce6f40af82ff2fcc08abb65083e32fdee666be43646a9f53f59033da85fa6
4
- data.tar.gz: 7ba3f5a80167323fff58ae89b82fbfa44d10e4d20d6f6046436906250f1d9715
3
+ metadata.gz: 7e6b0081c6aa307274ff6dd94cc9e1dc0c8840dd5e6c3fc1768d4b54adc511f6
4
+ data.tar.gz: e1e3f47b3c8ea24b70fc3a518b0cb61e484ea46bc1630b1c8fd911100f4b12e8
5
5
  SHA512:
6
- metadata.gz: 34b29a0aaacc4994fadd9542c2d38696c15b88caaa6660f36d0b2cdac82aca27b818e7173ef633e03d2deea0fd162a24db41a7090feab2ff8c68d1fc6b8bcec3
7
- data.tar.gz: 68bf238bdc9bdf4528cbbd5ba2554525d849d6008e5a7a6c5c0e35c96cf2896c04c2a8900ba29b69188b34f6ea1d30692dd25762b174c7b8bd7ee8afa146d9c1
6
+ metadata.gz: f8b8d8bd63a376180c162afb97f0feba1cd5e81dde9160501c441104fc6c00417185fcfa259a4a6fd2718de95d25d2069ea9dab2e037683fe69c2f9ff429e88a
7
+ data.tar.gz: a908516aeb3e7436f61e276e6b751e52e1b38c8f067eb0a4b55951957a7ef36e923599e2ba5484d8c46c058480c4d573b305d52b91d035822aa997a82123ce37
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: audit-clock-drift
3
+ description: Audit ductwork for time comparisons that are prone to clock drift
4
+ allowed-tools: Read, Grep, Glob
5
+ ---
6
+
7
+ # Audit Clock Drift
8
+
9
+ Audit the entire OSS ductwork codebase for places where we are open to clock drift issues. Specifically, if ductwork is running across multiple hosts, where are we prone to comparing a database timestamp with an in-memory OS clock read generated with Ruby. Only look for comparisons that gate safety or visibility. For example: heartbeat, enqueueing, claiming, possibly ordering.
10
+
11
+ For each finding: file:line, severity, why it matters, suggested fix. Do not modify files.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: audit-database-indexes
3
+ description: Audit ductwork for missing database indexes
4
+ allowed-tools: Read, Grep, Glob
5
+ ---
6
+
7
+ # Audit Missing Database Indexes
8
+
9
+ Audit the entire OSS ductwork codebase for queries that are missing a database index. All migrations live as templates under `lib/generators/ductwork/install/templates/db/**.rb`. Be sure to check all queries and determine if it is a hot path that needs an index. For example, reading next-to-be-claimed IDs, associations, etc. Ensure that suggestions work across at least PostgreSQL, MySQL, and SQLite.
10
+
11
+ For each finding: file:line, severity, why it matters, suggested fix. Do not modify files.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: audit-database-support
3
+ description: Audit ductwork for what database adapters and technologies are supported
4
+ allowed-tools: Read, Grep, Glob
5
+ ---
6
+
7
+ # Audit Clock Drift
8
+
9
+ Audit the entire OSS ductwork codebase for code and queries that do not support a certain database adapters or technology. Ensure support for:
10
+
11
+ * PostgreSQL
12
+ * CockroachDB
13
+ * MySQL 8+ (adapter and technology)
14
+ * Trilogy (adapter)
15
+ * SQLite
16
+ * Oracle
17
+
18
+ For each finding: file:line, severity, why it matters, suggested fix. Do not modify files.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: audit-durability
3
+ description: Audit ductwork for durability gaps
4
+ allowed-tools: Read, Grep, Glob
5
+ ---
6
+
7
+ # Durability Audit Gap
8
+
9
+ Audit the entire OSS ductwork codebase for durability gaps.
10
+
11
+ Check for:
12
+ 1. **Stuck pipelines**: claims without transition records, advancements without completion, missing reaper coverage
13
+ 2. **Lost data**: writes after observable side effects, missing "write before you act" ordering, places where partial failure is not handled
14
+ 3. **Double execution**: missing fencing on claim token or process ID, missing idempotency on transitions, gaps in two-phase commit
15
+ 4. **Reaper clobbering**: heartbeat updates racing reaper swwps, stale claim token assumptions, missing recoery count increments
16
+
17
+ For each finding: file:line, severity, why it matters, suggested fix. Do not modify files.
@@ -3,3 +3,4 @@ test:
3
3
  database: db/test.sqlite3
4
4
  pool: 5
5
5
  timeout: 5000
6
+ default_transaction_mode: immediate
data/CHANGELOG-PRO.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Ductwork Pro Changelog
2
2
 
3
+ ## [1.0.0] (Unreleased)
4
+
5
+ - chore: wire payloads to jobs without instantiating whole model objects
6
+ - fix: avoid race condition of `nil`-ing out `execution` on job worker
7
+ - fix: no longer strand `combine`/`collapse` branches in `advancing` when a run-row deadlock rolls back a transition that had already completed the branch (via the shared OSS claim-fence fix); previously the run stalled until the advancer process was reaped
8
+ - fix: lock the run `FOR NO KEY UPDATE` on Postgres in `resolve_terminal_state!` to avoid the run-row deadlock between concurrent `combine`/`collapse` transitions
9
+ - fix: resolve the `collapse` fan-in barrier via `barrier_node` so intermediate `divide`/`combine`/`chain` transitions and nested expands no longer create duplicate collapse targets
10
+ - feat: record the matching `expand` node as `barrier_node` on `collapse` edges in the pipeline definition
11
+ - fix: lower payload enveloped value limit to ~1GB
12
+ - fix: print banner on boot
13
+ - fix: avoid per-batch sort when wiring large collapse fan-ins
14
+ - fix: remove unnecessary ordering so existing index is hit
15
+ - fix: add index to support a keyset `ORDER BY` query for payloads
16
+ - feat: stream large collapse fan-ins via lazy input payloads
17
+ - feat: stream large expand fan-outs via lazy output payloads
18
+ - fix: make changes to reach parity with OSS
19
+ - fix: use existing count attribute on branch instead of `COUNT` query
20
+ - fix: create composite index for the collapse fan-in read
21
+ - chore: do not instantiate full payload activerecord models
22
+ - fix: insert payload records in batches of 1_000
23
+ - fix: harden the kill-and-restart path against thread hangs
24
+ - chore: move configurable pipeline advancer thread pool to the OSS gem
25
+ - fix: replace unnecessary lock with atomic, conditional increment
26
+ - chore: add advancement integration durability tests
27
+ - feat: make `collapse` interruptible, resumeable, and recoverable
28
+ - feat: track `collapse` fan-in with counters on `ductwork_branches`
29
+ - perf: collapse fan-in via atomic counter instead of scanning siblings
30
+ - feat: read `ductwork_payloads` records when executing jobs
31
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `collapse`
32
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `expand`
33
+ - fix: associate `ductwork_payloads` with `ductwork_executions` for origination
34
+ - feat: set `ductwork_payloads.to_job_id` when resuming a `dampen`-ed pipeline run
35
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `converge`
36
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `divide`
37
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `divert`
38
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `combine`
39
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `chain`
40
+ - fix: add `position` column to `ductwork_payloads` table
41
+ - feat: store step output payloads in `ductwork_payloads` records
42
+ - feat: support all OSS v1.0 changes
43
+ - fix: release branch in `Ductwork::Pro::Run#resume!`
44
+ - fix: set status for pipeline, run, and branch when resuming
45
+ - feat: add back dampening during advancement
46
+ - feat: respect delay in definition when advancing branches
47
+ - fix: properly release branch when execution times out
48
+ - fix: call methods on `branch` in pipeline advancer
49
+ - fix: use correct associations in `JobWorker#timed_out?`
50
+ - fix: do not advance pipeline on job timeouts
51
+ - feat: move dampening and resuming to `Run` with a top-level `Pipeline#resume!`
52
+ - fix: properly report metrics when pipeline run completes or halts
53
+ - fix: create `runs` and `branches` records when triggering pipeline
54
+ - chore: change `dampers` association to `runs` instead of `pipelines`
55
+ - chore: regenerate spec migrations to pick up `ductwork` v1.0.0 changes
56
+
57
+ ## [0.8.0]
58
+
59
+ - feat: support delay and timeout arguments for `divert` and `converge` transitions - this is the last of adding support for the new transitions
60
+ - fix: wrap code with rails app executor
61
+ - feat: support `divert` and `converge` transitions in pipeline advancement
62
+
3
63
  ## [0.7.0]
4
64
 
5
65
  - feat: allow for passing an argument when resuming a pipeline - this will replace passing the previous step's output payload as the input arguments to the next step
data/CHANGELOG.md CHANGED
@@ -1,5 +1,148 @@
1
1
  # Ductwork Changelog
2
2
 
3
+ ## [1.0.0]
4
+
5
+ - chore: add `trilogy` database adapter to the CI test matrix
6
+ - chore: read workflow/pipelines definitions from `app/workflows` directory
7
+ - fix: rescue transient errors in supervisor/runner work loops so one bad iteration doesn't skip graceful shutdown or kill the process
8
+ - chore: rescue invalid foreign key error as to not let thread die
9
+ - fix: reap claims that lost their process ID on a process destroy racing a fresh claim
10
+ - fix: always recover incomplete claims when reusing a PID + machine identifier
11
+ - fix: recover an advancement whose transition completed the branch and then rolled back (e.g. a run-row deadlock victim) instead of stranding the branch in `advancing` — the claim fence and `release!` now use the token captured when advancement starts rather than the in-memory attribute that `complete!`/`halt!` null mid-transition
12
+ - fix: lock the run `FOR NO KEY UPDATE` on Postgres when resolving terminal state so it does not deadlock upgrading past the `FOR KEY SHARE` locks concurrent transitions hold from inserting run-referencing rows
13
+ - fix: fan-in `collapse` correctly when siblings are not direct children of the expanding branch (intermediate `divide`/`combine`/`chain` or nested expands no longer create duplicate collapse targets)
14
+ - feat: record the matching `expand` node as `barrier_node` on `collapse` edges in the pipeline definition
15
+ - fix: use database clock instead of app-server time for execution, job, and process timestamps
16
+ - feat: add `Ductwork::DatabaseClock.now` to read current time from the database
17
+ - fix: add missing indexes for runs and transitions
18
+ - fix: use correct index for claiming for mysql databases
19
+ - chore: add optional force argument on process reap
20
+ - chore: do not raise `StaleClaimError` in `Branch#with_claim_fence`
21
+ - fix: add missing claim fence around branch/advancement mutation queries
22
+ - fix: add a branch claiming guard to skip when no live process record exists
23
+ - fix: cleanup crashed thread and release branch atomically
24
+ - feat: cap pipeline advancer crashes and halt with `advancer_crashes_exhausted`
25
+ - feat: create configuration for pipeline advancer max crash count
26
+ - chore: conditionally update step, run, and pipeline status when claiming
27
+ - fix: fire `on_halt` once and outside of all database transactions
28
+ - fix: add missing database indexes (mostly) for dashboard pages
29
+ - fix: only `sleep` pipeline advancer when no branch is claimed
30
+ - feat: add configurable pipeline advancer thread pool via `pipeline_advancer.count`
31
+ - fix: do not let one wedged worker freeze the whole supervisor work loop
32
+ - fix: ignore already-exited child processes during graceful shutdown
33
+ - chore: set `job_crashes_exhausted` as pipeline halt reason when exhausted crash budget
34
+ - feat: cap and tail back-off execution crashes
35
+ - feat: create configuration for job worker max crash count
36
+ - feat: add `Ductwork::Workflow` alias of `Ductwork::Pipeline`
37
+ - fix: skip "internal" errors when calculating advancement retry budget
38
+ - chore: bump advancement retry count to 10
39
+ - feat: automatically restart threads that are stuck in framework code
40
+ - fix: add secondary order by UUID v7 to get latest step on branch
41
+ - feat: make supervisor reaper timeout configurable
42
+ - chore: add composite index for branch claim step subquery
43
+ - chore: add composite index for `latest_step` lookup
44
+ - chore: add index on processes for last_heartbeat_at timestamp
45
+ - fix: use partial index for resolving run's terminal state for performance
46
+ - fix: use partial index in branch claim query for performance
47
+ - fix: use original claim token when cleaning up dead pipeline advancer threads
48
+ - fix: do not call `on_halt` lifecycle event if transaction rolls back
49
+ - fix: cleanup claimed resources on worker/advancer restart
50
+ - fix: crash execution if thread crashes without process crashing
51
+ - fix: abandon advancements if thread crashes without process crashing
52
+ - fix: prevent OS-level PID reuse from adopting stale `Ductwork::Process` record
53
+ - fix: fence `Execution#crashed!` with process ID so a reclaimed execution isn't clobbered by the reaper
54
+ - fix: check claim tokens before advancing branch
55
+ - feat: create in-scope `idempotency_key` attribute for the `Step` model
56
+ - fix: raise `Execution::CommitFailed` from `#succeeded!`/`#errored!` instead of silently dropping the result when the reaper has clobbered an in-flight execution
57
+ - fix: do not re-query current process ID in execution claiming
58
+ - fix: use database clock in process supervisor to avoid cross-host clock drift
59
+ - feat: add CLI health check command that reports supervisor processes health
60
+ - fix: protect against cross-host clock skew with database time comparisons
61
+ - chore: rescue heartbeat errors to prevent killing process
62
+ - fix: duplicate `Ductwork::BranchLink` records when reviving a pipeline
63
+ - fix: prevent possible double execution in `Pipeline#revive!` by only retrying advancing if it was the halt reason, otherwise retry the job
64
+ - fix: pass failed job input arguments to new job when reviving a pipeline
65
+ - fix: pass in owner process id when executing job - this ensures the process finishing the job is the one that claimed it
66
+ - fix: make `Execution#succeeded!` and `#errored!` idempotent and fence with process id
67
+ - feat: set `ductwork_execution.process_id` when claiming availability
68
+ - chore: associate `ductwork_executions` to `ductwork_processes`
69
+ - chore: hoist usages of app executor out of models and services
70
+ - chore: refactor `Job` methods and claiming onto `Execution`
71
+ - fix: prevent possible double job execution by using "current" `execution` record
72
+ - chore: swap locking for atomic update in `Ductwork::Availability#abandon!`
73
+ - chore: swap locking for atomic update in `Ductwork::Advancement#abandon!`
74
+ - fix: make `Ductwork::Job#execution_crashed!` idempotent to prevent race condition with the reaper
75
+ - fix: prevent possible double job execution by only wrapping user code in rescue
76
+ - fix: atomically release branches conditioned on claim token
77
+ - fix: fail abandoned availabilities and executions when reaping jobs
78
+ - fix: fail abandoned advancements when reaping branches
79
+ - fix: prevent double execution by explicitly killing process before replacing it if it failed health check
80
+ - fix: close durability gap by rescuing branch halting
81
+ - fix: close durability gap by releasing branch in advancement rescue
82
+ - fix: permit `id` parameter when paginating
83
+ - chore: set new execution time to be "now" when job worker crashes (ie. don't set a retry back-off as with job erroring)
84
+ - feat: add a crash count to executions and increment when job worker crashes
85
+ - chore: setup and add first durability integration test
86
+ - chore: nullify claim token when releasing, completing, or halting branch
87
+ - feat: prevent branch claim/release race condition by comparing tokens
88
+ - chore: generate and set claim token on branch record during claiming
89
+ - chore: add `ductwork_branches.claim_token` string column
90
+ - chore: set `@branch` ivar after branch is claimed
91
+ - fix: only use `unique_by` options for non-MySQL db adapters
92
+ - feat: add mysql and postgresql databases to CI test matrix
93
+ - fix: don't use partial indexes for MySQL
94
+ - fix: adopt or create process record when reporting heartbeat
95
+ - fix: fan-in advancement checks branch status instead of step status
96
+ - fix: add back in calling `on_halt` DSL method
97
+ - feat: set `halt_reason` when halting branch
98
+ - chore: add `halt_reason` column to `ductwork_branches` table
99
+ - fix: update proper records and state for advancer retry
100
+ - feat: respect pipeline advancer max retry configuration
101
+ - feat: add pipeline advancer max retry configuration
102
+ - feat: properly set pipeline/run terminal states
103
+ - fix: check for terminal status before setting status on pipeline
104
+ - chore: do not halt pipeline from job worker
105
+ - feat: update branch claim query
106
+ - feat: implement `Ductwork::Pipeline#revive!`
107
+ - chore: add `source_step_id` column on `ductwork_steps`
108
+ - fix: protect against null error backtraces for job results
109
+ - fix: update dashboard pages to handle `ductwork_runs`
110
+ - fix: create `ductwork_runs` records opaquely in rspec test helpers
111
+ - feat: associate `branches`, `steps`, and `tuples` to `runs` instead of `pipelines`
112
+ - feat: add `ductwork_runs` table and model to represent pipeline runs
113
+ - chore: rename `ductwork_runs` to `ductwork_attempts`
114
+ - chore: log when pipeline advancement errors
115
+ - fix: move branch release into main advance transaction
116
+ - fix: set `last_advanced_at` timestamp when releasing branch
117
+ - chore: derive process dead threshold from process reap threshold
118
+ - fix: move transition and advancement creation into claim transaction
119
+ - fix: guard against PID reuse with guard statement
120
+ - fix: use safe navigation operator on process records
121
+ - fix: rescue all job worker errors as to not let thread die
122
+ - fix: reap process (and claims) on process restart or immediate shutdown
123
+ - chore: bump ruby versions in CI
124
+ - chore: bump rails versions in appraisals file
125
+ - chore: DRY up process record destruction
126
+ - fix: release job availabilities during process reaping
127
+ - fix: use proper association to `ductwork_processes` in `ductwork_availabilities`
128
+ - fix: release branches during process reaping
129
+ - feat: add process reaper check to supervisors' run loop
130
+ - chore: DRY up process record adoption and creation
131
+ - fix: create or adopt process records in pipeline advancer and job worker
132
+ - fix: create top-level process record for thread supervisor
133
+ - fix: use safe navigation in rescue when advancing branch
134
+ - fix: use correct column name in migration
135
+ - feat!: advance branches instead of pipelines - BREAKING CHANGE: this completely changes how pipelines are advanced. to migrate let all your current pipelines complete then deploy
136
+ - fix: delete child process records when restarting within supervisor
137
+ - fix: wrap optimistic job claiming in a single transaction
138
+ - chore: bump project and CI ruby versions to v4.0.2
139
+ - fix: add missing index when migrating to UUIDs
140
+ - fix: job availability claim and update state in a single transaction
141
+ - feat: complete and halt active branches for pipelines
142
+ - feat: create initial branch when a pipeline is triggered
143
+ - feat: introduce `Branch` and `BranchJunction` models and tables
144
+ - fix: correctly log transition names via edge
145
+
3
146
  ## [0.26.0]
4
147
 
5
148
  - feat: add `divert` and complementary `converge` transitions - this is essentially a conditional/case statement transition for pipelines including it's "fan-in" method
@@ -256,4 +399,4 @@
256
399
 
257
400
  ## [0.1.0]
258
401
 
259
- - Initial release - see [documentation](https://docs.getductwork.io/) for details
402
+ - Initial release - see [documentation](https://www.getductwork.io/docs/) for details
data/CLAUDE.md ADDED
@@ -0,0 +1,34 @@
1
+ # Ductwork OSS Architecture Context
2
+
3
+ This is the OSS `ductwork` gem (LGPL v3). The paid `ductwork-pro` gem
4
+ extends it via `prepend` and adds features that MUST NOT be reimplemented
5
+ or referenced here.
6
+
7
+ ## Lives in OSS (this repo)
8
+ - Core workflow transitions: `chain`, `expand`, `divide`, `divert`, `combine`, `converge`, and `collapse`
9
+ - Core pipeline DSL
10
+ - Two-phase commit (transition + advancement records) for pipeline advancement
11
+ - Supervisor / advancer / worker process hierarchy
12
+ - Forking + threaded concurrency modes
13
+ - Configurable pipeline advancer thread pool
14
+ - Heartbeat-based orphan detection
15
+ - SKIP LOCKED claiming with atomic UPDATE...WHERE fallback
16
+ - Reaper with global-timeout sweeps
17
+ - Automatic restart of worker threads stuck in framework code (no execution claimed)
18
+ - `Ductwork::Pipeline#revive!` API
19
+ - UUID v7 primary keys across PG/MySQL/SQLite
20
+ - Rails engine-mountable web dashboard
21
+
22
+ ## Lives in Pro
23
+ - Human-in-the-loop functionality with `dampen` transition
24
+ - Step timeout feature defined in pipeline definition DSL
25
+ - Step delay feature defined in pipeline definition DSL
26
+ - Automatic restart of worker threads stuck inside job execution (claimed execution that won't return; via step timeout)
27
+ - Large payload support
28
+ - Resumable batched fan-out/fan-in
29
+ - Interruptible pipeline advancement
30
+ - Metric reporting to StatsD
31
+
32
+ ## Hard rules
33
+ - Never reference `Ductwork::Pro::*` constants from OSS code.
34
+ - Pro extends OSS via `prepend`; OSS must remain functional standalone.
data/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  [![CI](https://github.com/ductwork/ductwork/actions/workflows/main.yml/badge.svg)](https://github.com/ductwork/ductwork/actions/workflows/main.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/ductwork.svg?icon=si%3Arubygems)](https://rubygems.org/gems/ductwork)
5
5
 
6
- A Ruby pipeline and workflow framework.
6
+ A durable workflow orchestration framework for Ruby.
7
7
 
8
- Ductwork lets you build complex pipelines quickly and easily using intuitive Ruby tooling and a natural DSL. No need to learn complicated unified object models or stand up separate runner instancesjust write Ruby code and let Ductwork handle the orchestration.
8
+ Ductwork lets you build durable pipelines and workflows quickly and easily using intuitive Ruby tooling and a natural DSL. No need to learn complicated unified object models or stand up separate runner instances, just write Ruby code and let Ductwork handle the orchestration.
9
9
 
10
10
  There is also a paid [Ductwork Pro](https://www.getductwork.io/) version with more features and support. See the [Pricing](https://www.getductwork.io/#pricing) page to buy a license.
11
11
 
12
- **[Full Documentation](https://docs.getductwork.io/)**
12
+ **[Full Documentation](https://www.getductwork.io/docs/)**
13
13
 
14
14
  ## Installation
15
15
 
@@ -31,34 +31,34 @@ bin/rails generate ductwork:install
31
31
  bin/rails generate ductwork:update
32
32
  ```
33
33
 
34
- Run migrations and you're ready to start building pipelines!
34
+ Run migrations and you're ready to start building workflows!
35
35
 
36
- ## Configuration
37
36
 
37
+ ## Configuration
38
38
 
39
- The only required configuration is specifying which pipelines to run. Edit the default configuration file `config/ductwork.yml`:
39
+ The only required configuration is specifying which workflows and pipelines to run. Edit the default configuration file `config/ductwork.yml`:
40
40
 
41
41
  ```yaml
42
42
  default: &default
43
43
  pipelines:
44
44
  - EnrichUserDataPipeline
45
- - SendMonthlyStatusReportsPipeline
45
+ - SendMonthlyStatusReportsWorkflow
46
46
  ```
47
47
 
48
- Or use the wildcard to run all pipelines (use cautiouslythis can consume significant resources):
48
+ Or use the wildcard to run all pipelines (use cautiously as this can consume significant resources):
49
49
 
50
50
  ```yaml
51
51
  default: &default
52
52
  pipelines: "*"
53
53
  ```
54
54
 
55
- See the [Configuration Guide](https://docs.getductwork.io/getting-started/configuration.html) for all available options including thread counts, timeouts, and database settings.
55
+ See the [Configuration Guide](https://www.getductwork.io/docs/getting-started/configuration/) for all available options including thread counts, timeouts, and database settings.
56
56
 
57
57
  ## Usage
58
58
 
59
- ### 1. Create a Pipeline Class
59
+ ### 1. Create a Workflow Class
60
60
 
61
- Pipeline classes live in `app/pipelines` and inherit from `Ductwork::Pipeline`. While the "Pipeline" suffix is optional, it can help avoid naming collisions:
61
+ Your workflow and pipeline classes live in `app/pipelines` or `app/workflows` and inherit from `Ductwork::Pipeline` or `Ductwork::Workflow` — `Ductwork::Workflow` is an alias for `Ductwork::Pipeline`, so pick whichever name reads best for the process you're modeling. While the "Pipeline" or "Workflow" suffix is optional, it can help avoid naming collisions:
62
62
 
63
63
  ```ruby
64
64
  # app/pipelines/enrich_user_data_pipeline.rb
@@ -111,7 +111,7 @@ end
111
111
 
112
112
  **Important:** Return values must be JSON-serializable.
113
113
 
114
- See [Defining Pipelines](https://docs.getductwork.io/getting-started/defining-pipelines.html) for detailed documentation.
114
+ See [Defining Pipelines](https://www.getductwork.io/docs/getting-started/defining-pipelines/) for detailed documentation.
115
115
 
116
116
  ### 4. Run Ductwork
117
117
 
@@ -129,7 +129,7 @@ bin/ductwork -c config/ductwork.0.yml
129
129
 
130
130
  ### 5. Trigger Your Pipeline
131
131
 
132
- Trigger pipelines from anywhere in your Rails application. The `trigger` method returns a `Ductwork::Pipeline` instance for monitoring:
132
+ Trigger workflows from anywhere in your Rails application. The `trigger` method returns a `Ductwork::Pipeline` instance for monitoring:
133
133
 
134
134
  ```ruby
135
135
  # In a Rake task
@@ -142,10 +142,20 @@ end
142
142
  def create
143
143
  pipeline = EnrichUserDataPipeline.trigger(params[:days_outdated])
144
144
 
145
- render json: { pipeline_id: pipeline.id, status: pipeline.status }
145
+ render json: { id: pipeline.id, status: pipeline.status }
146
146
  end
147
147
  ```
148
148
 
149
+ ## Delivery Guarantees
150
+
151
+ Ductwork guarantees **at-least-once**, never exactly-once, execution of each step.
152
+
153
+ If a worker process is killed (`kill -9`, OOM, host failure, deploy) mid-job, Ductwork can't know whether the step's side effects already ran. Rather than risk silently dropping work, it favors re-running it: a reaper detects the orphaned claim via missed heartbeats and, after a timeout, makes the job eligible to be claimed and executed again, potentially re-running side effects that already completed.
154
+
155
+ **Write step side effects to be idempotent.** Prefer upserts over inserts, guard non-idempotent external calls (charges, emails, webhooks) with your own dedupe key, etc. Every `Ductwork::Step` exposes `idempotency_key` (a stable ID for that step's execution) for exactly this purpose. Keep steps as small as possible and limit each one to as few side effects as you can; the smaller the blast radius of a re-run, the easier it is to make idempotent.
156
+
157
+ Pipeline advancement (moving a branch from one step to the next) is tracked separately via its own claim/commit records, so a crash between "step finished" and "pipeline advanced" is handled the same way: the stalled advancement is reaped and retried rather than left stuck.
158
+
149
159
  ## Development
150
160
 
151
161
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -4,8 +4,8 @@ module Ductwork
4
4
  class ApplicationController < ActionController::Base
5
5
  DEFAULT_PER_PAGE = 50
6
6
 
7
- def query_pipelines
8
- Ductwork::Pipeline
7
+ def query_pipeline_runs
8
+ Ductwork::Run
9
9
  .includes(steps: { job: { executions: :result } })
10
10
  .then(&method(:filter_by_klass))
11
11
  .then(&method(:filter_by_status))
@@ -15,7 +15,7 @@ module Ductwork
15
15
 
16
16
  def filter_by_klass(relation)
17
17
  if params[:klass].present?
18
- relation.where(klass: params[:klass])
18
+ relation.where(pipeline_klass: params[:klass])
19
19
  else
20
20
  relation
21
21
  end
@@ -13,7 +13,7 @@ module Ductwork
13
13
  @klasses = Ductwork::Pipeline.group(:klass).pluck(:klass).sort
14
14
  @statuses = Ductwork::Pipeline.statuses.keys
15
15
  @per_page = 25
16
- @pipelines = query_pipelines
16
+ @runs = query_pipeline_runs
17
17
  end
18
18
  end
19
19
  end
@@ -3,23 +3,24 @@
3
3
  module Ductwork
4
4
  class PipelinesController < Ductwork::ApplicationController
5
5
  def index
6
- @pipelines = query_pipelines
6
+ @runs = query_pipeline_runs
7
7
  @klasses = Ductwork::Pipeline.group(:klass).pluck(:klass).sort
8
8
  @statuses = Ductwork::Pipeline.statuses.keys
9
9
  end
10
10
 
11
11
  def show
12
12
  @pipeline = Ductwork::Pipeline.find(params[:id])
13
+ @last_run = @pipeline.runs.order(started_at: :desc).first
13
14
  @per_page = 10
14
15
  @steps = query_steps
15
- @klasses = @pipeline.steps.group(:klass).pluck(:klass).sort
16
+ @klasses = @last_run.steps.group(:klass).pluck(:klass).sort
16
17
  @statuses = Ductwork::Step.statuses.keys
17
18
  end
18
19
 
19
20
  private
20
21
 
21
22
  def query_steps
22
- @pipeline
23
+ @last_run
23
24
  .steps
24
25
  .then(&method(:filter_by_klass))
25
26
  .then(&method(:filter_by_status))
@@ -21,7 +21,7 @@ module Ductwork
21
21
  def next_page_path
22
22
  next_page = params[:page].to_i + 1
23
23
  next_params = params
24
- .permit(:controller, :action, :klass, :status, :page)
24
+ .permit(:controller, :action, :id, :klass, :status, :page)
25
25
  .merge(page: next_page)
26
26
 
27
27
  url_for(**next_params)
@@ -30,7 +30,7 @@ module Ductwork
30
30
  def previous_page_path
31
31
  previous_page = params[:page].to_i - 1
32
32
  previous_params = params
33
- .permit(:controller, :action, :klass, :status, :page)
33
+ .permit(:controller, :action, :id, :klass, :status, :page)
34
34
  .merge(page: previous_page)
35
35
 
36
36
  url_for(**previous_params)
@@ -80,29 +80,29 @@
80
80
  </tr>
81
81
  </thead>
82
82
  <tbody>
83
- <% @pipelines.each do |pipeline| %>
84
- <tr class="row-link" data-href="<%= pipeline_path(pipeline.id) %>">
83
+ <% @runs.each do |run| %>
84
+ <tr class="row-link" data-href="<%= pipeline_path(run.pipeline_id) %>">
85
85
  <td>
86
86
  <code>
87
- <%= pipeline.id %>
87
+ <%= run.pipeline_id %>
88
88
  </code>
89
89
  </td>
90
90
  <td>
91
91
  <code>
92
- <%= pipeline.klass %>
92
+ <%= run.pipeline_klass %>
93
93
  </code>
94
94
  </td>
95
95
  <td>
96
- <div class="status-pill <%= pipeline.status %>">
97
- <%= pipeline.status.gsub("_", "-") %>
96
+ <div class="status-pill <%= run.status %>">
97
+ <%= run.status.gsub("_", "-") %>
98
98
  </div>
99
99
  </td>
100
100
  <td>
101
101
  <%
102
102
  count = Ductwork::Result
103
- .joins(execution: { job: { step: :pipeline }})
103
+ .joins(execution: { job: { step: { run: :pipeline }}})
104
104
  .failure
105
- .where(ductwork_pipelines: { id: pipeline.id })
105
+ .where(ductwork_pipelines: { id: run.pipeline_id })
106
106
  .count
107
107
  %>
108
108
  <% if count.zero? %>
@@ -116,16 +116,16 @@
116
116
  </td>
117
117
  <td>
118
118
  <div class="tight-timestamp">
119
- <%= pipeline.started_at.iso8601(3) %>
119
+ <%= run.started_at.iso8601(3) %>
120
120
  </div>
121
121
  </td>
122
122
  <td>
123
- <% if pipeline.completed_at.nil? %>
124
- <div data-started-at=<%= pipeline.started_at.iso8601 %>>
123
+ <% if run.completed_at.nil? %>
124
+ <div data-started-at=<%= run.started_at.iso8601 %>>
125
125
  <span id="elapsed-timer">0h 0m 0s</span>
126
126
  </div>
127
127
  <% else %>
128
- <%= formatted_time_distance(pipeline.started_at, pipeline.completed_at) %>
128
+ <%= formatted_time_distance(run.started_at, run.completed_at) %>
129
129
  <% end %>
130
130
  </td>
131
131
  </tr>
@@ -54,25 +54,25 @@
54
54
  </tr>
55
55
  </thead>
56
56
  <tbody>
57
- <% @pipelines.each do |pipeline| %>
58
- <tr class="row-link" data-href="<%= pipeline_path(pipeline.id) %>">
57
+ <% @runs.each do |run| %>
58
+ <tr class="row-link" data-href="<%= pipeline_path(run.pipeline_id) %>">
59
59
  <td>
60
- <code><%= pipeline.id %></code>
60
+ <code><%= run.pipeline_id %></code>
61
61
  </td>
62
62
  <td>
63
- <code><%= pipeline.klass %></code>
63
+ <code><%= run.pipeline_klass %></code>
64
64
  </td>
65
65
  <td>
66
- <div class="status-pill <%= pipeline.status %>">
67
- <%= pipeline.status.gsub("_", "-") %>
66
+ <div class="status-pill <%= run.status %>">
67
+ <%= run.status.gsub("_", "-") %>
68
68
  </div>
69
69
  </td>
70
70
  <td>
71
71
  <%
72
72
  count = Ductwork::Result
73
- .joins(execution: { job: { step: :pipeline }})
73
+ .joins(execution: { job: { step: { run: :pipeline }}})
74
74
  .failure
75
- .where(ductwork_pipelines: { id: pipeline.id })
75
+ .where(ductwork_pipelines: { id: run.pipeline_id })
76
76
  .count
77
77
  %>
78
78
  <% if count.zero? %>
@@ -86,16 +86,16 @@
86
86
  </td>
87
87
  <td>
88
88
  <div class="tight-timestamp">
89
- <%= pipeline.started_at.iso8601(3) %>
89
+ <%= run.started_at.iso8601(3) %>
90
90
  </div>
91
91
  </td>
92
92
  <td>
93
- <% if pipeline.completed_at.nil? %>
94
- <div data-started-at=<%= pipeline.started_at.iso8601 %>>
93
+ <% if run.completed_at.nil? %>
94
+ <div data-started-at=<%= run.started_at.iso8601 %>>
95
95
  <span id="elapsed-timer">0h 0m 0s</span>
96
96
  </div>
97
97
  <% else %>
98
- <%= formatted_time_distance(pipeline.started_at, pipeline.completed_at) %>
98
+ <%= formatted_time_distance(run.started_at, run.completed_at) %>
99
99
  <% end %>
100
100
  </td>
101
101
  </tr>