ductwork 1.0.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 (36) 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 +12 -1
  11. data/CHANGELOG.md +26 -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/cli.rb +9 -11
  17. data/lib/ductwork/crash.rb +5 -0
  18. data/lib/ductwork/models/advancement.rb +11 -5
  19. data/lib/ductwork/models/branch.rb +91 -18
  20. data/lib/ductwork/models/execution.rb +40 -13
  21. data/lib/ductwork/models/pipeline.rb +2 -2
  22. data/lib/ductwork/models/process.rb +7 -3
  23. data/lib/ductwork/models/run.rb +2 -1
  24. data/lib/ductwork/models/step.rb +6 -0
  25. data/lib/ductwork/optimistic_locking_execution_claim.rb +6 -16
  26. data/lib/ductwork/orphaned_claim.rb +5 -0
  27. data/lib/ductwork/polling_interval.rb +30 -0
  28. data/lib/ductwork/process_crash.rb +5 -0
  29. data/lib/ductwork/processes/job_worker.rb +25 -6
  30. data/lib/ductwork/processes/pipeline_advancer.rb +16 -6
  31. data/lib/ductwork/processes/process_supervisor.rb +21 -0
  32. data/lib/ductwork/processes/worker_health_check.rb +38 -10
  33. data/lib/ductwork/row_locking_execution_claim.rb +4 -17
  34. data/lib/ductwork/thread_crash.rb +5 -0
  35. data/lib/ductwork/version.rb +1 -1
  36. metadata +15 -4
@@ -1,11 +1,188 @@
1
1
  ---
2
2
  name: audit-database-indexes
3
- description: Audit ductwork for missing database indexes
4
- allowed-tools: Read, Grep, Glob
3
+ description: Audit the OSS ductwork codebase for missing, unusable, or redundant database indexes — hot claim-path queries with no supporting index, composite indexes in the wrong column order, partial-index predicates that break on MySQL, foreign keys and polymorphic columns without coverage, and indexes that duplicate a prefix of another. Use when the user asks about missing indexes, slow queries, query plans, index coverage, schema performance, or scaling a table.
4
+ allowed-tools: Read, Grep, Glob, Bash
5
5
  ---
6
6
 
