Skip to content

Commit 8b391c7

Browse files
authored
feat: preflight migration review / DDL risk analyzer (#100)
Deterministic DDL risk analyzer (MCP + API) with fail-closed parsing and measured Postgres lock rules.
1 parent 89e8018 commit 8b391c7

34 files changed

Lines changed: 3687 additions & 9 deletions

CLAUDE.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,84 @@ than an `assertCan*` call — the case `Endpoint Authorization Rules` describes
412412
- Usage belongs to `QueryActorContextHolder` first and the security principal second, so
413413
under **View as** the spend is attributed to the target user, not the admin.
414414

415+
## Pre-flight Migration Review
416+
417+
`POST /migrations/analyze` (`MigrationRiskController``MigrationRiskService`
418+
`DatabaseDialect.migrationRisk()`) classifies a DDL statement's blast radius —
419+
verdict, locks (per table), whether it rewrites the table, a coarse duration bucket,
420+
and a safer alternative — before anyone runs it. It is **deterministic by
421+
convention**, same as `IndexAdvisorService` and `ExplainPlanService`: the rule table
422+
and the target table's real size (`pg_class.reltuples` / `pg_total_relation_size`)
423+
decide the verdict; nothing here asks an LLM to judge risk. An LLM may narrate the
424+
report in chat, but it never computes it.
425+
426+
- **The Postgres rule table is engine-verified, not hand-derived.** A Testcontainers
427+
suite runs each rule's DDL against a real `postgres:18`, reading
428+
`pg_relation_filenode` before/after to detect an actual rewrite and `pg_locks` to
429+
read the actual lock mode taken — not PostgreSQL documentation summarized from
430+
memory. If a rule disagrees with what the engine measured, the rule is wrong,
431+
full stop.
432+
- **`DEFAULT now()` does NOT force a table rewrite — this is measured, not a bug.**
433+
`now()` is STABLE (it returns the same value for the whole transaction), and
434+
Postgres 11+ adds a column with a STABLE or constant default as a metadata-only
435+
operation. Only a VOLATILE default (`random()`, `gen_random_uuid()`,
436+
`clock_timestamp()`, `uuid_generate_v4()`) forces a full rewrite, because a
437+
volatile function must be evaluated per row. Wrote this down after getting it
438+
wrong from memory twice before the Testcontainers run corrected it.
439+
- **`ADD FOREIGN KEY` takes `ShareRowExclusiveLock` on the referenced table too**,
440+
not just the table being altered — confirmed live: `ALTER TABLE child ADD
441+
CONSTRAINT fk FOREIGN KEY (t_id) REFERENCES t(id)` returns a `locks` array with
442+
two entries, `child` and `t`. This is why `MigrationRiskReport.locks` is a
443+
per-table array rather than a single lock on the altered table — a statement can
444+
block writes on a table it never names, and that is the finding an operator is
445+
least likely to expect from reading the SQL alone.
446+
- **The JSqlParser 5.2 shim exists because the library cannot parse the forms this
447+
tool exists to recommend.** JSqlParser has no grammar for `NOT VALID` or `CREATE
448+
INDEX CONCURRENTLY` — both fail to parse outright, which would make the analyzer
449+
unable to evaluate the very migration pattern (`ADD CONSTRAINT ... NOT VALID` +
450+
`VALIDATE CONSTRAINT`, `CREATE INDEX CONCURRENTLY`) it recommends as the safer
451+
alternative. `DdlStatementParser` pre-strips both into flags (`notValid`,
452+
`concurrently`) before handing the rest to JSqlParser. Do not remove this step —
453+
removing it silently turns every NOT VALID / CONCURRENTLY statement into a parse
454+
failure, which fails closed (UNKNOWN) but defeats the point of the feature for
455+
exactly the statements it is meant to bless as safe.
456+
- **Fail-closed, not best-effort.** Unparseable SQL, an ALTER with more than one
457+
clause (a single `DdlFacts` cannot honestly represent two clauses' worth of risk),
458+
an unrecognised default function DeepSQL cannot confirm the volatility of, and
459+
MySQL (no verified rule table exists yet — `MySQLMigrationRiskProvider` always
460+
returns `UNKNOWN`) all report `UNKNOWN` or `CAUTION` rather than guessing `SAFE`.
461+
Verified live: `{"sql":"not sql"}` returns `verdict: UNKNOWN`, `safeToRun: false`.
462+
- **Known limitation: `ALTER COLUMN TYPE` over-warns.** `DdlFacts` carries no old
463+
column type, only the new one, so the provider cannot tell a same-family widening
464+
(`varchar(50)``varchar(100)`, which Postgres does NOT rewrite) from a genuine
465+
type change (which does). It reports the conservative rewrite verdict for both
466+
rather than risk a false SAFE.
467+
- **The verification test ranks lock strength by an explicit ordering list, not
468+
`max(mode)` — the provider itself does not rank at all.**
469+
`PostgresMigrationRiskProvider` never computes a lock mode; each rule hardcodes
470+
the one it asserts (e.g. `addForeignKey` always reports `ShareRowExclusiveLock`).
471+
The ranking lives only in `PostgresMigrationRiskVerificationTest`
472+
(`LOCK_STRENGTH` + `strongestLock()`), which has to pick the strongest lock out
473+
of however many rows a query against the real `pg_locks` returns. It needs the
474+
ordering because Postgres lock mode names sort alphabetically in a way that has
475+
nothing to do with strength — `"ShareLock".compareTo("AccessExclusiveLock") > 0`,
476+
so a naive string-max would call `ShareLock` the stronger of the two, when
477+
`AccessExclusiveLock` is in fact the most exclusive mode Postgres has. Any future
478+
code that needs "the worst lock a query is holding" from `pg_locks` must rank
479+
against Postgres's real lock hierarchy, not compare mode names — but that need
480+
has not yet reached production code, only this test.
481+
- **Authorization is asserted in the service, before parsing, credential
482+
decryption, or session opening** (`MigrationRiskService.analyze` calls
483+
`accessControlService.assertCanReadConnectionContent` first). The controller is
484+
exempted from the static `ConnectionScopedAuthorizationSafetyTest` sweep
485+
(`AUTHORIZED_ELSEWHERE`) on that basis, same reasoning as
486+
`DashboardWorkspaceController`. Verified live, not just by the mocked unit test
487+
and the static scanner: a second user with zero grants on the target connection
488+
(confirmed empty in `connection_access_grant`) received a genuine `403` from
489+
`POST /migrations/analyze`, with no stack trace in the logs — proof the
490+
controller's `catch (ResponseStatusException e) { throw e; }` before the
491+
catch-all is live and not swallowing the 403 into a 500.
492+
415493
## Key Rules & Patterns
416494

417495
### Backend Rules

backend/pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,18 @@
356356
<version>4.2.2</version>
357357
<scope>test</scope>
358358
</dependency>
359+
<dependency>
360+
<groupId>org.testcontainers</groupId>
361+
<artifactId>postgresql</artifactId>
362+
<version>1.20.4</version>
363+
<scope>test</scope>
364+
</dependency>
365+
<dependency>
366+
<groupId>org.testcontainers</groupId>
367+
<artifactId>junit-jupiter</artifactId>
368+
<version>1.20.4</version>
369+
<scope>test</scope>
370+
</dependency>
359371
</dependencies>
360372

361373
<build>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.dbaagent.controller;
2+
3+
import com.dbaagent.dto.MigrationRiskReport;
4+
import com.dbaagent.service.migration.MigrationRiskService;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.http.ResponseEntity;
7+
import org.springframework.web.bind.annotation.*;
8+
import org.springframework.web.server.ResponseStatusException;
9+
10+
import java.util.Map;
11+
12+
@RestController
13+
@RequestMapping("/migrations")
14+
@RequiredArgsConstructor
15+
public class MigrationRiskController {
16+
17+
private final MigrationRiskService migrationRiskService;
18+
19+
@PostMapping("/analyze")
20+
public ResponseEntity<?> analyze(@RequestBody Map<String, String> body) {
21+
String connectionId = body.get("connectionId");
22+
String sql = body.get("sql");
23+
if (connectionId == null || connectionId.isBlank() || sql == null || sql.isBlank()) {
24+
return ResponseEntity.badRequest().body(Map.of("message", "connectionId and sql are required"));
25+
}
26+
try {
27+
// MigrationRiskService.analyze asserts assertCanReadConnectionContent before
28+
// parsing, credential decryption or session opening — see AUTHORIZED_ELSEWHERE
29+
// in ConnectionScopedAuthorizationSafetyTest.
30+
MigrationRiskReport report = migrationRiskService.analyze(connectionId, sql);
31+
return ResponseEntity.ok(report);
32+
} catch (ResponseStatusException e) {
33+
throw e; // preserve 403/404 — a catch-all below would report it as a 500
34+
} catch (Exception e) {
35+
return ResponseEntity.internalServerError()
36+
.body(Map.of("message", "Migration analysis failed: " + e.getMessage()));
37+
}
38+
}
39+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.dbaagent.dto;
2+
3+
import java.util.List;
4+
5+
public record MigrationRiskReport(
6+
String dialect,
7+
String verdict, // SAFE | CAUTION | DANGER | FAILS | UNKNOWN
8+
boolean safeToRun,
9+
boolean dialectSupported,
10+
String operation,
11+
String table,
12+
List<LockRef> locks, // per table — a statement can lock tables it does not name
13+
boolean rewritesTable,
14+
long tableRows,
15+
long tableSizeBytes,
16+
String estimatedDuration, // coarse bucket, never a number
17+
String reason,
18+
String saferAlternative,
19+
String docsUrl,
20+
String confidence) {
21+
22+
public record LockRef(String table, String mode, List<String> blocks) {}
23+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.dbaagent.dto;
2+
3+
public record TableFacts(long rowEstimate, long sizeBytes, boolean empty) {}

backend/src/main/java/com/dbaagent/provider/api/DatabaseDialect.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,10 @@ public interface DatabaseDialect {
9393
* @return The sampling provider
9494
*/
9595
SamplingProvider sampling();
96+
97+
/**
98+
* Get the migration risk provider for this database.
99+
* @return The migration risk provider
100+
*/
101+
MigrationRiskProvider migrationRisk();
96102
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.dbaagent.provider.api;
2+
3+
import com.dbaagent.dto.MigrationRiskReport;
4+
import com.dbaagent.dto.TableFacts;
5+
import com.dbaagent.service.migration.DdlFacts;
6+
7+
public interface MigrationRiskProvider {
8+
MigrationRiskReport classify(DdlFacts facts, TableFacts table);
9+
}

backend/src/main/java/com/dbaagent/provider/mysql/MySQLDialect.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class MySQLDialect implements DatabaseDialect {
2828
private final MySQLQueryExecutionProvider queryExecutionProvider;
2929
private final MySQLPrivilegeCheckProvider privilegeCheckProvider;
3030
private final MySQLSamplingProvider samplingProvider;
31+
private final MySQLMigrationRiskProvider migrationRiskProvider;
3132

3233
@Override
3334
public String getCanonicalName() {
@@ -93,4 +94,9 @@ public PrivilegeCheckProvider privileges() {
9394
public SamplingProvider sampling() {
9495
return samplingProvider;
9596
}
97+
98+
@Override
99+
public MigrationRiskProvider migrationRisk() {
100+
return migrationRiskProvider;
101+
}
96102
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.dbaagent.provider.mysql;
2+
3+
import com.dbaagent.dto.MigrationRiskReport;
4+
import com.dbaagent.dto.TableFacts;
5+
import com.dbaagent.provider.api.MigrationRiskProvider;
6+
import com.dbaagent.service.migration.DdlFacts;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.util.List;
10+
11+
/**
12+
* MySQL migration risk is not implemented. Returning UNKNOWN is deliberate: MySQL's
13+
* online-DDL matrix varies by version, storage engine and ALGORITHM/LOCK clause, and a
14+
* confident wrong answer about a lock is worse than no answer.
15+
*/
16+
@Component
17+
public class MySQLMigrationRiskProvider implements MigrationRiskProvider {
18+
19+
@Override
20+
public MigrationRiskReport classify(DdlFacts facts, TableFacts table) {
21+
return new MigrationRiskReport("mysql", "UNKNOWN", false, false,
22+
facts.operation().name(), facts.table(), List.of(), false,
23+
table.rowEstimate(), table.sizeBytes(), "unknown",
24+
"DeepSQL does not yet have verified MySQL DDL rules. Review this by hand.",
25+
null, "https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html",
26+
"unverified");
27+
}
28+
}

backend/src/main/java/com/dbaagent/provider/postgres/PostgresDialect.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class PostgresDialect implements DatabaseDialect {
2828
private final PostgresQueryExecutionProvider queryExecutionProvider;
2929
private final PostgresPrivilegeCheckProvider privilegeCheckProvider;
3030
private final PostgresSamplingProvider samplingProvider;
31+
private final PostgresMigrationRiskProvider migrationRiskProvider;
3132

3233
@Override
3334
public String getCanonicalName() {
@@ -93,4 +94,9 @@ public PrivilegeCheckProvider privileges() {
9394
public SamplingProvider sampling() {
9495
return samplingProvider;
9596
}
97+
98+
@Override
99+
public MigrationRiskProvider migrationRisk() {
100+
return migrationRiskProvider;
101+
}
96102
}

0 commit comments

Comments
 (0)