Skip to content

perf(builder): defer NativeDatabase init to after no-op early exit#884

Merged
carlos-alm merged 2 commits intomainfrom
perf/defer-native-db-init
Apr 6, 2026
Merged

perf(builder): defer NativeDatabase init to after no-op early exit#884
carlos-alm merged 2 commits intomainfrom
perf/defer-native-db-init

Conversation

@carlos-alm
Copy link
Copy Markdown
Contributor

Summary

  • Defer NativeDatabase.openReadWrite() + initSchema() from setupPipeline to on-demand: the native orchestrator opens it when the Rust fast path runs, and the JS pipeline defers until after the early-exit check
  • No-op rebuilds now skip the ~5ms native DB open/initSchema/checkpoint/close cycle entirely, equalizing WASM and native no-op performance
  • Remove dead batched native path from getChangedFiles (nativeDb was already undefined during detectChanges due to suspendNativeDb)

Test plan

  • tests/builder/pipeline.test.ts — 4/4 pass (including no-op rebuild test)
  • tests/builder/detect-changes.test.ts — 4/4 pass
  • tests/integration/build.test.ts — 17/17 pass (2 skipped, 1 pre-existing worktree WASM grammar issue)
  • tests/integration/incremental-parity.test.ts — 12/12 pass
  • tests/integration/watcher-rebuild.test.ts — pass
  • tests/builder/context.test.ts — pass
  • Lint clean on all 3 changed files

On no-op rebuilds, the native engine opened NativeDatabase, ran
initSchema, checkpointed WAL, then immediately closed it — all before
detectChanges even ran. This added ~5ms overhead on every no-op rebuild
where the connection was never used.

Move NativeDatabase.openReadWrite() from setupPipeline to on-demand:
- tryNativeOrchestrator opens it when the Rust fast path will run
- runPipelineStages defers it until after the early-exit check
- setupPipeline now only probes loadNative() for availability
- checkEngineSchemaMismatch uses better-sqlite3 (reads are WAL-safe)
- Remove dead batched native path from getChangedFiles (nativeDb was
  already undefined during detectChanges due to suspendNativeDb)
@claude
Copy link
Copy Markdown

claude bot commented Apr 6, 2026

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


PR Review: perf(builder): defer NativeDatabase init to after no-op early exit ⭐⭐⭐⭐☆

Root Cause Analysis

Problem Being Solved: No-op rebuilds (incremental builds with no file changes) were unnecessarily spending ~5ms to open, initialize schema, and close the native database connection, even when the build would exit early without using it. This created a performance disparity between WASM and native engine paths on no-op rebuilds.

Why This Approach: The solution defers NativeDatabase.openReadWrite() + initSchema() from the setupPipeline phase to on-demand initialization:

  • Native orchestrator opens it when the Rust fast path runs (line 522-524 in pipeline.ts)
  • JS pipeline defers until after the early-exit check in detectChanges
  • This eliminates the overhead for the common case of no-op rebuilds

Risk Assessment: Low risk. The changes maintain the same functional behavior while optimizing the common no-op path. The deferral is well-architected with proper error handling and fallback mechanisms.

Backlog Compliance

  • Zero-dep: ✓ — no new runtime dependencies
  • Foundation-aligned: ✓ — improves performance without violating principles
  • Problem-fit: 3 — performance optimization that benefits user experience, but doesn't directly address the core "AI agent navigation" problem
  • Breaking: No — purely internal optimization, no API changes
  • Tier: 1

Code Quality Assessment

Positive aspects:

  1. Clean architecture: The nativeAvailable boolean flag properly separates availability detection from actual initialization
  2. Proper error handling: Native DB initialization failures gracefully fall back to JS path (lines 525-534)
  3. Consistent patterns: Maintains the existing suspend/reopen WAL pattern for dual-connection safety
  4. Dead code removal: Properly removes the unused batched native path in getChangedFiles (detect-changes.ts:62-63)
  5. Good commenting: Clear explanations of the deferral strategy and why it's safe

Technical correctness:

  • The deferral is safe because collectFiles and detectChanges only need better-sqlite3 access
  • WAL checkpoint patterns are preserved to avoid corruption
  • All code paths that need native DB properly check for availability and initialize on demand

Performance impact:

  • Eliminates ~5ms overhead on no-op rebuilds (as claimed in PR description)
  • No performance degradation on builds that actually do work
  • Equalizes WASM vs native performance for the no-op case

Test Coverage