7
- # Audit Missing Database Indexes
7
+ # Database Index Audit
8
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.
9
+ Audit the OSS ductwork codebase for queries whose access pattern is not
10
+ supported by an index, and for indexes that cost writes without earning it.
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, do not
13
+ run migrations, and do not write to any database.
14
+
15
+ ## Step 0 — Load shared context
16
+
17
+ - `.claude/skills/audit-common/method.md`
18
+ - `.claude/skills/audit-common/severity.md`
19
+ - `.claude/skills/audit-common/scope-boundaries.md`
20
+ - `.claude/skills/audit-common/accepted-tradeoffs.md`
21
+
22
+ **Both migration directories are in scope:**
23
+
24
+ - `lib/generators/ductwork/install/templates/db/` — fresh installs
25
+ - `lib/generators/ductwork/update/templates/db/` — existing installations
26
+
27
+ An index added only to the install template never reaches anyone who already
28
+ runs Ductwork. **Every index recommendation must specify both**: the change
29
+ to the install template *and* a new upgrade migration. A recommendation that
30
+ names only one is incomplete and should be reported as such.
31
+
32
+ ## Step 1 — Build two inventories
33
+
34
+ ### 1a. The index inventory
35
+
36
+ Read every migration in both directories. Produce a table of what an
37
+ already-migrated installation actually has: table, columns in order, unique?,
38
+ partial predicate?, and which adapters receive it (several indexes are inside
39
+ `if mysql?` / `else` branches and differ per adapter).
40
+
41
+ Watch for indexes added, renamed, or dropped by later upgrade migrations —
42
+ the install template alone does not tell you the current shape.
43
+
44
+ ### 1b. The query inventory
45
+
46
+ Enumerate every query in `lib/` and `app/`. Do not rely on grep for `where`
47
+ alone; include:
48
+
49
+ - Named scopes and class-method finders on the models
50
+ - `where`, `order`, `limit`, `pluck`, `exists?`, `find_each`, `update_all`,
51
+ `delete_all`, `count`
52
+ - The claim paths in `branch_claim.rb`, `row_locking_execution_claim.rb`,
53
+ `optimistic_locking_execution_claim.rb`
54
+ - Raw SQL fragments, including those built by `DatabaseClock`
55
+ - Association traversals that emit a query per parent
56
+ - Dashboard queries in `app/` — different profile from the hot path, still
57
+ real
58
+
59
+ For each query record: table, `WHERE` columns, `ORDER BY`, `LIMIT`, and
60
+ whether it runs on a hot path.
61
+
62
+ ## Step 2 — Classify by heat
63
+
64
+ Severity depends far more on call frequency than on query shape.
65
+
66
+ | Class | Meaning |
67
+ |---|---|
68
+ | **Hot** | Runs on every claim attempt, every poll tick, or per unit of work — by every worker and advancer thread simultaneously. Missing index here degrades non-linearly with table size. |
69
+ | **Warm** | Runs per pipeline advancement, per job completion, or per reap sweep. |
70
+ | **Cold** | Dashboard, CLI, health check, migrations, one-off maintenance. |
71
+
72
+ The claim paths are the hottest queries in the system and the ones where a
73
+ missing or unusable index matters most. Start there.
74
+
75
+ Cold queries earn a finding only when the table grows unboundedly and the
76
+ query is a full scan. Do not file an index for a dashboard query that filters
77
+ an already-indexed column.
78
+
79
+ ## Step 3 — Checklist
80
+
81
+ ### 3.1 Missing coverage
82
+
83
+ - Every `WHERE` column combination on a hot or warm query — is there an index
84
+ whose **leading columns** match? An index on `(a, b)` does not serve a
85
+ query filtering only on `b`.
86
+ - Foreign keys and `belongs_to` columns used in lookups.
87
+ - Columns backing uniqueness guarantees — is the constraint enforced by a
88
+ **unique index** in the database, or only by an ActiveRecord validation?
89
+ Validation-only uniqueness is a correctness finding, not just performance,
90
+ because concurrent claimers bypass it.
91
+ - Columns used in `ORDER BY ... LIMIT 1` — the claim pattern. Without an
92
+ index providing the order, the database sorts the entire candidate set to
93
+ return one row.
94
+ - Polymorphic association pairs (`*_type`, `*_id`) indexed together.
95
+
96
+ ### 3.2 Column order and usability
97
+
98
+ An index can exist and still not be usable for a query:
99
+
100
+ - **Equality columns first, then range/sort columns.** An index on
101
+ `(status, created_at)` serves `WHERE status = ? ORDER BY created_at`;
102
+ `(created_at, status)` does not.
103
+ - Does the `ORDER BY` direction match, and are mixed `ASC`/`DESC` orders
104
+ supported by the index as declared?
105
+ - Is a leading column wrapped in a function or cast in the query? That makes
106
+ the index unusable — including implicit casts from a type mismatch between
107
+ the bind and the column.
108
+ - Are near-duplicate indexes (`(a, b, c)` and `(a, c, b)`) both actually
109
+ needed by distinct queries, or is one dead weight?
110
+
111
+ ### 3.3 The `IS NULL` ordering trap (PostgreSQL)
112
+
113
+ **A known and previously diagnosed issue in this codebase — check for new
114
+ instances.** A composite index `(a, b, c)` queried as
115
+ `WHERE a = ? AND b IS NULL ORDER BY c` will **not** be used for ordering on
116
+ PostgreSQL. PostgreSQL does not treat `IS NULL` as an equality constraint for
117
+ index-ordering purposes, so you get a bitmap scan plus a sort instead of an
118
+ ordered index scan — which defeats the `LIMIT 1` entirely.
119
+
120
+ The fix is a partial index moving the null test into the predicate:
121
+ `ON table (a, c) WHERE b IS NULL`.
122
+
123
+ This is exactly the shape the claim paths use (`claimed_for_advancing_at IS
124
+ NULL`, `completed_at IS NULL`), so check every one of them.
125
+
126
+ ### 3.4 Partial indexes and adapter portability
127
+
128
+ Partial indexes (`where:`) work on PostgreSQL and SQLite. **MySQL does not
129
+ support them at all.**
130
+
131
+ The codebase already handles this with `if mysql?` / `else` branches that
132
+ substitute a full index. For every partial index:
133
+
134
+ - Is there a MySQL fallback branch, or does that adapter silently get no
135
+ index?
136
+ - Is the fallback actually useful for the query, or is it a full index whose
137
+ leading column has terrible selectivity?
138
+ - If a fix requires a partial index, state explicitly what MySQL gets
139
+ instead. A recommendation that only works on Postgres is incomplete.
140
+
141
+ Also confirm the predicate is expressible: Cockroach and SQLite accept
142
+ partial indexes but have their own restrictions on the predicate expression.
143
+
144
+ ### 3.5 Redundant and over-indexed
145
+
146
+ Indexes are not free — every one is written on every insert and update, and
147
+ these tables are write-heavy by nature.
148
+
149
+ - Is any index a **strict prefix** of another? `(a)` is redundant when
150
+ `(a, b)` exists and nothing needs `(a)` alone for uniqueness.
151
+ - Are there indexes no query in the inventory uses? Cross-reference inventory
152
+ 1a against 1b and list the unmatched ones.
153
+ - Does a table have so many indexes that insert cost is a concern? Call out
154
+ the write amplification on the hot insert paths.
155
+
156
+ Report these as Low or Medium — real, but the risk of removal is nonzero and
157
+ the reader should decide.
158
+
159
+ ### 3.6 Adapter-specific limits
160
+
161
+ - **MySQL index key length.** Indexed string columns count toward a byte
162
+ limit (3072 bytes with InnoDB/DYNAMIC, and 767 in older configurations).
163
+ Non-Postgres installs store UUIDs as `string(36)` rather than a native
164
+ `uuid` type, so composite indexes over several ID columns are far wider
165
+ there than on Postgres. Check the widest composite indexes.
166
+ - **Identifier length.** Oracle historically caps identifiers at 30
167
+ characters (128 in 12.2+). Auto-generated index names over long
168
+ `ductwork_*` table and column names can exceed it. Check the longest
169
+ generated names and recommend an explicit `name:` where they are at risk.
170
+ - Does `MigrationHelper` gate the index correctly for every supported
171
+ adapter, or does an adapter fall through to a branch meant for another?
172
+
173
+ ## Step 4 — Verify and report
174
+
175
+ Follow `method.md`. Additionally, every index finding must state:
176
+
177
+ 1. **The exact query** — file:line — that is unserved.
178
+ 2. **The access pattern** — `WHERE` columns, `ORDER BY`, `LIMIT`.
179
+ 3. **What the database does today** without the index (full scan, sort of the
180
+ candidate set, bitmap plus sort) and how that scales.
181
+ 4. **The proposed index**, in migration form, with column order justified.
182
+ 5. **The MySQL story** if the proposal is partial.
183
+ 6. **Both landing sites** — install template and a new upgrade migration.
184
+
185
+ Do not claim a query plan you have not verified. If you are inferring the
186
+ plan from the index shape rather than from an `EXPLAIN`, say so and mark the
187
+ finding medium confidence. `EXPLAIN` requires a live database and is out of
188
+ scope for this audit unless the user supplies one.
@@ -1,18 +1,216 @@
1
1
  ---
