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.
- checksums.yaml +4 -4
- data/.claude/skills/audit-clock-drift/SKILL.md +167 -5
- data/.claude/skills/audit-common/accepted-tradeoffs.md +118 -0
- data/.claude/skills/audit-common/method.md +86 -0
- data/.claude/skills/audit-common/scope-boundaries.md +101 -0
- data/.claude/skills/audit-common/severity.md +77 -0
- data/.claude/skills/audit-database-indexes/SKILL.md +182 -5
- data/.claude/skills/audit-database-support/SKILL.md +209 -11
- data/.claude/skills/audit-durability/SKILL.md +227 -10
- data/CHANGELOG-PRO.md +9 -1
- data/CHANGELOG.md +21 -0
- data/CLAUDE.md +1 -1
- data/lib/ductwork/abandoned_claim.rb +8 -0
- data/lib/ductwork/branch_claim.rb +92 -36
- data/lib/ductwork/claimed_state.rb +57 -0
- data/lib/ductwork/crash.rb +5 -0
- data/lib/ductwork/models/advancement.rb +11 -5
- data/lib/ductwork/models/branch.rb +91 -18
- data/lib/ductwork/models/execution.rb +40 -13
- data/lib/ductwork/models/pipeline.rb +2 -2
- data/lib/ductwork/models/process.rb +7 -3
- data/lib/ductwork/models/run.rb +2 -1
- data/lib/ductwork/models/step.rb +6 -0
- data/lib/ductwork/optimistic_locking_execution_claim.rb +6 -16
- data/lib/ductwork/orphaned_claim.rb +5 -0
- data/lib/ductwork/polling_interval.rb +30 -0
- data/lib/ductwork/process_crash.rb +5 -0
- data/lib/ductwork/processes/job_worker.rb +25 -6
- data/lib/ductwork/processes/pipeline_advancer.rb +16 -6
- data/lib/ductwork/processes/process_supervisor.rb +21 -0
- data/lib/ductwork/processes/worker_health_check.rb +38 -10
- data/lib/ductwork/row_locking_execution_claim.rb +4 -17
- data/lib/ductwork/thread_crash.rb +5 -0
- data/lib/ductwork/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bd349349c33899f1c8cdcf97339176dd72fb6397df4dbccba7445946337810b1
|
|
4
|
+
data.tar.gz: 39560169d62cd46193221f6d74f00524b39314bc470787213f891260bcc5a3db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
#
|
|
7
|
+
# Clock Drift Audit
|
|
8
8
|
|
|
9
|
-
Audit the
|
|
9
|
+
Audit the OSS ductwork codebase for time handling that breaks when Ductwork
|
|
10
|
+
runs across multiple hosts whose clocks disagree.
|
|
10
11
|
|
|
11
|
-
|
|
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.
|