The PR author reports comprehensive test coverage:

  • ✅ Pipeline unit tests (4/4 pass) including no-op rebuild test
  • ✅ Change detection tests (4/4 pass)
  • ✅ Integration tests (17/17 pass)
  • ✅ Incremental parity tests (12/12 pass)
  • ✅ All lint checks pass

The existing tests/builder/pipeline.test.ts contains a specific test for the no-op early exit case (lines 43-49), which validates this optimization.

Minor Concerns

  1. Comment clarity: The comment on line 118 could be more precise about when the DB gets opened in each path
  2. Potential confusion: Having two different initialization paths (orchestrator vs JS pipeline) could be confusing for future maintainers, though it's well-documented

Critical Concerns

None. The implementation is solid and well-tested.

Final Recommendation

  • Rating: ⭐⭐⭐⭐☆ (4/5)
  • Action: APPROVE
  • Reasoning: This is a well-architected performance optimization that addresses a real inefficiency in the build pipeline. The code changes are clean, maintain proper error handling, remove dead code, and have comprehensive test coverage. While it doesn't directly solve the core AI agent navigation problem, it improves the user experience for a common case (no-op rebuilds) without introducing risk or breaking changes.

The 40-line net reduction while improving performance demonstrates good engineering practices. The deferral strategy is appropriate and maintains the existing architectural patterns.


@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 6, 2026

Greptile Summary

This PR defers NativeDatabase.openReadWrite() + initSchema() from setupPipeline to on-demand, eliminating the ~5ms native DB overhead on no-op rebuilds. It also correctly moves the WAL checkpoint callbacks (suspendJsDb/resumeJsDb) to the reopenNativeDb('analyses') block in runPipelineStages (addressing the prior review concern), and removes dead batched-native code from getChangedFiles.