2
2
  name: audit-database-support
3
- description: Audit ductwork for what database adapters and technologies are supported
4
- allowed-tools: Read, Grep, Glob
3
+ description: Audit the OSS ductwork codebase for code, SQL, and migrations that break on a supported database adapter — PostgreSQL, CockroachDB, MySQL 8+, Trilogy, SQLite, or Oracle. Covers SKIP LOCKED and locking-mode differences, RETURNING, upsert syntax, partial indexes, isolation-level divergence, adapter_name detection bugs, type and identifier limits, and support claimed without CI coverage. Use when the user asks about database compatibility, adapter support, cross-database SQL, portability, or whether something works on a specific database.
4
+ allowed-tools: Read, Grep, Glob, Bash
5
5
  ---
6
6
 
7
- # Audit Clock Drift
7
+ # Database Support Audit
8
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:
9
+ Audit the OSS ductwork codebase for code and queries that do not work
10
+ correctly on every claimed-supported database.
10
11
 
11
- * PostgreSQL
12
- * CockroachDB
13
- * MySQL 8+ (adapter and technology)
14
- * Trilogy (adapter)
15
- * SQLite
16
- * Oracle
12
+ Bash is available for **read-only** inspection. Do not modify files, do not
13
+ run migrations, and do not write to any database.
17
14
 
