ductwork 0.26.0 → 1.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 (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 +63 -0
  8. data/CHANGELOG.md +149 -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 +82 -35
  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 +50 -9
  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: 95a205cfc0a928333a96512317753b11a461512ae58e38a4320969108d1b7431
4
+ data.tar.gz: 96a4ebef590088f0b44c71345b190934493ef44ec137be136804ecf8cad7c6d3
5
5
  SHA512:
6
- metadata.gz: 34b29a0aaacc4994fadd9542c2d38696c15b88caaa6660f36d0b2cdac82aca27b818e7173ef633e03d2deea0fd162a24db41a7090feab2ff8c68d1fc6b8bcec3
7
- data.tar.gz: 68bf238bdc9bdf4528cbbd5ba2554525d849d6008e5a7a6c5c0e35c96cf2896c04c2a8900ba29b69188b34f6ea1d30692dd25762b174c7b8bd7ee8afa146d9c1
6
+ metadata.gz: 5785b7e08bfd89bb7f94303babc18fc8b528f26e8b16cbe0bb338b072faee51057f89fdc8d4bcc9f551bd83a81766a9d07045cb246e2cdd5c1bb8ab8e0fb6094
7
+ data.tar.gz: f1887e429b87d44c3e90dddd2acb5106c4e39b100689b95f1570a8c240e3e7ea08555b0efb3fce1341a9f72c66e961196e5468999390f034a1147ae0e5f1cbf7
@@ -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,68 @@
1
1
  # Ductwork Pro Changelog
2
2
 
3
+ ## [1.0.0] (Unreleased)
4
+
5
+ - chore: add multiple database adapters to CI test matrix
6
+ - fix: inspect `in_batches` parameters for passing `cursor` option
7
+ - fix: only use cursor options for ActiveRecord versions that support it
8
+ - chore: wire payloads to jobs without instantiating whole model objects
9
+ - fix: avoid race condition of `nil`-ing out `execution` on job worker
10
+ - 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
11
+ - 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
12
+ - 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
13
+ - feat: record the matching `expand` node as `barrier_node` on `collapse` edges in the pipeline definition
14
+ - fix: lower payload enveloped value limit to ~1GB
15
+ - fix: print banner on boot
16
+ - fix: avoid per-batch sort when wiring large collapse fan-ins
17
+ - fix: remove unnecessary ordering so existing index is hit
18
+ - fix: add index to support a keyset `ORDER BY` query for payloads
19
+ - feat: stream large collapse fan-ins via lazy input payloads
20
+ - feat: stream large expand fan-outs via lazy output payloads
21
+ - fix: make changes to reach parity with OSS
22
+ - fix: use existing count attribute on branch instead of `COUNT` query
23
+ - fix: create composite index for the collapse fan-in read
24
+ - chore: do not instantiate full payload activerecord models
25
+ - fix: insert payload records in batches of 1_000
26
+ - fix: harden the kill-and-restart path against thread hangs
27
+ - chore: move configurable pipeline advancer thread pool to the OSS gem
28
+ - fix: replace unnecessary lock with atomic, conditional increment
29
+ - chore: add advancement integration durability tests
30
+ - feat: make `collapse` interruptible, resumeable, and recoverable
31
+ - feat: track `collapse` fan-in with counters on `ductwork_branches`
32
+ - perf: collapse fan-in via atomic counter instead of scanning siblings
33
+ - feat: read `ductwork_payloads` records when executing jobs
34
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `collapse`
35
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `expand`
36
+ - fix: associate `ductwork_payloads` with `ductwork_executions` for origination
37
+ - feat: set `ductwork_payloads.to_job_id` when resuming a `dampen`-ed pipeline run
38
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `converge`
39
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `divide`
40
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `divert`
41
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `combine`
42
+ - feat: set `ductwork_payloads.to_job_id` when advancing a branch via `chain`
43
+ - fix: add `position` column to `ductwork_payloads` table
44
+ - feat: store step output payloads in `ductwork_payloads` records
45
+ - feat: support all OSS v1.0 changes
46
+ - fix: release branch in `Ductwork::Pro::Run#resume!`
47
+ - fix: set status for pipeline, run, and branch when resuming
48
+ - feat: add back dampening during advancement
49
+ - feat: respect delay in definition when advancing branches
50
+ - fix: properly release branch when execution times out
51
+ - fix: call methods on `branch` in pipeline advancer
52
+ - fix: use correct associations in `JobWorker#timed_out?`
53
+ - fix: do not advance pipeline on job timeouts
54
+ - feat: move dampening and resuming to `Run` with a top-level `Pipeline#resume!`
55
+ - fix: properly report metrics when pipeline run completes or halts
56
+ - fix: create `runs` and `branches` records when triggering pipeline
57
+ - chore: change `dampers` association to `runs` instead of `pipelines`
58
+ - chore: regenerate spec migrations to pick up `ductwork` v1.0.0 changes
59
+
60
+ ## [0.8.0]
61
+
62
+ - feat: support delay and timeout arguments for `divert` and `converge` transitions - this is the last of adding support for the new transitions
63
+ - fix: wrap code with rails app executor
64
+ - feat: support `divert` and `converge` transitions in pipeline advancement
65
+
3
66
  ## [0.7.0]
4
67
 
5
68
  - 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,153 @@
1
1
  # Ductwork Changelog
2
2
 
3
+ ## [1.1.0]
4
+
5
+ - chore: add color to CLI banner
6
+ - chore: change CLI banner to be more on-theme
7
+
8
+ ## [1.0.0]
9
+
10
+ - chore: add `trilogy` database adapter to the CI test matrix
11
+ - chore: read workflow/pipelines definitions from `app/workflows` directory
12
+ - fix: rescue transient errors in supervisor/runner work loops so one bad iteration doesn't skip graceful shutdown or kill the process
13
+ - chore: rescue invalid foreign key error as to not let thread die
14
+ - fix: reap claims that lost their process ID on a process destroy racing a fresh claim
15
+ - fix: always recover incomplete claims when reusing a PID + machine identifier
16
+ - 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
17
+ - 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
18
+ - 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)
19
+ - feat: record the matching `expand` node as `barrier_node` on `collapse` edges in the pipeline definition
20
+ - fix: use database clock instead of app-server time for execution, job, and process timestamps
21
+ - feat: add `Ductwork::DatabaseClock.now` to read current time from the database
22
+ - fix: add missing indexes for runs and transitions
23
+ - fix: use correct index for claiming for mysql databases
24
+ - chore: add optional force argument on process reap
25
+ - chore: do not raise `StaleClaimError` in `Branch#with_claim_fence`
26
+ - fix: add missing claim fence around branch/advancement mutation queries
27
+ - fix: add a branch claiming guard to skip when no live process record exists
28
+ - fix: cleanup crashed thread and release branch atomically
29
+ - feat: cap pipeline advancer crashes and halt with `advancer_crashes_exhausted`
30
+ - feat: create configuration for pipeline advancer max crash count
31
+ - chore: conditionally update step, run, and pipeline status when claiming
32
+ - fix: fire `on_halt` once and outside of all database transactions
33
+ - fix: add missing database indexes (mostly) for dashboard pages
34
+ - fix: only `sleep` pipeline advancer when no branch is claimed
35
+ - feat: add configurable pipeline advancer thread pool via `pipeline_advancer.count`
36
+ - fix: do not let one wedged worker freeze the whole supervisor work loop
37
+ - fix: ignore already-exited child processes during graceful shutdown
38
+ - chore: set `job_crashes_exhausted` as pipeline halt reason when exhausted crash budget
39
+ - feat: cap and tail back-off execution crashes
40
+ - feat: create configuration for job worker max crash count
41
+ - feat: add `Ductwork::Workflow` alias of `Ductwork::Pipeline`
42
+ - fix: skip "internal" errors when calculating advancement retry budget
43
+ - chore: bump advancement retry count to 10
44
+ - feat: automatically restart threads that are stuck in framework code
45
+ - fix: add secondary order by UUID v7 to get latest step on branch
46
+ - feat: make supervisor reaper timeout configurable
47
+ - chore: add composite index for branch claim step subquery
48
+ - chore: add composite index for `latest_step` lookup
49
+ - chore: add index on processes for last_heartbeat_at timestamp
50
+ - fix: use partial index for resolving run's terminal state for performance
51
+ - fix: use partial index in branch claim query for performance
52
+ - fix: use original claim token when cleaning up dead pipeline advancer threads
53
+ - fix: do not call `on_halt` lifecycle event if transaction rolls back
54
+ - fix: cleanup claimed resources on worker/advancer restart
55
+ - fix: crash execution if thread crashes without process crashing
56
+ - fix: abandon advancements if thread crashes without process crashing
57
+ - fix: prevent OS-level PID reuse from adopting stale `Ductwork::Process` record
58
+ - fix: fence `Execution#crashed!` with process ID so a reclaimed execution isn't clobbered by the reaper
59
+ - fix: check claim tokens before advancing branch
60
+ - feat: create in-scope `idempotency_key` attribute for the `Step` model
61
+ - fix: raise `Execution::CommitFailed` from `#succeeded!`/`#errored!` instead of silently dropping the result when the reaper has clobbered an in-flight execution
62
+ - fix: do not re-query current process ID in execution claiming
63
+ - fix: use database clock in process supervisor to avoid cross-host clock drift
64
+ - feat: add CLI health check command that reports supervisor processes health
65
+ - fix: protect against cross-host clock skew with database time comparisons
66
+ - chore: rescue heartbeat errors to prevent killing process
67
+ - fix: duplicate `Ductwork::BranchLink` records when reviving a pipeline
68
+ - fix: prevent possible double execution in `Pipeline#revive!` by only retrying advancing if it was the halt reason, otherwise retry the job
69
+ - fix: pass failed job input arguments to new job when reviving a pipeline
70
+ - fix: pass in owner process id when executing job - this ensures the process finishing the job is the one that claimed it
71
+ - fix: make `Execution#succeeded!` and `#errored!` idempotent and fence with process id
72
+ - feat: set `ductwork_execution.process_id` when claiming availability
73
+ - chore: associate `ductwork_executions` to `ductwork_processes`
74
+ - chore: hoist usages of app executor out of models and services
75
+ - chore: refactor `Job` methods and claiming onto `Execution`
76
+ - fix: prevent possible double job execution by using "current" `execution` record
77
+ - chore: swap locking for atomic update in `Ductwork::Availability#abandon!`
78
+ - chore: swap locking for atomic update in `Ductwork::Advancement#abandon!`
79
+ - fix: make `Ductwork::Job#execution_crashed!` idempotent to prevent race condition with the reaper
80
+ - fix: prevent possible double job execution by only wrapping user code in rescue
81
+ - fix: atomically release branches conditioned on claim token
82
+ - fix: fail abandoned availabilities and executions when reaping jobs
83
+ - fix: fail abandoned advancements when reaping branches
84
+ - fix: prevent double execution by explicitly killing process before replacing it if it failed health check
85
+ - fix: close durability gap by rescuing branch halting
86
+ - fix: close durability gap by releasing branch in advancement rescue
87
+ - fix: permit `id` parameter when paginating
88
+ - chore: set new execution time to be "now" when job worker crashes (ie. don't set a retry back-off as with job erroring)
89
+ - feat: add a crash count to executions and increment when job worker crashes
90
+ - chore: setup and add first durability integration test
91
+ - chore: nullify claim token when releasing, completing, or halting branch
92
+ - feat: prevent branch claim/release race condition by comparing tokens
93
+ - chore: generate and set claim token on branch record during claiming
94
+ - chore: add `ductwork_branches.claim_token` string column
95
+ - chore: set `@branch` ivar after branch is claimed
96
+ - fix: only use `unique_by` options for non-MySQL db adapters
97
+ - feat: add mysql and postgresql databases to CI test matrix
98
+ - fix: don't use partial indexes for MySQL
99
+ - fix: adopt or create process record when reporting heartbeat
100
+ - fix: fan-in advancement checks branch status instead of step status
101
+ - fix: add back in calling `on_halt` DSL method
102
+ - feat: set `halt_reason` when halting branch
103
+ - chore: add `halt_reason` column to `ductwork_branches` table
104
+ - fix: update proper records and state for advancer retry
105
+ - feat: respect pipeline advancer max retry configuration
106
+ - feat: add pipeline advancer max retry configuration
107
+ - feat: properly set pipeline/run terminal states
108
+ - fix: check for terminal status before setting status on pipeline
109
+ - chore: do not halt pipeline from job worker
110
+ - feat: update branch claim query
111
+ - feat: implement `Ductwork::Pipeline#revive!`
112
+ - chore: add `source_step_id` column on `ductwork_steps`
113
+ - fix: protect against null error backtraces for job results
114
+ - fix: update dashboard pages to handle `ductwork_runs`
115
+ - fix: create `ductwork_runs` records opaquely in rspec test helpers
116
+ - feat: associate `branches`, `steps`, and `tuples` to `runs` instead of `pipelines`
117
+ - feat: add `ductwork_runs` table and model to represent pipeline runs
118
+ - chore: rename `ductwork_runs` to `ductwork_attempts`
119
+ - chore: log when pipeline advancement errors
120
+ - fix: move branch release into main advance transaction
121
+ - fix: set `last_advanced_at` timestamp when releasing branch
122
+ - chore: derive process dead threshold from process reap threshold
123
+ - fix: move transition and advancement creation into claim transaction
124
+ - fix: guard against PID reuse with guard statement
125
+ - fix: use safe navigation operator on process records
126
+ - fix: rescue all job worker errors as to not let thread die
127
+ - fix: reap process (and claims) on process restart or immediate shutdown
128
+ - chore: bump ruby versions in CI
129
+ - chore: bump rails versions in appraisals file
130
+ - chore: DRY up process record destruction
131
+ - fix: release job availabilities during process reaping
132
+ - fix: use proper association to `ductwork_processes` in `ductwork_availabilities`
133
+ - fix: release branches during process reaping
134
+ - feat: add process reaper check to supervisors' run loop
135
+ - chore: DRY up process record adoption and creation
136
+ - fix: create or adopt process records in pipeline advancer and job worker
137
+ - fix: create top-level process record for thread supervisor
138
+ - fix: use safe navigation in rescue when advancing branch
139
+ - fix: use correct column name in migration
140
+ - 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
141
+ - fix: delete child process records when restarting within supervisor
142
+ - fix: wrap optimistic job claiming in a single transaction
143
+ - chore: bump project and CI ruby versions to v4.0.2
144
+ - fix: add missing index when migrating to UUIDs
145
+ - fix: job availability claim and update state in a single transaction
146
+ - feat: complete and halt active branches for pipelines
147
+ - feat: create initial branch when a pipeline is triggered
148
+ - feat: introduce `Branch` and `BranchJunction` models and tables
149
+ - fix: correctly log transition names via edge
150
+
3
151
  ## [0.26.0]
4
152
 
5
153
  - 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 +404,4 @@
256
404
 
257
405
  ## [0.1.0]
258
406
 
259
- - Initial release - see [documentation](https://docs.getductwork.io/) for details
407
+ - 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>