Skip to content

fix(watch): purge embeddings before nodes to stop FK crash in rebuildFile#1182

Open
carlos-alm wants to merge 4 commits into
mainfrom
fix/1176-watch-fk-embeddings
Open

fix(watch): purge embeddings before nodes to stop FK crash in rebuildFile#1182
carlos-alm wants to merge 4 commits into
mainfrom
fix/1176-watch-fk-embeddings

Conversation

@carlos-alm
Copy link
Copy Markdown
Contributor

Summary

  • The watcher's rebuildFile rolled its own purgeAncillaryData helper that purged cfg_*, dataflow, complexity, node_metrics, and ast_nodes — but not embeddings. With codegraph embed populated and better-sqlite3's default PRAGMA foreign_keys = ON, the subsequent DELETE FROM nodes fired SQLITE_CONSTRAINT_FOREIGNKEY (embeddings.node_idnodes.id) and killed the long-running watcher process.
  • Switch rebuildFile to the shared purgeFileData helper already used by the main builder — it deletes from all child tables (including embeddings) in the correct FK-safe order, then edges, then nodes. Drop the now-unused deleteEdgesForFile / deleteNodes / countEdgesForFile plumbing from IncrementalStmts and the watcher's statement prep.
  • Wrap per-file rebuildFile calls in processPendingFiles with try/catch so any future SQLite/parse/filesystem error logs and skips instead of crashing the watcher. The watcher is a long-running session — one bad file should not bring it down.

Test plan

  • New regression test tests/integration/watcher-fk-embeddings.test.ts reproduces the failure (seeds an embeddings row, enables PRAGMA foreign_keys = ON, calls rebuildFile) and asserts no FK violation + that the embedding row is purged alongside the rebuilt file's nodes.
  • Existing parity test tests/integration/watcher-rebuild.test.ts still passes with the slimmer IncrementalStmts shape.
  • Full test suite: npm test — 2732 passed, 24 skipped, 0 failed.
  • Lint: npm run lint — clean.

Closes #1176

…File

The watcher's `rebuildFile` had its own `purgeAncillaryData` helper that
missed the `embeddings` table. With `codegraph embed` populated and
better-sqlite3's default `foreign_keys = ON`, the next `deleteNodes`
fired `SQLITE_CONSTRAINT_FOREIGNKEY` (embeddings.node_id → nodes.id)
and killed the watcher process.

Switch to the shared `purgeFileData` helper used by the main builder —
it purges embeddings, cfg_*, dataflow, complexity, node_metrics,
ast_nodes, edges, and nodes in the correct order — and drop the now
unused stmt prep. Wrap per-file `rebuildFile` calls in try/catch so
any future SQLite or parse error logs+skips instead of crashing the
long-running watcher.

Closes #1176
@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

Claude finished @carlos-alm's task —— View job


Reviewing PR #1182: Foreign key constraint fix in watcher

Todo List

  • Read and understand the changed files
  • Phase 1: Root cause analysis
  • Phase 2: Configuration change detection
  • Phase 3: Test integrity verification
  • Phase 4: Code quality review
  • Phase 5: Backlog compliance assessment
  • Provide final structured assessment

Phase 1: Root Cause Analysis ✓

Problem Being Solved: The watcher's rebuildFile function had a custom purgeAncillaryData helper that deleted from several tables (cfg_*, dataflow, complexity, node_metrics, ast_nodes) but NOT the embeddings table. With better-sqlite3's default PRAGMA foreign_keys = ON, the subsequent DELETE FROM nodes would trigger a foreign key constraint violation because embeddings.node_id references nodes.id.

Why This Approach: The fix correctly switches from the watcher's custom purge logic to the shared purgeFileData helper that's already used by the main builder. This helper properly deletes from all child tables (including embeddings) in the correct FK-safe order: dependent tables first, then edges, then nodes.

Risk Assessment: Low risk. This is a defensive fix that prevents crashes and uses existing, well-tested purge logic rather than duplicating/maintaining separate deletion code.

Phase 2: Configuration Change Detection ✓

No configuration changes detected. This PR makes no changes to:

  • Biome/linting rules
  • Test timeouts or coverage thresholds
  • Build configuration
  • TypeScript settings
  • Any rule relaxations

Phase 3: Test Integrity Verification ✓

New Regression Test: watcher-fk-embeddings.test.ts directly reproduces the FK violation:

  • ✅ Sets up embeddings table with FK constraint
  • ✅ Seeds an embedding row referencing a node from target file
  • ✅ Enables PRAGMA foreign_keys = ON (matches watcher behavior)
  • ✅ Calls rebuildFile and asserts no FK violation occurs
  • ✅ Verifies embedding is properly purged alongside nodes

Existing Tests: watcher-rebuild.test.ts still passes with slimmer IncrementalStmts interface (removed unused statements).

Test Quality: High-quality regression test that directly reproduces the user-reported failure scenario.