18
- For each finding: file:line, severity, why it matters, suggested fix. Do not modify files.
15
+ ## Step 0 Load shared context
16
+
17
+ - `.claude/skills/audit-common/method.md`
18
+ - `.claude/skills/audit-common/severity.md`
19
+ - `.claude/skills/audit-common/scope-boundaries.md`
20
+ - `.claude/skills/audit-common/accepted-tradeoffs.md`
21
+
22
+ ## Supported targets
23
+
24
+ | Target | Kind | Notes |
25
+ |---|---|---|
26
+ | PostgreSQL | technology + adapter | Primary target |
27
+ | CockroachDB | technology | PG wire protocol, **different semantics** |
28
+ | MySQL 8+ | technology | |
29
+ | `mysql2` | adapter | |
30
+ | Trilogy | adapter | MySQL-compatible, different adapter name |
31
+ | SQLite | technology + adapter | Weakest feature set; drives uniformity constraints |
32
+ | Oracle | technology + adapter | |
33
+
34
+ "Supported" means the feature works correctly, not that it fails loudly.
35
+ Silent divergence is worse than an exception.
36
+
37
+ **CI covers only postgres, mysql, trilogy, and sqlite**
38
+ (`.github/workflows/main.yml`). Oracle and CockroachDB are claimed with no
39
+ automated verification. Auditing that gap is this skill's job — see 3.8.
40
+
41
+ ## Step 1 — Inventory every divergence point
42
+
43
+ Enumerate and count:
44
+
45
+ 1. **Adapter conditionals** — every `adapter_name` reference in `lib/` and
46
+ `app/`, plus every `postgresql?` / `mysql?` helper call in the migration
47
+ templates.
48
+ 2. **Raw SQL** — every string passed to `where`, `select_value`, `execute`,
49
+ `lock`, `order`, or built by `DatabaseClock`.
50
+ 3. **Locking calls** — `lock!`, `with_lock`, `.lock(...)`, any `FOR UPDATE`
51
+ variant.
52
+ 4. **Upserts** — `upsert`, `upsert_all`, `insert_all`, `unique_by`,
53
+ `RecordNotUnique` rescues.
54
+ 5. **Migration DDL** — both template directories, especially column types,
55
+ partial indexes, and anything inside an adapter conditional.
56
+ 6. **Type usage** — UUID columns, JSON columns, boolean columns, text vs
57
+ string, timestamp precision.
58
+
59
+ ## Step 2 — The divergence matrix
60
+
61
+ Check every inventoried construct against this. This is the substance of the
62
+ audit — do not audit from memory of what databases support.
63
+
64
+ | Feature | PG | Cockroach | MySQL 8+ | SQLite | Oracle |
65
+ |---|---|---|---|---|---|
66
+ | `FOR UPDATE SKIP LOCKED` | Yes | **Parses, semantics differ** | Yes | **No row locks at all** | Yes |
67
+ | `FOR NO KEY UPDATE` | Yes | Yes | **No equivalent** | n/a | **No equivalent** |
68
+ | `FOR UPDATE NOWAIT` | Yes | Yes | Yes | n/a | Yes |
69
+ | `RETURNING` | Yes | Yes | **No** | 3.35+ | Via different syntax |
70
+ | Partial indexes (`WHERE`) | Yes | Yes | **No** | Yes | Via function-based index |
71
+ | `ON CONFLICT` target (`unique_by`) | Yes | Yes | **No** (`ON DUPLICATE KEY`, no target) | Yes | **No** (`MERGE`) |
72
+ | Native UUID type | Yes | Yes | **No** (string/binary) | **No** | **No** |
73
+ | Transactional DDL | Yes | Yes | **No** (implicit commit) | Yes | **No** |
74
+ | Default isolation | READ COMMITTED | **SERIALIZABLE** | **REPEATABLE READ** | SERIALIZABLE | READ COMMITTED |
75
+ | Advisory locks | Yes | **No** | Yes (`GET_LOCK`) | No | Via `DBMS_LOCK` |
76
+ | `LIMIT` in `UPDATE`/`DELETE` | **No** | No | Yes | Compile-flag | **No** |
77
+ | Identifier length | 63 | 63+ | 64 | Generous | **30** (128 in 12.2+) |
78
+ | Sub-second timestamps | Yes | Yes | Only if precision declared | Text-based | Yes |
79
+ | Boolean type | Native | Native | `TINYINT(1)` | Integer | **No** (`NUMBER(1)`) |
80
+
81
+ Two entries deserve special attention because they fail *silently*:
82
+
83
+ - **CockroachDB `SKIP LOCKED`.** It is accepted syntactically. Do not
84
+ conclude "it parses, therefore it works." Cockroach's serializable
85
+ isolation and contention handling mean claim behavior is not equivalent to
86
+ Postgres, and its retryable-transaction errors surface differently.
87
+ - **MySQL REPEATABLE READ.** A `SELECT` inside a transaction reads a
88
+ snapshot from the transaction's start, not from statement start. A
89
+ select-then-guarded-update CAS therefore behaves differently on MySQL than
90
+ on Postgres — the candidate read can be stale in a way READ COMMITTED
91
+ would not produce. Every CAS claim path needs checking against this.
92
+
93
+ ## Step 3 — Checklist
94
+
95
+ ### 3.1 Adapter detection consistency
96
+
97
+ The most likely source of real bugs. Every conditional must classify all six
98
+ targets correctly.
99
+
100
+ - **Does the regex cover Cockroach?** The Cockroach adapter reports
101
+ `CockroachDB`, which does **not** match `/postgresql/i`. A helper testing
102
+ `/postgresql/i` sends Cockroach down the non-Postgres branch, while
103
+ `/postgresql|cockroach/` sends it down the Postgres one. Both patterns
104
+ exist in this codebase — that inconsistency means Cockroach gets Postgres
105
+ locking modes but non-Postgres column types. Verify each site and report
106
+ every divergence.
107
+ - Does every regex cover both `mysql2` and `Trilogy`?
108
+ - Is `downcase` applied consistently, or does one site rely on case-sensitive
109
+ matching that a differently-cased adapter name would miss?
110
+ - Does every `case`/`if` chain over adapters have an `else` that **raises**
111
+ rather than silently falling through to a default meant for one adapter?
112
+ Compare against `DatabaseClock`, which raises `NotImplementedError` — that
113
+ is the pattern to hold others to.
114
+ - Is detection based on `adapter_name` where a **capability** check would be
115
+ more honest (e.g. SQLite version for `RETURNING`)?
116
+
117
+ ### 3.2 Locking and claiming
118
+
119
+ - Does each locking construct have a path for every adapter, including SQLite
120
+ where `lock!` is a no-op and row-level locking does not exist?
121
+ - Where SQLite has no row locks, what provides the guarantee instead — a CAS
122
+ predicate, or nothing? "SQLite is single-writer so it's fine" is only true
123
+ within one process.
124
+ - `FOR NO KEY UPDATE` exists on PG/Cockroach only. Is the fallback for
125
+ MySQL/Oracle correct, and is the deadlock consequence handled (retry) or
126
+ merely accepted?
127
+ - Are Cockroach's retryable serialization errors (`40001`) handled anywhere,
128
+ or would they surface as unhandled failures?
129
+
130
+ ### 3.3 Upsert and conflict handling
131
+
132
+ - `unique_by` requires a conflict target, which MySQL does not accept. Where
133
+ the code branches to `{}` for MySQL, does the resulting
134
+ `ON DUPLICATE KEY UPDATE` match against the intended unique index, or
135
+ against whatever unique key it happens to hit first? This is a silent
136
+ wrong-row hazard, not a syntax error.
137
+ - Oracle has neither; does anything reach it?
138
+ - Are `RecordNotUnique` rescues adapter-portable — does every adapter raise
139
+ the same ActiveRecord error class for a violated unique index?
140
+
141
+ ### 3.4 Types and identifiers
142
+
143
+ - UUID: PG gets native `uuid`, others get `string(36)`. Are joins and
144
+ comparisons type-consistent, and does Cockroach land in the right branch?
145
+ - Does any code assume the ID column is a native UUID?
146
+ - JSON columns: are JSON *operators* used anywhere, or only whole-value
147
+ read/write? Operators are not portable.
148
+ - Booleans: does any raw SQL compare against `TRUE`/`FALSE` literals?
149
+ - Are any generated identifiers at risk of Oracle's limit? Check the longest
150
+ index and constraint names against the `ductwork_*` table names.
151
+
152
+ ### 3.5 Migrations
153
+
154
+ - Does every migration run correctly on all six, including the adapter
155
+ conditionals?
156
+ - Non-transactional DDL on MySQL and Oracle means a migration that fails
157
+ partway **cannot roll back**. Are multi-statement migrations written so a
158
+ partial application is recoverable?
159
+ - Is the `up`/`down` (or `change`) reversible on every adapter?
160
+ - Does any migration reference a model class? That breaks when the model
161
+ changes later — flag regardless of adapter.
162
+ - Are the install and update templates consistent with each other per
163
+ adapter, or does one branch where the other doesn't?
164
+
165
+ ### 3.6 Raw SQL construction
166
+
167
+ - Every raw fragment: is the syntax valid on all six, or gated?
168
+ - Interpolated values — are they bound parameters or string-interpolated? An
169
+ interpolated integer is portable but still worth flagging if it is ever
170
+ attacker-influenced.
171
+ - Are quoting and identifier-escaping done through the connection's quoting
172
+ methods rather than hardcoded backticks or double quotes? Backticks are
173
+ MySQL-only; double quotes mean identifiers on PG and can mean strings
174
+ elsewhere.
175
+ - Are functions used that do not exist everywhere (`julianday`,
176
+ `clock_timestamp`, `NUMTODSINTERVAL`, `strftime`, `NOW()`, `GREATEST`)?
177
+
178
+ ### 3.7 ActiveRecord behavior that differs underneath
179
+
180
+ - Does anything rely on the ordering of an unordered query? Only PG's heap
181
+ ordering makes this appear to work.
182
+ - `update_all` / `delete_all` with `LIMIT` — not portable to PG or Oracle.
183
+ - Does any code rely on `insert_all` returning IDs? That needs `RETURNING`.
184
+ - Does anything depend on autoincrement semantics or last-insert-id?
185
+
186
+ ### 3.8 Claimed support without verification
187
+
188
+ For each of the six targets, determine and report:
189
+
190
+ - Is it exercised in CI? Name the workflow job, or state that none exists.
191
+ - Is there any spec that exercises the adapter-specific branches written for
192
+ it?
193
+ - Are there code paths written for it that no test covers?
194
+
195
+ **Oracle and CockroachDB currently have no CI coverage.** Report this once,
196
+ as a single finding, with the specific untested branches enumerated —
197
+ `DatabaseClock`'s Oracle SQL, `run.rb`'s Cockroach locking branch, and any
198
+ others found in Step 1. Do not file it separately per branch.
199
+
200
+ Grade by consequence: an untested branch that would raise on first use is
201
+ High; one that merely lacks a regression test is Medium.
202
+
203
+ ## Step 4 — Verify and report
204
+
205
+ Follow `method.md`. Additionally, every finding must state:
206
+
207
+ 1. **Which adapters are affected** and which are fine.
208
+ 2. **The failure mode** — does it raise, silently return wrong results, or
209
+ silently degrade? Silent wrongness outranks a raise; say which it is.
210
+ 3. **Whether the code is reachable** on that adapter, or dead there.
211
+ 4. **The portable fix**, or an explicitly gated one covering every target.
212
+
213
+ Do not report a divergence the code already handles correctly — check for an
214
+ existing adapter conditional before filing. Per `method.md`, dedupe to the
215
+ deepest owning layer: if one helper is wrong for Cockroach, that is one
216
+ finding listing its call sites, not one finding per call site.
@@ -1,17 +1,234 @@
1
1
  ---