One regression: runPostNativeAnalysis (the native orchestrator's post-analysis path) reopens nativeDb but does not assign the WAL callbacks, leaving them as undefined — the dual-connection WAL guard from #696/#715 is absent for that path.

Confidence Score: 4/5

Not safe to merge without addressing the WAL guard regression in the native orchestrator post-analysis path.

The prior WAL callback regression is correctly fixed for the JS pipeline path. However, runPostNativeAnalysis (native orchestrator path) now runs with suspendJsDb/resumeJsDb both undefined because initializeEngine no longer sets them eagerly. This removes the dual-connection WAL corruption guard from #696/#715 for the native orchestrator + analysis code path — a real correctness defect on the changed path.

src/domain/graph/builder/pipeline.ts — specifically the runPostNativeAnalysis function around lines 434-444 where WAL callbacks are not assigned alongside nativeDb.

Important Files Changed

Filename Overview
src/domain/graph/builder/context.ts Adds nativeAvailable: boolean flag to PipelineContext to track deferred native engine availability — clean, minimal change.
src/domain/graph/builder/pipeline.ts Defers NativeDatabase init to on-demand, fixes WAL callbacks in JS pipeline analyses block, but misses callback assignment in runPostNativeAnalysis (native orchestrator path), breaking the #696/#715 WAL guard there.
src/domain/graph/builder/stages/detect-changes.ts Removes dead batched native path from getChangedFilesnativeDb was always undefined at call sites due to prior suspendNativeDb; correct cleanup.

Sequence Diagram

sequenceDiagram
    participant BG as buildGraph()
    participant SP as setupPipeline
    participant TNO as tryNativeOrchestrator
    participant RPNA as runPostNativeAnalysis
    participant RPS as runPipelineStages
    participant RA as runAnalyses

    BG->>SP: setup (nativeAvailable=true, nativeDb=undefined)
    BG->>TNO: try native orchestrator
    TNO->>TNO: open NativeDatabase on demand
    TNO->>TNO: buildGraph (Rust)
    alt earlyExit (no-op)
        TNO-->>BG: 'early-exit' (NativeDB never opened ✓)
    else native success
        TNO->>RPNA: runPostNativeAnalysis
        RPNA->>RPNA: reopen nativeDb
        RPNA->>RPNA: set engineOpts.nativeDb only
        Note over RPNA: ⚠️ suspendJsDb/resumeJsDb remain undefined
        RPNA->>RA: runAnalyses (WAL guard missing)
        TNO-->>BG: BuildResult
    else native fail / FORCE_JS_PIPELINE
        TNO-->>BG: undefined
        BG->>RPS: runPipelineStages
        RPS->>RPS: collectFiles + detectChanges (nativeDb=undefined)
        alt earlyExit (no-op)
            RPS-->>BG: return (NativeDB never opened ✓)
        else files changed
            RPS->>RPS: reopenNativeDb('analyses')
            RPS->>RPS: set nativeDb + suspendJsDb + resumeJsDb ✓
            RPS->>RA: runAnalyses (WAL guard active)
        end
    end
Loading

Reviews (2): Last reviewed commit: "fix(builder): restore WAL checkpoint cal..." | Re-trigger Greptile

Comment on lines +46 to +50
// nativeDb and WAL callbacks are set later when NativeDatabase is opened
// (deferred to skip overhead on no-op rebuilds).
nativeDb: undefined,
suspendJsDb: undefined,
resumeJsDb: undefined,
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.

P1 WAL checkpoint callbacks silently dropped

These fields are set to undefined here with a comment promising they'll be populated "later when NativeDatabase is opened", but the only later assignment — the if (ctx.nativeAvailable) block in runPipelineStages (~line 660) — only sets ctx.engineOpts.nativeDb. The WAL callbacks are never assigned. When the JS pipeline runs with native engine active (e.g., forceFullRebuild triggered by schema/engine/version change, or CODEGRAPH_FORCE_JS_PIPELINE=1), ast.ts, complexity.ts, cfg.ts, and dataflow.ts all invoke nativeDb.bulkInsert* wrapped in suspendJsDb?.() / resumeJsDb?.() — both silently no-op, removing the WAL checkpoint guard introduced in #696/#715 that prevents dual-connection corruption when rusqlite and better-sqlite3 are open to the same WAL file simultaneously.

The fix is to assign both callbacks in the if (ctx.nativeDb && ctx.engineOpts) branch after reopenNativeDb(ctx, 'analyses'):

if (ctx.nativeDb && ctx.engineOpts) {
  ctx.engineOpts.nativeDb = ctx.nativeDb;
  ctx.engineOpts.suspendJsDb = () => {
    ctx.db.pragma('wal_checkpoint(TRUNCATE)');
  };
  ctx.engineOpts.resumeJsDb = () => {
    try {
      ctx.nativeDb?.exec('PRAGMA wal_checkpoint(TRUNCATE)');
    } catch (e) {
      debug(`resumeJsDb: WAL checkpoint failed: ${toErrorMessage(e)}`);
    }
  };
}

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 1ef71db. Both suspendJsDb and resumeJsDb are now assigned in the reopenNativeDb('analyses') block alongside engineOpts.nativeDb. Also explicitly resets both to undefined in the fallback branch when nativeDb is unavailable. Good catch — this was a real regression of the WAL corruption guard from #696/#715.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 6, 2026

Codegraph Impact Analysis

9 functions changed10 callers affected across 6 files

  • PipelineContext in src/domain/graph/builder/context.ts:21 (6 transitive callers)
  • initializeEngine in src/domain/graph/builder/pipeline.ts:41 (5 transitive callers)
  • checkEngineSchemaMismatch in src/domain/graph/builder/pipeline.ts:58 (5 transitive callers)
  • meta in src/domain/graph/builder/pipeline.ts:67 (3 transitive callers)
  • setupPipeline in src/domain/graph/builder/pipeline.ts:110 (6 transitive callers)
  • tryNativeOrchestrator in src/domain/graph/builder/pipeline.ts:507 (5 transitive callers)
  • runPipelineStages in src/domain/graph/builder/pipeline.ts:622 (6 transitive callers)
  • getChangedFiles in src/domain/graph/builder/stages/detect-changes.ts:57 (3 transitive callers)
  • detectChanges in src/domain/graph/builder/stages/detect-changes.ts:465 (5 transitive callers)

…884)

suspendJsDb and resumeJsDb were initialized to undefined in
initializeEngine and never reassigned when nativeDb was reopened for the
analysis phase. This silently removed the dual-connection WAL corruption
guard introduced in #696/#715. Assign both callbacks alongside
engineOpts.nativeDb in the reopenNativeDb('analyses') block.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

Addressed P1 finding from Greptile: WAL checkpoint callbacks (suspendJsDb/resumeJsDb) are now properly assigned in the reopenNativeDb('analyses') block (1ef71db). The dual-connection WAL corruption guard from #696/#715 is restored.

@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit d351fce into main Apr 6, 2026
18 checks passed
@carlos-alm carlos-alm deleted the perf/defer-native-db-init branch April 6, 2026 23:20
@github-actions github-actions bot locked and limited conversation to collaborators Apr 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant