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.
- checksums.yaml +4 -4
- data/.claude/skills/audit-clock-drift/SKILL.md +11 -0
- data/.claude/skills/audit-database-indexes/SKILL.md +11 -0
- data/.claude/skills/audit-database-support/SKILL.md +18 -0
- data/.claude/skills/audit-durability/SKILL.md +17 -0
- data/.saturnci/database.yml +1 -0
- data/CHANGELOG-PRO.md +60 -0
- data/CHANGELOG.md +144 -1
- data/CLAUDE.md +34 -0
- data/README.md +24 -14
- data/app/controllers/ductwork/application_controller.rb +3 -3
- data/app/controllers/ductwork/dashboards_controller.rb +1 -1
- data/app/controllers/ductwork/pipelines_controller.rb +4 -3
- data/app/helpers/ductwork/application_helper.rb +2 -2
- data/app/views/ductwork/dashboards/show.html.erb +12 -12
- data/app/views/ductwork/pipelines/index.html.erb +12 -12
- data/app/views/ductwork/pipelines/show.html.erb +13 -13
- data/app/views/ductwork/step_errors/index.html.erb +2 -2
- data/lib/ductwork/branch_claim.rb +185 -0
- data/lib/ductwork/cli.rb +73 -24
- data/lib/ductwork/configuration.rb +73 -3
- data/lib/ductwork/context.rb +17 -19
- data/lib/ductwork/database_clock.rb +84 -0
- data/lib/ductwork/dsl/branch_builder.rb +6 -1
- data/lib/ductwork/dsl/definition_builder.rb +24 -1
- data/lib/ductwork/{job_claim.rb → execution_claim.rb} +7 -6
- data/lib/ductwork/fault_injection.rb +35 -0
- data/lib/ductwork/migration_helper.rb +1 -1
- data/lib/ductwork/models/advancement.rb +48 -0
- data/lib/ductwork/models/attempt.rb +9 -0
- data/lib/ductwork/models/availability.rb +2 -0
- data/lib/ductwork/models/branch.rb +800 -0
- data/lib/ductwork/models/branch_link.rb +10 -0
- data/lib/ductwork/models/execution.rb +199 -2
- data/lib/ductwork/models/job.rb +8 -121
- data/lib/ductwork/models/pipeline.rb +150 -272
- data/lib/ductwork/models/process.rb +177 -5
- data/lib/ductwork/models/result.rb +2 -1
- data/lib/ductwork/models/run.rb +119 -1
- data/lib/ductwork/models/step.rb +26 -6
- data/lib/ductwork/models/transition.rb +15 -0
- data/lib/ductwork/models/tuple.rb +1 -4
- data/lib/ductwork/models/workflow.rb +3 -0
- data/lib/ductwork/optimistic_locking_execution_claim.rb +97 -0
- data/lib/ductwork/processes/health_check.rb +87 -0
- data/lib/ductwork/processes/job_worker.rb +86 -20
- data/lib/ductwork/processes/job_worker_runner.rb +25 -37
- data/lib/ductwork/processes/pipeline_advancer.rb +48 -76
- data/lib/ductwork/processes/pipeline_advancer_runner.rb +34 -44
- data/lib/ductwork/processes/process_supervisor.rb +87 -10
- data/lib/ductwork/processes/thread_supervisor.rb +55 -21
- data/lib/ductwork/processes/thread_supervisor_runner.rb +8 -5
- data/lib/ductwork/processes/worker_health_check.rb +55 -0
- data/lib/ductwork/row_locking_execution_claim.rb +77 -0
- data/lib/ductwork/testing/rspec.rb +28 -11
- data/lib/ductwork/version.rb +1 -1
- data/lib/ductwork.rb +5 -0
- data/lib/generators/ductwork/install/install_generator.rb +14 -4
- data/lib/generators/ductwork/install/templates/config/ductwork.yml +4 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_advancements.rb +29 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_attempts.rb +20 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +18 -5
- data/lib/generators/ductwork/install/templates/db/create_ductwork_branch_links.rb +27 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_branches.rb +37 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +8 -1
- data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +0 -8
- data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +1 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +45 -6
- data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +20 -5
- data/lib/generators/ductwork/install/templates/db/create_ductwork_transitions.rb +43 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +3 -3
- data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_advancements.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_executions.rb +14 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_results.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_runs.rb +9 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_transitions.rb +16 -0
- data/lib/generators/ductwork/update/templates/db/add_pipeline_started_index_to_ductwork_runs.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_process_id_to_ductwork_executions.rb +35 -0
- data/lib/generators/ductwork/update/templates/db/add_role_to_ductwork_processes.rb +20 -0
- data/lib/generators/ductwork/update/templates/db/associate_branches_to_runs.rb +74 -0
- data/lib/generators/ductwork/update/templates/db/associate_steps_to_branches.rb +24 -0
- data/lib/generators/ductwork/update/templates/db/associate_steps_to_runs.rb +72 -0
- data/lib/generators/ductwork/update/templates/db/associate_tuples_to_runs.rb +73 -0
- data/lib/generators/ductwork/update/templates/db/backfill_branch_ids_on_steps.rb +21 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_advancements.rb +28 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_branch_links.rb +27 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_branches.rb +37 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_runs.rb +55 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_transitions.rb +32 -0
- data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +11 -4
- data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +5 -0
- data/lib/generators/ductwork/update/templates/db/rename_runs_to_attempts.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/update_process_associations.rb +29 -0
- data/lib/generators/ductwork/update/update_generator.rb +99 -0
- metadata +47 -6
- data/lib/ductwork/optimistic_locking_job_claim.rb +0 -88
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e6b0081c6aa307274ff6dd94cc9e1dc0c8840dd5e6c3fc1768d4b54adc511f6
|
|
4
|
+
data.tar.gz: e1e3f47b3c8ea24b70fc3a518b0cb61e484ea46bc1630b1c8fd911100f4b12e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/.saturnci/database.yml
CHANGED
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://
|
|
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
|
[](https://github.com/ductwork/ductwork/actions/workflows/main.yml)
|
|
4
4
|
[](https://rubygems.org/gems/ductwork)
|
|
5
5
|
|
|
6
|
-
A
|
|
6
|
+
A durable workflow orchestration framework for Ruby.
|
|
7
7
|
|
|
8
|
-
Ductwork lets you build
|
|
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://
|
|
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
|
|
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
|
-
-
|
|
45
|
+
- SendMonthlyStatusReportsWorkflow
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Or use the wildcard to run all pipelines (use cautiously
|
|
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://
|
|
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
|
|
59
|
+
### 1. Create a Workflow Class
|
|
60
60
|
|
|
61
|
-
|
|
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://
|
|
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
|
|
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: {
|
|
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
|
|
8
|
-
Ductwork::
|
|
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(
|
|
18
|
+
relation.where(pipeline_klass: params[:klass])
|
|
19
19
|
else
|
|
20
20
|
relation
|
|
21
21
|
end
|
|
@@ -3,23 +3,24 @@
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
class PipelinesController < Ductwork::ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
@
|
|
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 = @
|
|
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
|
-
@
|
|
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
|
-
<% @
|
|
84
|
-
<tr class="row-link" data-href="<%= pipeline_path(
|
|
83
|
+
<% @runs.each do |run| %>
|
|
84
|
+
<tr class="row-link" data-href="<%= pipeline_path(run.pipeline_id) %>">
|
|
85
85
|
<td>
|
|
86
86
|
<code>
|
|
87
|
-
<%=
|
|
87
|
+
<%= run.pipeline_id %>
|
|
88
88
|
</code>
|
|
89
89
|
</td>
|
|
90
90
|
<td>
|
|
91
91
|
<code>
|
|
92
|
-
<%=
|
|
92
|
+
<%= run.pipeline_klass %>
|
|
93
93
|
</code>
|
|
94
94
|
</td>
|
|
95
95
|
<td>
|
|
96
|
-
<div class="status-pill <%=
|
|
97
|
-
<%=
|
|
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:
|
|
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
|
-
<%=
|
|
119
|
+
<%= run.started_at.iso8601(3) %>
|
|
120
120
|
</div>
|
|
121
121
|
</td>
|
|
122
122
|
<td>
|
|
123
|
-
<% if
|
|
124
|
-
<div data-started-at=<%=
|
|
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(
|
|
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
|
-
<% @
|
|
58
|
-
<tr class="row-link" data-href="<%= pipeline_path(
|
|
57
|
+
<% @runs.each do |run| %>
|
|
58
|
+
<tr class="row-link" data-href="<%= pipeline_path(run.pipeline_id) %>">
|
|
59
59
|
<td>
|
|
60
|
-
<code><%=
|
|
60
|
+
<code><%= run.pipeline_id %></code>
|
|
61
61
|
</td>
|
|
62
62
|
<td>
|
|
63
|
-
<code><%=
|
|
63
|
+
<code><%= run.pipeline_klass %></code>
|
|
64
64
|
</td>
|
|
65
65
|
<td>
|
|
66
|
-
<div class="status-pill <%=
|
|
67
|
-
<%=
|
|
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:
|
|
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
|
-
<%=
|
|
89
|
+
<%= run.started_at.iso8601(3) %>
|
|
90
90
|
</div>
|
|
91
91
|
</td>
|
|
92
92
|
<td>
|
|
93
|
-
<% if
|
|
94
|
-
<div data-started-at=<%=
|
|
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(
|
|
98
|
+
<%= formatted_time_distance(run.started_at, run.completed_at) %>
|
|
99
99
|
<% end %>
|
|
100
100
|
</td>
|
|
101
101
|
</tr>
|