ductwork 1.1.0 → 1.1.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-clock-drift/SKILL.md +167 -5
  3. data/.claude/skills/audit-common/accepted-tradeoffs.md +118 -0
  4. data/.claude/skills/audit-common/method.md +86 -0
  5. data/.claude/skills/audit-common/scope-boundaries.md +101 -0
  6. data/.claude/skills/audit-common/severity.md +77 -0
  7. data/.claude/skills/audit-database-indexes/SKILL.md +182 -5
  8. data/.claude/skills/audit-database-support/SKILL.md +209 -11
  9. data/.claude/skills/audit-durability/SKILL.md +227 -10
  10. data/CHANGELOG-PRO.md +9 -1
  11. data/CHANGELOG.md +21 -0
  12. data/CLAUDE.md +1 -1
  13. data/lib/ductwork/abandoned_claim.rb +8 -0
  14. data/lib/ductwork/branch_claim.rb +92 -36
  15. data/lib/ductwork/claimed_state.rb +57 -0
  16. data/lib/ductwork/crash.rb +5 -0
  17. data/lib/ductwork/models/advancement.rb +11 -5
  18. data/lib/ductwork/models/branch.rb +91 -18
  19. data/lib/ductwork/models/execution.rb +40 -13
  20. data/lib/ductwork/models/pipeline.rb +2 -2
  21. data/lib/ductwork/models/process.rb +7 -3
  22. data/lib/ductwork/models/run.rb +2 -1
  23. data/lib/ductwork/models/step.rb +6 -0
  24. data/lib/ductwork/optimistic_locking_execution_claim.rb +6 -16
  25. data/lib/ductwork/orphaned_claim.rb +5 -0
  26. data/lib/ductwork/polling_interval.rb +30 -0
  27. data/lib/ductwork/process_crash.rb +5 -0
  28. data/lib/ductwork/processes/job_worker.rb +25 -6
  29. data/lib/ductwork/processes/pipeline_advancer.rb +16 -6
  30. data/lib/ductwork/processes/process_supervisor.rb +21 -0
  31. data/lib/ductwork/processes/worker_health_check.rb +38 -10
  32. data/lib/ductwork/row_locking_execution_claim.rb +4 -17
  33. data/lib/ductwork/thread_crash.rb +5 -0
  34. data/lib/ductwork/version.rb +1 -1
  35. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95a205cfc0a928333a96512317753b11a461512ae58e38a4320969108d1b7431
4
- data.tar.gz: 96a4ebef590088f0b44c71345b190934493ef44ec137be136804ecf8cad7c6d3
3
+ metadata.gz: bd349349c33899f1c8cdcf97339176dd72fb6397df4dbccba7445946337810b1
4
+ data.tar.gz: 39560169d62cd46193221f6d74f00524b39314bc470787213f891260bcc5a3db
5
5
  SHA512:
6
- metadata.gz: 5785b7e08bfd89bb7f94303babc18fc8b528f26e8b16cbe0bb338b072faee51057f89fdc8d4bcc9f551bd83a81766a9d07045cb246e2cdd5c1bb8ab8e0fb6094
7
- data.tar.gz: f1887e429b87d44c3e90dddd2acb5106c4e39b100689b95f1570a8c240e3e7ea08555b0efb3fce1341a9f72c66e961196e5468999390f034a1147ae0e5f1cbf7
6
+ metadata.gz: 62b5cc64d92c9b5caf225624bb51a252d550f48bbcbc4f5a73ffc19e464964477f41bceec74a654c783859890fcfab84305327abdf4685fa460ffbeb6e35fdff
7
+ data.tar.gz: 0d4483bcd3dce63092438c392cc6089f9b8ad9a8e5730cb133fbc7bacd22d0a82ff6afdabb285c447593afcc13979d3f74df3d57801861eb13815d91d5744a46
@@ -1,11 +1,173 @@
1
1
  ---
2
2
  name: audit-clock-drift
