From 375e0fb6735d072bfc7137ab2f8b4186e0a9a333 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 09:23:40 +0900 Subject: [PATCH 1/2] feat(health): circular foreign-key dependency detection Independent feature against main. detect_fk_cycles(snapshot) finds FK cycles (A->B->...->A) via iterative Tarjan SCC: multi-table cycles = warning (no create/drop/load order works without deferring constraints), self-references = info (hierarchical, benign). Iterative so a deep FK chain can't blow the recursion limit. GET /api/snapshots/{uuid}/fk-cycles, IDOR-safe. +5 tests (incl. 1500-node chain). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AxU2xaupAjp912oDNFuWyd --- backend/app/api/snapshots.py | 31 +++++++- backend/app/schemas.py | 8 ++ backend/app/spec/fk_cycles.py | 129 ++++++++++++++++++++++++++++++++ backend/tests/test_fk_cycles.py | 50 +++++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 backend/app/spec/fk_cycles.py create mode 100644 backend/tests/test_fk_cycles.py diff --git a/backend/app/api/snapshots.py b/backend/app/api/snapshots.py index 79f2515f..18dcefaa 100644 --- a/backend/app/api/snapshots.py +++ b/backend/app/api/snapshots.py @@ -17,8 +17,14 @@ SchemaSnapshotData, ) from app.permissions import require_project_member -from app.schemas import SnapshotCreateIn, SnapshotDetailOut, SnapshotOut +from app.schemas import ( + FkCyclesOut, + SnapshotCreateIn, + SnapshotDetailOut, + SnapshotOut, +) from app.ddl.export import snapshot_json_to_sql +from app.spec.fk_cycles import detect_fk_cycles from app.jobs.valkey_queue import enqueue_job_signal from app.spec.llm import ( LlmConfigurationError, @@ -161,6 +167,29 @@ async def export_snapshot_sql( return snapshot_json_to_sql(data.snapshot_json, target_dialect=dialect) +@router.get("/{schema_snapshot_uuid}/fk-cycles", response_model=FkCyclesOut) +async def fk_cycles( + schema_snapshot_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> FkCyclesOut: + """Report circular foreign-key dependencies (migration-ordering hazards). + + Multi-table cycles are warnings; self-references are informational. + IDOR-safe (uniform not-found for missing/unauthorized snapshots). + """ + snap = await _get_authorized_snapshot(session, schema_snapshot_uuid, user) + if snap is None: + return FkCyclesOut( + schema_snapshot_uuid=schema_snapshot_uuid, status="not_found", report=None + ) + data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) + report = detect_fk_cycles(data.snapshot_json if data else None) + return FkCyclesOut( + schema_snapshot_uuid=schema_snapshot_uuid, status="ok", report=report + ) + + @router.get( "/{schema_snapshot_uuid}/reversing-spec.md", response_class=PlainTextResponse, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0359799c..534ef937 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -91,6 +91,14 @@ class SnapshotDetailOut(BaseModel): snapshot_json: dict | None +class FkCyclesOut(BaseModel): + """Circular foreign-key dependency findings for a snapshot.""" + + schema_snapshot_uuid: uuid.UUID + status: str + report: dict | None + + class MeOut(BaseModel): """Current user payload returned by /me.""" diff --git a/backend/app/spec/fk_cycles.py b/backend/app/spec/fk_cycles.py new file mode 100644 index 00000000..03788598 --- /dev/null +++ b/backend/app/spec/fk_cycles.py @@ -0,0 +1,129 @@ +"""Detect circular foreign-key dependencies in a snapshot. + +A cycle in the FK graph (A → B → … → A) means there's no order in which the +tables can be created, dropped, or bulk-loaded without deferring constraints -- +a real migration hazard. Self-references (``employee.manager_id → employee``) +are a normal, benign special case and are reported separately. + +Pure and dialect-agnostic. Uses an iterative Tarjan SCC so a deep FK chain can't +blow the recursion limit. +""" + +from __future__ import annotations + +from typing import Any + +WARNING = "warning" +INFO = "info" + + +def _sccs(nodes: list[str], adj: dict[str, set[str]]) -> list[list[str]]: + """Tarjan's strongly-connected components (iterative).""" + index: dict[str, int] = {} + low: dict[str, int] = {} + on_stack: set[str] = set() + stack: list[str] = [] + result: list[list[str]] = [] + counter = 0 + + for root in nodes: + if root in index: + continue + index[root] = low[root] = counter + counter += 1 + stack.append(root) + on_stack.add(root) + work = [(root, iter(sorted(adj.get(root, set()))))] + while work: + v, it = work[-1] + pushed = False + for w in it: + if w not in index: + index[w] = low[w] = counter + counter += 1 + stack.append(w) + on_stack.add(w) + work.append((w, iter(sorted(adj.get(w, set()))))) + pushed = True + break + if w in on_stack: + low[v] = min(low[v], index[w]) + if pushed: + continue + if low[v] == index[v]: + comp = [] + while True: + w = stack.pop() + on_stack.discard(w) + comp.append(w) + if w == v: + break + result.append(comp) + work.pop() + if work: + low[work[-1][0]] = min(low[work[-1][0]], low[v]) + return result + + +def detect_fk_cycles(snapshot: dict[str, Any] | None) -> dict[str, Any]: + """Return circular-FK findings (multi-table cycles + self-references).""" + snapshot = snapshot or {} + relations = snapshot.get("relations") or [] + fk_edges = snapshot.get("fk_edges") or [] + + def _qname(oid: Any) -> str | None: + rel = rel_by_oid.get(oid) + if rel is None: + return None + return f"{rel.get('schema_name')}.{rel.get('relation_name')}" + + rel_by_oid = {r.get("relation_oid"): r for r in relations} + + adj: dict[str, set[str]] = {} + nodes: set[str] = set() + self_refs: set[str] = set() + for edge in fk_edges: + child = _qname(edge.get("child_relation_oid")) + parent = _qname(edge.get("parent_relation_oid")) + if child is None or parent is None: + continue + nodes.add(child) + nodes.add(parent) + if child == parent: + self_refs.add(child) + else: + adj.setdefault(child, set()).add(parent) + + items: list[dict[str, Any]] = [] + for comp in _sccs(sorted(nodes), adj): + if len(comp) >= 2: + tables = sorted(comp) + items.append( + { + "category": "circular_dependency", + "severity": WARNING, + "tables": tables, + "detail": ( + "Circular foreign-key dependency: " + + " → ".join(tables) + + " — no create/drop/load order works without deferring constraints." + ), + } + ) + for table in sorted(self_refs): + items.append( + { + "category": "self_reference", + "severity": INFO, + "tables": [table], + "detail": f"{table} references itself (hierarchical/tree) — usually fine; load roots first.", + } + ) + + items.sort(key=lambda i: (0 if i["severity"] == WARNING else 1, i["tables"])) + summary = { + "circular_dependencies": sum(1 for i in items if i["category"] == "circular_dependency"), + "self_references": len(self_refs), + "total": len(items), + } + return {"items": items, "summary": summary} diff --git a/backend/tests/test_fk_cycles.py b/backend/tests/test_fk_cycles.py new file mode 100644 index 00000000..5298bff0 --- /dev/null +++ b/backend/tests/test_fk_cycles.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from app.spec.fk_cycles import detect_fk_cycles + + +def _snap(edges, names=None): + names = names or sorted({e[0] for e in edges} | {e[1] for e in edges}) + oid = {n: i + 1 for i, n in enumerate(names)} + return { + "relations": [{"relation_oid": oid[n], "schema_name": "public", "relation_name": n} for n in names], + "fk_edges": [ + {"child_relation_oid": oid[c], "parent_relation_oid": oid[p], "child_column_name": "x", "parent_column_name": "y"} + for c, p in edges + ], + } + + +def test_detects_multi_table_cycle(): + # a -> b -> c -> a + report = detect_fk_cycles(_snap([("a", "b"), ("b", "c"), ("c", "a")])) + assert report["summary"]["circular_dependencies"] == 1 + cyc = next(i for i in report["items"] if i["category"] == "circular_dependency") + assert set(cyc["tables"]) == {"public.a", "public.b", "public.c"} + assert cyc["severity"] == "warning" + + +def test_two_node_cycle(): + report = detect_fk_cycles(_snap([("a", "b"), ("b", "a")])) + assert report["summary"]["circular_dependencies"] == 1 + + +def test_self_reference_is_info_not_a_cycle(): + report = detect_fk_cycles(_snap([("employee", "employee")], names=["employee"])) + assert report["summary"]["circular_dependencies"] == 0 + assert report["summary"]["self_references"] == 1 + assert report["items"][0]["severity"] == "info" + + +def test_acyclic_graph_has_no_findings(): + report = detect_fk_cycles(_snap([("orders", "member"), ("order_item", "orders")])) + assert report["items"] == [] + + +def test_two_independent_cycles_and_deep_chain_no_recursion_error(): + edges = [("a", "b"), ("b", "a"), ("c", "d"), ("d", "c")] + # long acyclic chain to exercise the iterative SCC + chain = [f"n{i}" for i in range(1500)] + edges += [(chain[i], chain[i + 1]) for i in range(len(chain) - 1)] + report = detect_fk_cycles(_snap(edges)) + assert report["summary"]["circular_dependencies"] == 2 From 0518b2c0c95b088a4694f53adcd49a1b04aa948b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 15:52:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20retrigger=20checks=20(strix=20scan=20?= =?UTF-8?q?flake=20=E2=80=94=20cannot=20rerun=20org=20workflow=20from=20fo?= =?UTF-8?q?rk)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fk-cycles is a pure read-only graph analyzer; no strix findings were posted and sibling analyzer PRs pass the same scan. Empty commit to re-enqueue. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AxU2xaupAjp912oDNFuWyd