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.
- 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 +12 -1
- data/CHANGELOG.md +26 -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/cli.rb +9 -11
- 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 +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
|
-
#
|
|
7
|
+
# Database Index Audit
|
|
8
8
|
|
|
9
|
-
Audit the
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
7
|
+
# Database Support Audit
|
|
8
8
|
|
|
9
|
-
Audit the
|
|
9
|
+
Audit the OSS ductwork codebase for code and queries that do not work
|
|
10
|
+
correctly on every claimed-supported database.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
7
|
+
# Durability Audit
|
|
8
8
|
|
|
9
|
-
Audit the
|
|
9
|
+
Audit the OSS ductwork codebase for durability gaps.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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.
|