3
- description: Audit ductwork for time comparisons that are prone to clock drift
4
- allowed-tools: Read, Grep, Glob
3
+ description: Audit the OSS ductwork codebase for unsafe time handling across hosts — comparing a database timestamp against a Ruby in-memory clock read, measuring durations with a wall clock instead of a monotonic one, timestamp precision mismatches between Ruby and column types, and timezone-naive comparisons. Use when the user asks about clock drift, NTP skew, time comparisons, heartbeat staleness, deadline or timeout correctness, multi-host clock safety, or DatabaseClock usage.
4
+ allowed-tools: Read, Grep, Glob, Bash
5
5
  ---
6
6
 
7
- # Audit Clock Drift
7
+ # Clock Drift Audit
8
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.
9
+ Audit the OSS ductwork codebase for time handling that breaks when Ductwork
10
+ runs across multiple hosts whose clocks disagree.
10
11
 
11
- For each finding: file:line, severity, why it matters, suggested fix. Do not modify files.
12
+ Bash is available for **read-only** inspection. Do not modify files.
13
+
14
+ ## Step 0 — Load shared context
15
+
16
+ - `.claude/skills/audit-common/method.md`
17
+ - `.claude/skills/audit-common/severity.md`
18
+ - `.claude/skills/audit-common/scope-boundaries.md`
19
+ - `.claude/skills/audit-common/accepted-tradeoffs.md`
20
+
21
+ ## The core hazard
22
+
23
+ Ductwork runs as multiple processes, potentially on multiple hosts, all
24
+ against one database. Each host's wall clock drifts independently. Any
25
+ comparison that mixes **a timestamp stored by the database** with **a clock
26
+ read taken in a Ruby process** is only as correct as the skew between those
27
+ two machines.
28
+
29
+ Under drift these comparisons make healthy work look stale (premature reap,
30
+ double execution) or stale work look healthy (never reaped, permanent stall).
31
+
32
+ `Ductwork::DatabaseClock` exists to resolve exactly this, and it is the
33
+ canonical fix for category 1 below. Findings should name it rather than
34
+ inventing a remedy.
35
+
36
+ ## Step 1 — Inventory
37
+
38
+ Enumerate and count before analyzing:
39
+
40
+ 1. Every `Time.current`, `Time.now`, `Time.zone.now`, `Date.today`, and
41
+ `DateTime.now` in `lib/` and `app/`.
42
+ 2. Every `Ductwork::DatabaseClock` call site (`.now`, `.ago_sql`, `.now_sql`).
43
+ 3. Every `Process.clock_gettime` call site.
44
+ 4. Every timestamp column in the migration templates, with its declared
45
+ precision.
46
+ 5. Every timeout/interval in `lib/ductwork/configuration.rb` and each place
47
+ the value is consumed.
48
+
49
+ ## Step 2 — Classify every time read
50
+
51
+ Not every `Time.current` is a bug. Most are fine. Sort each call site into
52
+ one of these, and only the first three are reportable:
53
+
54
+ | Class | Reportable? |
55
+ |---|---|
56
+ | **A. Cross-host comparison** — Ruby clock read compared against a DB-stored timestamp, gating a safety or visibility decision | **Yes** — usually High/Critical |
57
+ | **B. Wall-clock duration** — elapsed time or a deadline measured by subtracting/adding wall-clock reads | **Yes** — usually Medium/High |
58
+ | **C. Precision or timezone mismatch** — read and column disagree on resolution or zone | **Yes** — usually Medium |
59
+ | D. Same-process comparison — both reads from one process's clock, used only for that process's own bookkeeping | No, unless it's also class B |
60
+ | E. Recorded value — a timestamp written for display, audit, or metrics, never compared to gate a decision | No |
61
+ | F. Test/factory/dashboard code | No |
62
+
63
+ Class D is the common false positive: comparing two in-memory reads inside
64
+ one process is drift-safe by construction, because there is only one clock.
65
+ It may still be a class B bug if the interval matters. Check for that, then
66
+ move on — do not file it as drift.
67
+
68
+ ## Step 3 — Checklist
69
+
70
+ ### 3.1 Cross-host comparison (class A)
71
+
72
+ The critical category. For each, ask: does one side of this comparison come
73
+ from the database and the other from Ruby?
74
+
75
+ Highest-risk surfaces — check each explicitly:
76
+
77
+ - **Heartbeat staleness.** Comparing `last_heartbeat_at` (written by process
78
+ A) against a clock read in process B decides whether to reap A. Drift here
79
+ reaps live processes or leaves dead ones running.
80
+ - **Claim eligibility.** Any `WHERE ... <= ?` where the bind is a Ruby time
81
+ and the column is DB-written — including retry-after / backoff gates and
82
+ availability windows.
83
+ - **Reap sweeps and global timeouts.** "Started more than N seconds ago"
84
+ predicates.
85
+ - **Ordering.** `ORDER BY` on a column written by many hosts is already
86
+ approximate; a finding here needs a real consequence, not just imprecision.
87
+
88
+ **Fix:** push the comparison into SQL so both sides resolve on the database
89
+ server — `DatabaseClock.ago_sql(column, interval)` for "older than N
90
+ seconds", `DatabaseClock.now_sql(column)` for "at or before now". Where a
91
+ materialized value must be written, use `DatabaseClock.now` so the value
92
+ originates from the same clock everything compares against.
93
+
94
+ **Also check the writes.** Storing `Time.current` into a column that another
95
+ host later compares against the DB clock reintroduces the skew from the write
96
+ side. Both ends must agree on which clock is authoritative.
97
+
98
+ ### 3.2 Monotonic vs wall clock (class B)
99
+
100
+ A distinct bug from drift, and easy to miss because it is single-host. Wall
101
+ clocks step — NTP corrections, DST, manual sets, VM resume. A duration or
102
+ deadline computed from wall-clock reads can jump backward or forward
103
+ arbitrarily.
104
+
105
+ Check every:
106
+
107
+ - `deadline = Time.current + timeout`, then `while Time.current < deadline`
108
+ - `(Time.current - some_earlier_read) > threshold`
109
+ - Shutdown budgets, kill budgets, poll loops, backoff computation
110
+
111
+ **Fix:** `Process.clock_gettime(Process::CLOCK_MONOTONIC)` for anything
112
+ measuring *elapsed* time. Wall clock is correct only for timestamps that must
113
+ be meaningful to a human or comparable across processes.
114
+
115
+ Note the asymmetry when reporting: for a shutdown budget a backward clock
116
+ step means the loop waits far too long; for a staleness threshold it means
117
+ the check never fires. Say which.
118
+
119
+ The codebase already uses `CLOCK_MONOTONIC` in at least one place — cite it
120
+ as the in-repo precedent so the fix reads as consistency, not novelty.
121
+
122
+ ### 3.3 Precision and truncation (class C)
123
+
124
+ - Do timestamp columns declare a precision, and does it match what the code
125
+ compares against? `DatabaseClock` emits `CURRENT_TIMESTAMP(6)` on
126
+ MySQL/Trilogy — microseconds. A MySQL `DATETIME` declared with no precision
127
+ stores **whole seconds** and truncates on write. A stored value can then
128
+ appear up to a second *earlier* than it was, making `<=` comparisons fire
129
+ early or late near the boundary.
130
+ - SQLite stores timestamps as strings; `julianday()` comparison and string
131
+ comparison do not order identically for mixed formats.
132
+ - Does Ruby write sub-second precision the column cannot hold?
133
+ - Are two columns compared against each other stored at different precisions?
134
+
135
+ Grade these by whether the truncation can cross a decision boundary. If the
136
+ threshold is 60 seconds, a one-second truncation is Low. If a claim gate
137
+ compares near-simultaneous timestamps, it is not.
138
+
139
+ ### 3.4 Timezone handling
140
+
141
+ - `Time.now` (system zone) rather than `Time.current` (app zone) — a real bug
142
+ when hosts have different `TZ`.
143
+ - Comparing a zone-aware value to a naive one.
144
+ - `Date.today` in any gating logic — it's the system zone and it rolls over
145
+ at different instants per host.
146
+
147
+ ### 3.5 The single-clock assumption
148
+
149
+ `DatabaseClock` is only safe if every process reaches the *same* clock.
150
+ Verify and report where it does not hold:
151
+
152
+ - A read replica serves a different `clock_timestamp()` than the primary. Are
153
+ any of these comparisons on a connection that could be routed to a replica?
154
+ - CockroachDB is multi-node; its clock guarantees differ from single-primary
155
+ Postgres. Does anything assume tighter ordering than Cockroach provides?
156
+ - Does `DatabaseClock` handle every supported adapter, and does the `else`
157
+ branch raise rather than silently falling back? An adapter that reaches the
158
+ fallback would be comparing against nothing.
159
+
160
+ ## Step 4 — Verify and report
161
+
162
+ Follow `method.md`. Additionally, for each finding state **which two clocks
163
+ are being compared** and **which direction of skew causes which failure**.
164
+ A clock finding without that is not actionable:
165
+
166
+ > `process.rb:NN` compares `last_heartbeat_at` (written by the worker host)
167
+ > against `Time.current` on the supervisor host. If the supervisor's clock
168
+ > runs fast by more than the timeout, live workers are reaped and their
169
+ > in-flight work is re-claimed while still running.
170
+
171
+ Per `severity.md`, cross-host comparisons that gate reaping or claiming are
172
+ High or Critical because the failure is silent double execution or a
173
+ permanent stall. Recorded-but-uncompared timestamps are not findings.
@@ -0,0 +1,118 @@
1
+ # Accepted Tradeoffs — Do Not Report
2
+
3
+ Each item below has been investigated and consciously decided. Re-reporting
4
+ them wastes the reader's attention and buries real findings.
5
+
6
+ **Do not file these as findings.** If the audit surfaces one, mention it in a
7
+ single line under a "Known, previously accepted" heading at the end of the
8
+ report — or omit it entirely.
9
+
10
+ **Exception:** report it *only* if you have genuinely new information — a
11
+ concrete interleaving, call site, or consequence not covered by the reasoning
12
+ recorded here. If so, lead the finding with what is new. Do not re-argue the
13
+ original decision.
14
+
15
+ ---
16
+
17
+ ## 1. Branch claiming does not use `SKIP LOCKED`
18
+
19
+ Candidate selection (`BranchClaim#find_candidate_branch_id`) joins `Branch` to
20
+ `Step`, and SQLite must be supported uniformly (no adapter split like job
21
+ claiming's `RowLocking` / `OptimisticLocking` pair). That forced branch
22
+ claiming into a universal CAS: `SELECT` a candidate, then a guarded `UPDATE`,
23
+ then check rows-updated. Locking across the Branch↔Step join would also
24
+ contend with the job-claiming path's own `Step` writes.
25
+
26
+ Acknowledged 2026-07-22 as a data-model corner, acceptable for now.
27
+
28
+ **Also accepted:** the resulting thundering herd. With no `SKIP LOCKED` and no
29
+ randomization, every advancer thread converges on the same
30
+ `ORDER BY last_advanced_at LIMIT 1` row. At ~1M branches this makes the
31
+ select→update gap a routine stale window. It costs **wasted claim attempts,
32
+ not corruption**. Do not file the contention as a correctness bug.
33
+
34
+ **Still in force — this rule is auditable:** whatever the candidate `SELECT`
35
+ filters on, the claiming `UPDATE` must re-assert. A *new* divergence between
36
+ those two predicates is a real finding.
37
+
38
+ ## 2. Layer 3 per-claim heartbeats — rejected for OSS
39
+
40
+ The plan for `last_progress_at` on Advancement/Execution plus a reaper sweep,
41
+ to detect threads that are alive but stuck, was rejected.
42
+
43
+ Acting on "thread stuck mid-job" means killing the thread, which requires
44
+ bounding user-code runtime — that is the step-timeout feature, which is Pro
45
+ by design. You cannot distinguish "hung" from "legitimately slow" without it.
46
+ The remaining case, a thread hung in *framework* code between claims with no
47
+ execution claimed, is rare, short-lived, and holds nothing worth reaping.
48
+
49
+ Do not re-propose per-claim heartbeats. The OSS durability story is: ensure
50
+ blocks plus restart cleanup for thread crashes, process-heartbeat reaper for
51
+ process crashes, at-least-once documented for the rest.
52
+
53
+ ## 3. Reaper race 2 (process record drift) — deferred
54
+
55
+ A reaped-then-resumed process has worker threads holding in-memory ownership
56
+ of records that were already released and possibly re-claimed. The worker can
57
+ overwrite reaper state and the job can run twice.
58
+
59
+ Deliberately deferred. Acceptable while Ductwork assumes idempotent jobs. The
60
+ fix — an ownership check at the worker's commit boundary — lands when
61
+ non-idempotent work does, or if drift is observed in the wild.
62
+
63
+ Do not file this as Critical double-execution. **At-least-once under process
64
+ death is the documented contract.**
65
+
66
+ Race 1 (the reaper stomping a legitimate concurrent claim) is *not* on this
67
+ list — it is an active concern. Note that the design memo proposing
68
+ `Advancement#abandon!` / `Availability#abandon!` was superseded: the shipped
69
+ mechanism is a claim fence token (`Branch#claim_fence_token`, checked in
70
+ `Branch#release!` at `lib/ductwork/models/branch.rb:183`) plus
71
+ `BranchClaim#fail_abandoned_advancement`. Audit what is in the tree, not the
72
+ memo.
73
+
74
+ ## 4. MySQL durability-spec segfault — deferred
75
+
76
+ `spec/integration/durability/*` intermittently segfaults on mysql2 only. A
77
+ fault-injection `kill` terminates a thread holding a mysql2 connection
78
+ mid-query; the C client's fiber-ownership flag is never cleared, the poisoned
79
+ connection returns to the pool, and teardown double-frees in libmysqlclient.
80
+
81
+ Diagnosed as **test isolation, not a production-code bug**. Fix deferred by
82
+ explicit decision (2026-06-23) until it shows up often enough in CI to matter.
83
+
84
+ ## 5. No `status` column on transitions or advancements
85
+
86
+ State is derived from timestamps and error columns by design — in progress is
87
+ `completed_at IS NULL`, succeeded adds `error_klass IS NULL`, failed is
88
+ `error_klass IS NOT NULL`. Do not propose adding a status enum. Do not report
89
+ the derivation as a missing-column problem.
90
+
91
+ ## 6. No `stuck` pipeline state
92
+
93
+ Considered and rejected. Revival, hooks, dashboards, and transitions do not
94
+ diverge between "stuck" and "halted by failure", and a state enum must earn
95
+ its place through divergent behavior rather than labeling. Cause metadata
96
+ lives on `Branch#halt_reason` instead. Pipeline and Run states stay
97
+ `in_progress`, `completed`, `halted`.
98
+
99
+ ## 7. `Ductwork.validate!` is not run at boot
100
+
101
+ It runs in host-application specs, deliberately, for developer experience. Do
102
+ not recommend moving it to boot-time or engine initialization.
103
+
104
+ ---
105
+
106
+ # Known Open — report only with new information
107
+
108
+ Not settled, but already on record. Same rule: lead with what is new.
109
+
110
+ - **`PipelineAdvancer#kill` uses `thread.kill`**, which could poison a mysql2
111
+ connection the same way the spec segfault does, if it ever fires mid-query.
112
+ Flagged as worth a later look; production impact unconfirmed. A finding
113
+ here needs a concrete path showing it firing mid-query.
114
+ - **`Ductwork::UserJobError` wrapper.** The rescue layering
115
+ (`Job#execute` owns user code, `JobWorker#work_loop` owns everything else,
116
+ `Branch.with_latest_claimed` owns token-conditional release) would be
117
+ narrower and safer if each layer could rescue by class. Known shape to aim
118
+ for, not implemented. Report only a *new* concrete misrouting it causes.
@@ -0,0 +1,86 @@
1
+ # Shared Audit Method
2
+
3
+ Every `audit-*` skill in this repo follows this procedure. The topic-specific
4
+ SKILL.md supplies the *what*; this file supplies the *how*.
5
+
6
+ ## Step 1 — Build the inventory before analyzing
7
+
8
+ Do not start grepping for problems. First enumerate the surface in scope and
9
+ write the list down. The audit is only as complete as this list.
10
+
11
+ - Use Glob/Grep to enumerate every file in the topic's surface.
12
+ - Record the count. State it in the report.
13
+ - Work the list. A file that was never opened is not a file that passed.
14
+
15
+ "Audit the entire codebase" without an inventory produces a handful of
16
+ findings from whatever grep happened to surface first. That is the single
17
+ biggest cause of a thin audit.
18
+
19
+ ## Step 2 — Analyze against the topic checklist
20
+
21
+ Follow the topic skill's categories in order. For each item on the inventory,
22
+ ask the checklist questions explicitly rather than scanning for anything that
23
+ looks wrong.
24
+
25
+ Prefer reading whole files over grepping for patterns. Durability,
26
+ concurrency, and adapter bugs live in the *relationship* between lines — the
27
+ order of two writes, the extent of a transaction, what a rescue does and does
28
+ not cover. Grep finds none of that.
29
+
30
+ ## Step 3 — Verification pass (required)
31
+
32
+ Before writing the report, re-read every cited `file:line` and confirm the
33
+ code still says what the finding claims.
34
+
35
+ Drop any finding where:
36
+ - The line number no longer matches the quoted code.
37
+ - The concern is already handled somewhere the first pass missed (a guard in
38
+ the caller, a CAS predicate, an outer `ensure`, a DB constraint).
39
+ - It appears in `accepted-tradeoffs.md`.
40
+ - It is out of scope per `scope-boundaries.md` (notably: Pro features).
41
+
42
+ State how many candidate findings were dropped in verification. A pass that
43
+ drops nothing usually means the verification did not actually happen.
44
+
45
+ ## Step 4 — Deduplicate
46
+
47
+ A single line can legitimately trip several audits — a `Time.current`
48
+ comparison inside a claim query is both a clock-drift and a durability
49
+ finding. Within one audit, report each root cause **once**, at the deepest
50
+ layer that owns it, and list the other affected call sites underneath it.
51
+ Do not file the same root cause once per call site.
52
+
53
+ ## Output contract
54
+
55
+ Report inline in the response. Do not write a report file unless asked, and
56
+ do not modify any source file.
57
+
58
+ Order findings by severity (see `severity.md`), highest first. Group by
59
+ category only when there are enough findings that grouping helps.
60
+
61
+ Each finding uses this shape:
62
+
63
+ ```
64
+ ### [SEVERITY] Short title
65
+ `path/to/file.rb:123`
66
+
67
+ **What:** The specific code and what it does.
68
+ **Why it matters:** The concrete failure — the sequence of events that
69
+ produces data loss, a stall, double execution, or a wrong result. Not
70
+ "this is risky."
71
+ **Fix:** A specific change. Name the existing helper or pattern in this
72
+ codebase that it should use, if one exists.
73
+ **Confidence:** high | medium — and for medium, what you could not verify.
74
+ ```
75
+
76
+ Close with a short summary: files inventoried, files read, findings by
77
+ severity, candidates dropped in verification.
78
+
79
+ ## When there are no findings
80
+
81
+ Say so plainly. State what was inventoried and what checklist categories were
82
+ checked and came back clean. A clean audit is a valid and useful result.
83
+
84
+ Do not pad a report with speculative or stylistic findings to make it look
85
+ thorough. A "Low" that is really a preference is noise, and it trains the
86
+ reader to skim.
@@ -0,0 +1,101 @@
1
+ # Audit Scope & Boundaries
2
+
3
+ ## The OSS / Pro line
4
+
5
+ This repo is the OSS `ductwork` gem (LGPL v3). A paid `ductwork-pro` gem
6
+ extends it via `prepend`. **Pro features are not gaps in OSS.** They are
7
+ deliberately absent.
8
+
9
+ An audit that recommends a Pro feature as the fix for an OSS finding is
10
+ producing noise, and it is the single most common failure mode of the
11
+ durability and reliability audits in particular.
12
+
13
+ ### In OSS — fair game to audit
14
+
15
+ - Core transitions: `chain`, `expand`, `divide`, `divert`, `combine`,
16
+ `converge`, `collapse`
17
+ - Core pipeline DSL
18
+ - Two-phase commit (transition + advancement records) for advancement
19
+ - Supervisor / advancer / worker process hierarchy
20
+ - Forking and threaded concurrency modes
21
+ - Configurable advancer thread pool
22
+ - Heartbeat-based orphan detection
23
+ - SKIP LOCKED claiming with atomic `UPDATE...WHERE` fallback
24
+ - Reaper with global-timeout sweeps
25
+ - Restart of worker threads stuck in *framework* code (no execution claimed)
26
+ - `Ductwork::Pipeline#revive!`
27
+ - UUID v7 primary keys across PG / MySQL / SQLite
28
+ - Rails engine-mountable web dashboard
29
+
30
+ ### In Pro — do NOT recommend, do NOT report as missing
31
+
32
+ - Human-in-the-loop / the `dampen` transition
33
+ - **Step timeouts** defined in the DSL
34
+ - **Step delays** defined in the DSL
35
+ - Restart of worker threads stuck *inside job execution* (a claimed execution
36
+ that will not return) — this requires bounding user-code runtime, which is
37
+ the step-timeout feature
38
+ - Large payload support
39
+ - Resumable batched fan-out / fan-in
40
+ - Interruptible pipeline advancement
41
+ - StatsD metric reporting
42
+
43
+ ### Hard rules
44
+
45
+ - Never reference `Ductwork::Pro::*` constants from OSS code, and never
46
+ suggest that OSS code do so.
47
+ - OSS must remain fully functional standalone. A finding whose fix requires
48
+ Pro is not a valid OSS finding.
49
+ - If the correct fix genuinely lies in Pro, say so in one line and move on.
50
+ Do not file it.
51
+
52
+ ## Supported databases
53
+
54
+ Claimed support, per `CLAUDE.md` and the install migration templates:
55
+
56
+ | Adapter | Notes |
57
+ |---|---|
58
+ | PostgreSQL | Primary target |
59
+ | CockroachDB | PG wire protocol, divergent semantics |
60
+ | MySQL 8+ | Both the technology and the `mysql2` adapter |
61
+ | Trilogy | MySQL-compatible adapter |
62
+ | SQLite | No partial-index-free workarounds; drives "uniformity" constraints |
63
+ | Oracle | |
64
+
65
+ **CI covers only postgres, mysql, trilogy, and sqlite**
66
+ (`.github/workflows/main.yml`). Oracle and CockroachDB are claimed as
67
+ supported with no automated coverage. Claimed-but-untested support is itself
68
+ a legitimate finding for `audit-database-support` — flag it there, not in
69
+ every audit.
70
+
71
+ ## Code layout
72
+
73
+ - `lib/ductwork/` — core: claiming, clock, config, context, fault injection
74
+ - `lib/ductwork/models/` — ActiveRecord models (the durability surface)
75
+ - `lib/ductwork/processes/` — supervisor / advancer / worker hierarchy and
76
+ their runners
77
+ - `lib/ductwork/dsl/` — pipeline definition DSL
78
+ - `lib/generators/ductwork/install/templates/db/` — install migrations
79
+ - `lib/generators/ductwork/update/templates/db/` — **upgrade migrations**
80
+ - `app/` — the mountable dashboard engine
81
+ - `spec/integration/durability/` — fault-injection crash-window specs
82
+
83
+ **Schema changes land in two places.** A new index or column must be added to
84
+ the install template *and* as a new upgrade migration, or existing
85
+ installations never receive it. An audit that recommends a schema change
86
+ without noting both is incomplete.
87
+
88
+ ## Key abstractions to prefer in fixes
89
+
90
+ When recommending a fix, name the existing primitive rather than inventing a
91
+ new one:
92
+
93
+ - `Ductwork::DatabaseClock` — `.now`, `.ago_sql`, `.now_sql`. The canonical
94
+ answer to any cross-host time comparison.
95
+ - `Ductwork::BranchClaim` — branch claiming CAS.
96
+ - `Ductwork::RowLockingExecutionClaim` / `OptimisticLockingExecutionClaim` —
97
+ the adapter-split job claim strategies.
98
+ - `Ductwork::FaultInjection.checkpoint(:name)` — named crash points, driven
99
+ by the `DUCTWORK_FAULT` env var.
100
+ - `Ductwork::MigrationHelper` — adapter capability predicates for migrations.
101
+ - `Ductwork::Step::ADVANCEABLE_STATUSES` — the shared advanceable predicate.
@@ -0,0 +1,77 @@
1
+ # Severity Rubric
2
+
3
+ Ductwork is a job pipeline framework. Severity is graded by **what the user's
4
+ pipeline does wrong**, not by how unusual the code looks. A framework bug
5
+ silently corrupts every host application that hits it, so correctness
6
+ outranks performance at every level.
7
+
8
+ ## Critical
9
+
10
+ Silent incorrectness or permanent loss. The host application cannot detect it
11
+ and cannot recover without manual intervention.
12
+
13
+ - Work is lost: a step reports success but its result is never durably
14
+ written, or a branch is dropped and never advanced.
15
+ - Double execution of a non-idempotent unit under a realistic interleaving,
16
+ where at-least-once is not the documented contract for that path.
17
+ - A pipeline stalls forever with no reaper, timeout, or revive path that can
18
+ recover it.
19
+ - Data corruption: a run reaches a terminal state that contradicts its
20
+ branches, or a completed run overwritten as halted (or the reverse).
21
+ - A migration that can lose or corrupt existing rows.
22
+
23
+ ## High
24
+
25
+ Recoverable but requires operator action, or a correctness bug that needs an
26
+ uncommon-but-real interleaving.
27
+
28
+ - Stuck work that the reaper recovers only after a global timeout, when a
29
+ targeted mechanism should have caught it promptly.
30
+ - A crash window that leaks a claim, requiring a reap sweep to clear.
31
+ - A race that needs specific timing to trigger but produces incorrect state
32
+ when it does.
33
+ - Missing index on a hot claim path that degrades throughput non-linearly
34
+ with table size.
35
+ - An adapter in the supported list where a code path raises or silently
36
+ misbehaves.
37
+
38
+ ## Medium
39
+
40
+ Degraded behavior with a clear operational signal, or a latent bug that today
41
+ is masked by an assumption that holds but is not enforced.
42
+
43
+ - Correctness that depends on an invariant no constraint or guard enforces.
44
+ - Error routing that sends a failure to the wrong recovery path, where the
45
+ outcome is still eventually correct but the retry accounting is wrong.
46
+ - Missing index on a warm path.
47
+ - Adapter support that works but relies on undocumented or version-specific
48
+ behavior.
49
+ - A crash window that is real but leaks only a recoverable record with no
50
+ correctness consequence.
51
+
52
+ ## Low
53
+
54
+ Correct today and correct under the interleavings that matter, but fragile to
55
+ future change.
56
+
57
+ - Duplicated logic where one copy could drift from another (notably: a
58
+ predicate expressed in two places that must stay in sync).
59
+ - Missing test coverage for a durability window that is otherwise sound.
60
+ - Naming or structure that invites a future contributor to introduce a real
61
+ bug.
62
+
63
+ Do not file style, formatting, or preference items at any severity. Rubocop
64
+ owns those.
65
+
66
+ ## Grading rules
67
+
68
+ - **Grade by outcome, not by mechanism.** "No lock here" is not a finding.
69
+ "Two advancers both pass this check and both create an advancement, so the
70
+ step runs twice" is a finding.
71
+ - **If you cannot describe the interleaving, it is not High or Critical.**
72
+ A finding whose "why it matters" is theoretical caps at Medium.
73
+ - **A documented, accepted contract is not a bug.** At-least-once execution
74
+ under process death is Ductwork's documented contract. Do not file it as
75
+ Critical double-execution. See `accepted-tradeoffs.md`.
76
+ - **Frequency does not raise severity; consequence does.** A rare path that
77
+ silently loses data outranks a common path that wastes a claim attempt.