Phase 4: Code Quality Review ✅

Excellent Cleanup:

  • DRY Principle: Removes 35 lines of duplicated purge logic and consolidates to shared purgeFileData helper
  • Completeness: The old purgeAncillaryData was missing embeddings table cleanup - shared helper includes all tables
  • Safety: purgeFileData uses correct FK-safe deletion order and proper error handling
  • Interface Simplification: Removes unused deleteEdgesForFile/deleteNodes from IncrementalStmts
  • Error Handling: Adds per-file try/catch in processPendingFiles (lines 70-79) to prevent watcher crashes

Code Quality: Very clean implementation with excellent comments explaining the FK ordering requirement and referencing issue #1176.

No Security/Performance Concerns: Uses existing, vetted database operations. No command injection, SQL injection, or performance regressions.

Phase 5: Backlog Compliance Assessment ✅

Zero-dep: ✓ — No new runtime dependencies added. Uses existing SQLite operations.

Foundation-aligned: ✓ — Fully compliant with FOUNDATION.md principles:

  • No cloud API calls
  • No external dependencies
  • Pure defensive fix (doesn't generate code or make decisions)
  • Maintains programmatic API contract

Problem-fit: 4/5 — Directly prevents watcher crashes that would disrupt AI agents during long-running sessions. While not directly addressing "lost AI" token waste, it ensures the core dependency tracking infrastructure remains stable and available.

Breaking: No — This is a pure bug fix with no API changes. The IncrementalStmts interface change only removes unused properties, maintaining backward compatibility.

Tier: 1 (Zero-dep ✓ + Foundation-aligned ✓)


Root Cause Analysis

Problem Being Solved: Foreign key constraint violation crashes in watcher when embeddings exist
Why This Approach: Switch to shared, FK-safe purgeFileData helper instead of custom purge logic missing embeddings
Risk Assessment: Low risk - defensive fix using existing, well-tested database operations

Backlog Compliance

  • Zero-dep: ✓ — No new dependencies
  • Foundation-aligned: ✓ — Pure defensive fix, no principle violations
  • Problem-fit: 4 — Ensures core infrastructure stability for AI agents
  • Breaking: No — Bug fix with no API changes
  • Tier: 1

Critical Concerns

None identified. This is a well-executed fix with excellent test coverage.

Final Recommendation

  • Rating: ⭐⭐⭐⭐⭐ (5/5)
  • Action: APPROVE
  • Reasoning: Excellent bug fix that solves a real crash issue, includes perfect regression test, improves code quality by removing duplication, and follows all best practices. The FK constraint issue was a genuine bug that needed fixing, not a symptom of deeper problems.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 21, 2026

Greptile Summary

This PR fixes a SQLITE_CONSTRAINT_FOREIGNKEY crash in the watcher's rebuildFile by replacing a hand-rolled purgeAncillaryData helper — which omitted embeddings — with the shared purgeFileData utility that clears all child tables (including embeddings) in the correct FK-safe order before deleting nodes. A per-file try/catch in processPendingFiles is also added so parse failures or SQLite errors skip the bad file rather than killing the watcher.

  • Core fix: purgeFileData(db, relPath, { purgeHashes: false }) replaces the old purgeAncillaryData + stmts.deleteEdgesForFile + stmts.deleteNodes sequence, ensuring embeddings.node_id is cleared before nodes.id is deleted.
  • Watcher resilience: processPendingFiles now wraps each rebuildFile call in a try/catch with instanceof Error narrowing (addressing the previous review thread) so a misbehaving file doesn't crash the long-running session.
  • Test coverage: A new regression test seeds an embeddings row, enables PRAGMA foreign_keys = ON, runs rebuildFile, and asserts both no FK violation and that the seeded row is fully purged (not merely orphaned), using the corrected SELECT COUNT(*) FROM embeddings check.

Confidence Score: 5/5

Safe to merge — the fix correctly delegates purge ordering to the shared purgeFileData utility, and the new regression test directly verifies the FK-safe deletion sequence.

The change is narrowly scoped to deleting the old hand-rolled purge path and wiring in the battle-tested shared helper. The FK-ordering bug is fully addressed, the regression test seeds a real embeddings row and checks the total count goes to zero (not just a JOIN that would miss orphans), and the prior review issues (instanceof narrowing, orphan-row assertion) are both reflected in the updated code.

No files require special attention.

Important Files Changed

Filename Overview
src/domain/graph/builder/incremental.ts Replaces local purgeAncillaryData + stmts-based edge/node deletes with shared purgeFileData; removes deleteEdgesForFile and deleteNodes from IncrementalStmts; fix is correct and FK-safe.
src/domain/graph/watcher.ts Removes now-unused deleteNodes/deleteEdgesForFile/countEdgesForFile statement prep; adds per-file try/catch with proper instanceof narrowing in processPendingFiles; clean.
tests/integration/watcher-fk-embeddings.test.ts New regression test seeds an embeddings row, runs rebuildFile with FK enforcement on, and asserts total embeddings count = 0 — correctly catches orphan-row regressions after the previous review fix.
tests/integration/watcher-rebuild.test.ts Parity test updated to drop deleteNodes/deleteEdgesForFile/countEdgesForFile from makeStmts to match the slimmer IncrementalStmts interface.

Sequence Diagram

sequenceDiagram
    participant W as Watcher (processPendingFiles)
    participant R as rebuildFile
    participant P as purgeFileData
    participant DB as SQLite DB

    W->>R: rebuildFile(db, rootDir, filePath, stmts, ...)
    R->>P: "purgeFileData(db, relPath, {purgeHashes:false})"
    P->>DB: "DELETE FROM embeddings WHERE node_id IN (SELECT id FROM nodes WHERE file=?)"
    P->>DB: DELETE FROM cfg_edges / cfg_blocks / dataflow / complexity / node_metrics / ast_nodes
    P->>DB: "DELETE FROM edges WHERE source_id/target_id IN (SELECT id FROM nodes WHERE file=?)"
    P->>DB: "DELETE FROM nodes WHERE file=?"
    P-->>R: done (FK-safe)
    R->>DB: INSERT new nodes and edges
    R-->>W: RebuildResult
    W->>W: push result to writeJournalAndChangeEvents

    note over W,DB: On error: catch, warn+debug log, skip file - watcher continues
Loading

Reviews (4): Last reviewed commit: "Merge branch 'main' into fix/1176-watch-..." | Re-trigger Greptile

Comment thread src/domain/graph/watcher.ts Outdated
Comment on lines +77 to +78
warn(`Failed to rebuild ${relPath}: ${(err as Error).message} — skipping`);
debug((err as Error).stack ?? String(err));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 If err is a non-Error value (e.g., a plain string or null thrown by a third-party dependency), (err as Error).message evaluates to undefined, so the logged warning reads "Failed to rebuild …: undefined — skipping", which is hard to diagnose. Narrowing with instanceof produces a safe message in all cases.

Suggested change
warn(`Failed to rebuild ${relPath}: ${(err as Error).message} — skipping`);
debug((err as Error).stack ?? String(err));
const message = err instanceof Error ? err.message : String(err);
warn(`Failed to rebuild ${relPath}: ${message} — skipping`);
debug(err instanceof Error ? (err.stack ?? message) : String(err));

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d2db316 — replaced (err as Error).message with instanceof Error narrowing so a non-Error throw logs a usable message instead of "undefined".

Comment on lines +121 to +128
const remaining = db
.prepare(
`SELECT COUNT(*) AS c FROM embeddings e
JOIN nodes n ON e.node_id = n.id
WHERE n.file = ?`,
)
.get('shared/constants.js') as { c: number };
expect(remaining.c).toBe(0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Secondary assertion does not verify the seeded row was deleted

The query JOIN nodes n ON e.node_id = n.id WHERE n.file = ? counts embeddings that reference currently existing nodes for shared/constants.js. After rebuildFile the old node IDs are gone, so even if the embedding row were left as an orphan the JOIN would find nothing and the count would still be 0. A more reliable check is to count all rows in embeddings (there was exactly one seeded row) or use WHERE node_id = ? with the known seeded ID, so the assertion actually fails if the row survives as an orphan.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d2db316 — switched the assertion to SELECT COUNT(*) FROM embeddings (exactly one row was seeded), so it now catches the orphan-row regression the JOIN check missed.

…1182)

The watcher's local getNodeId.get used a typed parameter list
(name, kind, file, line) but IncrementalStmts declares
get: (...params: unknown[]). TS2322 rejected the narrower form,
which broke the build job and all downstream CI checks.

Switch to (...params: unknown[]) and destructure inside the body
so the implementation matches the interface contract.
Addresses Greptile P2 review feedback:

- src/domain/graph/watcher.ts: replace `(err as Error).message` with
  `instanceof Error` narrowing so a non-Error throw (plain string,
  null, or any value from a third-party dependency) logs a usable
  message instead of "undefined".

- tests/integration/watcher-fk-embeddings.test.ts: count all rows in
  `embeddings` directly instead of joining on `nodes.file`. The
  JOIN-based check would pass even if the seeded row survived as an
  orphan with a dangling node_id; counting the table itself catches
  that regression.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

Codegraph Impact Analysis

3 functions changed7 callers affected across 2 files

  • rebuildFile in src/domain/graph/builder/incremental.ts:497 (4 transitive callers)
  • prepareWatcherStatements in src/domain/graph/watcher.ts:18 (3 transitive callers)
  • processPendingFiles in src/domain/graph/watcher.ts:58 (4 transitive callers)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: watch mode crashes with FOREIGN KEY constraint failed in rebuildFile

1 participant