2
2
  name: audit-durability
3
- description: Audit ductwork for durability gaps
4
- allowed-tools: Read, Grep, Glob
3
+ description: Audit the OSS ductwork codebase for durability gaps — crash windows where a process or thread death leaks a claim, loses work, or stalls a pipeline; incorrect transaction boundaries; incomplete claim fencing; reaper races; error-routing mistakes; and liveness holes where a run can never reach a terminal state. Use when the user asks to audit durability, crash safety, reliability, at-least-once behavior, reap correctness, claim leaks, stuck pipelines, or "what happens if this crashes here".
4
+ allowed-tools: Read, Grep, Glob, Bash
5
5
  ---
6
6
 
7
- # Durability Audit Gap
7
+ # Durability Audit
8
8
 
9
- Audit the entire OSS ductwork codebase for durability gaps.
9
+ Audit the OSS ductwork codebase for durability gaps.
10
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
11
+ Bash is available for **read-only** inspection (`grep`, `git log`, `git
12
+ blame`, listing files). Do not modify any file, do not run the test suite,
13
+ and do not run migrations.
16
14
 
17
- For each finding: file:line, severity, why it matters, suggested fix. Do not modify files.
15
+ ## Step 0 Load shared context
16
+
17
+ Read these first. They are not optional; they define what counts as a
18
+ finding.
19
+
20
+ - `.claude/skills/audit-common/method.md` — inventory, verification, output
21
+ - `.claude/skills/audit-common/severity.md` — how to grade
22
+ - `.claude/skills/audit-common/scope-boundaries.md` — OSS/Pro line, layout
23
+ - `.claude/skills/audit-common/accepted-tradeoffs.md` — do not re-report
24
+
25
+ **The Pro boundary matters most in this audit.** Step timeouts, interruptible
26
+ advancement, and restarting threads stuck *inside job execution* are Pro
27
+ features. They are the natural-sounding fix for several things you will find.
28
+ They are out of scope. See `scope-boundaries.md`.
29
+
30
+ ## Step 1 — Inventory the durability surface
31
+
32
+ Enumerate and count each of these before analyzing. State the counts in the
33
+ report.
34
+
35
+ 1. **Claim paths** — `lib/ductwork/branch_claim.rb`,
36
+ `execution_claim.rb`, `row_locking_execution_claim.rb`,
37
+ `optimistic_locking_execution_claim.rb`.
38
+ 2. **Multi-step state mutations** — every method that writes more than one
39
+ row, or writes a row and then acts on it. Concentrated in
40
+ `lib/ductwork/models/` (`branch.rb`, `advancement.rb`, `execution.rb`,
41
+ `job.rb`, `run.rb`, `step.rb`, `process.rb`).
42
+ 3. **Transaction boundaries** — every `transaction do`, `after_commit`,
43
+ `lock!`, and `with_lock`.
44
+ 4. **Rescue and ensure sites** — every `rescue` and `ensure` in `lib/`.
45
+ 5. **Process and thread lifecycle** — all of `lib/ductwork/processes/`:
46
+ supervisors, runners, `job_worker.rb`, `pipeline_advancer.rb`, and their
47
+ start/restart/kill/shutdown paths.
48
+ 6. **Heartbeat and reap paths** — `Process.report_heartbeat!`,
49
+ `Process#reap!`, and each `reap_process_record!` across the runners and
50
+ supervisors.
51
+ 7. **Existing fault-injection checkpoints** — `grep -rn
52
+ "FaultInjection.checkpoint" lib/` and the specs in
53
+ `spec/integration/durability/`.
54
+
55
+ ## Step 2 — Crash-window analysis (the core of this audit)
56
+
57
+ This is the primary technique. Do this before the checklist in Step 3.
58
+
59
+ For every multi-step operation on the inventory, enumerate the points between
60
+ its steps and answer, at each point:
61
+
62
+ > If the process is SIGKILLed here — or the thread is killed here, or the
63
+ > database connection drops here — what state is left behind, and what
64
+ > recovers it?
65
+
66
+ For each window, name the recovery mechanism explicitly:
67
+
68
+ - An `ensure` block (does it actually run for this kind of death? `ensure`
69
+ runs on `Thread#kill` and `Interrupt`; it does **not** run on `SIGKILL`,
70
+ `exit!`, or a segfault)
71
+ - Transaction rollback (only if the writes are genuinely in one transaction)
72
+ - The heartbeat reaper (recovers on the *next sweep* — note the latency)
73
+ - A CAS predicate that makes a stale write a no-op
74
+ - The claim fence token (`Branch#claim_fence_token`, checked in
75
+ `Branch#release!`)
76
+ - `BranchClaim#fail_abandoned_advancement`, reactively on next claim
77
+ - Nothing — **this is the finding**
78
+
79
+ A window whose only recovery is "the reaper eventually" is not automatically
80
+ a bug; that is the design for process death. It *is* a bug when the reaper
81
+ cannot see the leaked state, when recovery requires the global timeout for
82
+ something that should be caught promptly, or when the leaked state blocks
83
+ other work in the meantime.
84
+
85
+ Report the specific window, not the general concern:
86
+
87
+ > Between the `UPDATE` at `branch_claim.rb:NN` and the advancement `INSERT`
88
+ > at `:MM`, a SIGKILL leaves the branch claimed with no advancement row.
89
+ > Recovery is X, which takes Y.
90
+
91
+ ## Step 3 — Checklist
92
+
93
+ Work these in order. For each, cite file:line or state that the category came
94
+ back clean.
95
+
96
+ ### 3.1 Write ordering and observable side effects
97
+
98
+ - Is state written durably **before** the action it authorizes, or after?
99
+ - Does anything become visible to another process before the transaction that
100
+ makes it correct has committed?
101
+ - Are results written before the record that says the work is done?
102
+ - Is a job enqueued or a branch made claimable inside a transaction that can
103
+ still roll back?
104
+
105
+ ### 3.2 Transaction boundaries
106
+
107
+ - Does each `transaction do` span exactly the writes that must be atomic —
108
+ no more, no less?
109
+ - Are there nested transactions where an inner rollback silently becomes a
110
+ savepoint rollback, leaving the outer one committed?
111
+ - Is there external or long-running work (a network call, user code, a sleep)
112
+ inside a transaction, holding locks?
113
+ - Does anything rely on `after_commit` ordering that is not guaranteed?
114
+ - Does a `lock!` happen *before* the read whose value it protects, or after
115
+ (a lock taken after reading protects nothing — check for a `reload`)?
116
+
117
+ ### 3.3 Claim integrity and fencing
118
+
119
+ - **The re-assertion rule:** whatever the candidate `SELECT` filters on, the
120
+ claiming `UPDATE` must re-assert. A divergence between the two predicates
121
+ is a real finding. `Ductwork::Step::ADVANCEABLE_STATUSES` is the shared
122
+ constant; check both sides still use it.
123
+ - Is `rows_updated` checked after every CAS `UPDATE`, and is losing the CAS
124
+ handled as a normal outcome rather than an error?
125
+ - Can a claim be released by someone who no longer owns it — is the fence
126
+ token compared on every release path?
127
+ - Can the same unit of work be claimed twice concurrently?
128
+ - Is `process_id` (or the fence token) verified before a terminal write, or
129
+ is the write blind?
130
+
131
+ ### 3.4 Reaper correctness
132
+
133
+ - Can the reaper release a claim that a healthy worker legitimately re-took
134
+ between the staleness check and the release? (Race 1 — active concern.)
135
+ - Does every reap path go through the same guarded release, or does one
136
+ open-code it?
137
+ - Is the heartbeat written on a schedule that cannot be starved by the work
138
+ loop itself — can a long unit of work delay the heartbeat past the timeout
139
+ and cause a self-reap?
140
+ - Are reap sweeps idempotent if two supervisors sweep concurrently?
141
+ - Are crash/recovery counters incremented exactly once per event?
142
+
143
+ Race 2 (zombie worker overwriting reaper state) is deferred by decision — see
144
+ `accepted-tradeoffs.md`.
145
+
146
+ ### 3.5 Thread and process lifecycle
147
+
148
+ - Does every thread body have an outer `ensure` that abandons in-flight work?
149
+ - Does thread *restart* clean up what the dead thread held, for deaths that
150
+ bypass `ensure`?
151
+ - Is `restart` genuinely distinct from `start`, or aliased such that cleanup
152
+ is skipped?
153
+ - On SIGTERM, is there a drain path — does in-flight work finish or get
154
+ cleanly abandoned, or is it simply dropped?
155
+ - Does a `thread.kill` risk interrupting a query mid-flight and poisoning the
156
+ connection? (Known open for `PipelineAdvancer#kill` — new information only.)
157
+ - If the supervisor restarts, does the sweep catch records orphaned by the
158
+ children it lost?
159
+
160
+ ### 3.6 Error routing and rescue layering
161
+
162
+ The three legitimate layers, each owning exactly one semantic class:
163
+
164
+ | Layer | Owns |
165
+ |---|---|
166
+ | `Job#execute` | user code raising → errored (retry) |
167
+ | `JobWorker#work_loop` | anything else raising → crashed (keep thread alive) |
168
+ | `Branch.with_latest_claimed` | token-conditional release on any exit |
169
+
170
+ - Does any `rescue` extend past its layer's boundary call to cover framework
171
+ bookkeeping? A DB error while *recording success* must not route to the
172
+ user-code-failed path — it belongs to the outer backstop.
173
+ - Can one logical failure produce two retry records by passing through two
174
+ layers?
175
+ - Does any `rescue` swallow an error without recording it, leaving a claim
176
+ held and a record neither completed nor failed?
177
+ - Are retry and crash counters attributed to the right layer?
178
+
179
+ ### 3.7 Liveness and terminal-state resolution
180
+
181
+ - Can a run sit `in_progress` forever with no work left? Check fan-in
182
+ (`combine`, `collapse`, `converge`) where the last sibling to finish must
183
+ trigger resolution.
184
+ - Is `resolve_terminal_state!` reached on **both** the halt path and the
185
+ completion path?
186
+ - Are terminal transitions guarded by a run lock plus an already-terminal
187
+ check, so a completed run cannot be overwritten as halted or vice versa?
188
+ - Can a fan-in wait on a sibling that will never reach a terminal state?
189
+ - Do claims exclude halted and completed runs, so orphaned work cannot
190
+ resurrect a finished pipeline?
191
+ - Is every `halt_reason` path reachable, and does each caller put the step in
192
+ its correct terminal state *before* halting?
193
+
194
+ ### 3.8 Connection and database failure
195
+
196
+ - What happens if the connection drops mid-transaction — is the in-memory
197
+ record state now a lie about what is committed?
198
+ - Does a failed heartbeat write retry, or silently pass and let the process
199
+ be reaped while healthy?
200
+ - Are claim queries safe to retry, or would a retry after an ambiguous
201
+ timeout double-claim?
202
+
203
+ ## Step 4 — Fault-injection coverage cross-check
204
+
205
+ Ductwork has a named-checkpoint fault harness
206
+ (`lib/ductwork/fault_injection.rb`, driven by `DUCTWORK_FAULT`, supporting
207
+ `kill`, `raise`, `sleep`, `exit`), with crash-window specs in
208
+ `spec/integration/durability/`.
209
+
210
+ Cross-reference the crash windows found in Step 2 against the checkpoints
211
+ that exist and the specs that exercise them. Report:
212
+
213
+ - **Windows with no checkpoint** — a durability-critical window that cannot
214
+ currently be tested. Recommend the checkpoint name and where it goes.
215
+ - **Checkpoints with no spec** — injection points nothing exercises.
216
+ - **Windows covered only for one failure mode** — e.g. a `raise` spec but no
217
+ `kill` spec, when `ensure` behavior differs between them. This distinction
218
+ is the whole point of the harness.
219
+
220
+ Coverage gaps are usually Medium — real, but not themselves a live bug.
221
+ Grade Higher only when the uncovered window is one you independently found a
222
+ correctness problem in.
223
+
224
+ ## Step 5 — Verify and report
225
+
226
+ Follow `method.md`: re-read every cited `file:line`, drop what does not hold
227
+ or is already accepted, deduplicate to the deepest owning layer, and report
228
+ inline in the format specified there.
229
+
230
+ Findings in this audit must name the interleaving. "This is not locked" is
231
+ not a finding; "advancer A passes the check at :NN while advancer B is
232
+ between :MM and :QQ, so both create an advancement and the step runs twice"
233
+ is a finding. Per `severity.md`, a finding with no describable interleaving
234
+ caps at Medium.