Skip to content

Eliminate N+1 subgraph hydration queries with batched entity/relation fetches#4

Merged
vndee merged 8 commits into
mainfrom
copilot/add-batch-fetch-entity-relation
Apr 6, 2026
Merged

Eliminate N+1 subgraph hydration queries with batched entity/relation fetches#4
vndee merged 8 commits into
mainfrom
copilot/add-batch-fetch-entity-relation

Conversation

Copilot AI commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

HydrateSubgraph and graph-result hydration were doing per-node/per-edge lookups (GetEntity/GetRelation) in loops, creating N+1 query behavior on larger traversals. This change switches both paths to batched ID fetches and in-memory map hydration.

  • Storage API: add batch lookup primitives

    • Added to storage.Store:
      • GetEntitiesByIDs(ctx, kbID, ids) (map[string]*domain.Entity, error)
      • GetRelationsByIDs(ctx, kbID, ids) (map[string]*domain.Relation, error)
  • SQLite implementation: single-query ID batching (chunked)

    • Implemented both methods in SQLiteStore with WHERE kb_id = ? AND id IN (...).
    • Added chunking for large ID lists to avoid oversized parameter lists while preserving batch semantics.
    • Return shape is keyed maps for O(1) hydration lookups.
    • Updated batch query row handling to guarantee rows are closed on all return paths.
  • Hydration paths: remove per-item store calls

    • internal/server/helpers.go (HydrateSubgraph):
      • Batch fetch all node IDs and relation IDs once.
      • Hydrate from maps with existing fallback behavior for missing rows.
    • internal/search/graph.go (hydrateEntityResults):
      • Fetches entity IDs in progressive batches (limit-aware) instead of loading the full ID slice at once.
      • Stops querying once enough hydrated results are collected.
      • Builds results via map lookup, skipping missing IDs.
  • Focused coverage for new behavior

    • Added store tests for batch fetch semantics, KB scoping, partial/missing IDs, and large batches.
    • Added hydration tests asserting batched methods are used (and per-item getters are not), while verifying hydrated/fallback payload correctness.
    • Added a hydration test to verify small limits do not trigger full-ID fetches.
entitiesByID, err := store.GetEntitiesByIDs(ctx, kbID, ids)
if err != nil { return nil, fmt.Errorf("get entities by ids: %w", err) }

for _, id := range ids {
    ent, ok := entitiesByID[id]
    if !ok { continue } // preserve missing-row skip behavior
    // build result...
}

Summary by CodeRabbit

  • Chores
    • Refactored internal entity and relation retrieval mechanisms to use efficient batch database operations, consolidating multiple individual queries into single batch requests for improved efficiency.
    • Added comprehensive test coverage for batch retrieval functionality, including edge cases and large-scale data scenarios.

Copilot AI and others added 4 commits April 6, 2026 10:10
Agent-Logs-Url: https://github.com/vndee/memex/sessions/1ec419ab-55f2-4063-8471-c0e8891d649c

Co-authored-by: vndee <28271488+vndee@users.noreply.github.com>
Agent-Logs-Url: https://github.com/vndee/memex/sessions/1ec419ab-55f2-4063-8471-c0e8891d649c

Co-authored-by: vndee <28271488+vndee@users.noreply.github.com>
Agent-Logs-Url: https://github.com/vndee/memex/sessions/1ec419ab-55f2-4063-8471-c0e8891d649c

Co-authored-by: vndee <28271488+vndee@users.noreply.github.com>
Copilot AI changed the title [WIP] Add batch entity/relation fetch to eliminate N+1 queries Eliminate N+1 subgraph hydration queries with batched entity/relation fetches Apr 6, 2026
Copilot AI requested a review from vndee April 6, 2026 10:15
Copilot finished work on behalf of vndee April 6, 2026 10:15
@vndee
vndee marked this pull request as ready for review April 6, 2026 10:15
@vndee

vndee commented Apr 6, 2026

Copy link
Copy Markdown
Owner

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 6, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Apr 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a67e8151-adfb-49fd-a69b-2633149e9d29

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements batch fetching for entities and relations, replacing individual per-item lookups with bulk retrieval methods. It adds GetEntitiesByIDs and GetRelationsByIDs to the storage layer, refactors hydrateEntityResults and HydrateSubgraph to use these batch operations, and provides comprehensive test coverage.

Changes

Cohort / File(s) Summary
Storage Interface & Batch Methods
internal/storage/sqlite.go, internal/storage/entities.go, internal/storage/relations.go
Added GetEntitiesByIDs and GetRelationsByIDs methods to the Store interface and SQLiteStore implementation. Both methods support batching with a 900-ID limit per query chunk, use IN (...) SQL placeholders, and return results as maps keyed by ID.
Search Graph Hydration
internal/search/graph.go
Refactored hydrateEntityResults to call store.GetEntitiesByIDs once instead of per-entity store.GetEntity calls. Changed error handling from per-ID failures to batch-level errors, silently skipping missing entries from the returned map.
Search Graph Tests
internal/search/graph_test.go
Added TestHydrateEntityResults_UsesBatchLookup that verifies batched entity hydration via GetEntitiesByIDs rather than individual lookups, using a counting store wrapper to assert call counts.
Server Subgraph Hydration
internal/server/helpers.go
Refactored HydrateSubgraph to pre-collect node and edge IDs, perform single batch calls to GetEntitiesByIDs and GetRelationsByIDs, and conditionally hydrate fields only when results are present (fallback to minimal fields otherwise). Changed error handling from per-item ignoring to batch-level failures.
Server Helpers Tests
internal/server/helpers_test.go
Added comprehensive test for HydrateSubgraph that verifies batched lookups for both entities and relations, validates hydrated fields, and confirms zero per-item calls via a counting store wrapper.
Storage Layer Tests
internal/storage/storage_test.go
Added test coverage for GetEntitiesByIDs and GetRelationsByIDs, including basic mixed-ID queries, cross-knowledge-base isolation, and a stress test with 905 entity IDs to validate batching behavior.

Possibly related issues

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 Hops of joy for batch-wise ways,
No more loops through storage haze,
Gather IDs, fetch them whole,
Swift and clean—a rabbit's goal!
Maps and batches dance so bright,

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the main objective of the changeset: replacing per-item lookups with batch operations to eliminate N+1 query patterns during subgraph hydration.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch copilot/add-batch-fetch-entity-relation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/storage/storage_test.go (1)

296-354: Good test coverage for batch entity fetching.

The tests correctly validate KB scoping (entities from other KBs excluded), partial ID matches, and large batch handling (905 IDs exercises the chunking logic).

Consider adding an edge case test for empty ids slice to verify the early return behavior:

💡 Optional test for empty input
func TestGetEntitiesByIDs_EmptyInput(t *testing.T) {
	store := newTestStore(t)
	createTestKB(t, store, "kb1")

	entities, err := store.GetEntitiesByIDs(context.Background(), "kb1", []string{})
	if err != nil {
		t.Fatal("get entities by ids:", err)
	}
	if len(entities) != 0 {
		t.Fatalf("got %d entities, want 0", len(entities))
	}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/storage_test.go` around lines 296 - 354, Add a unit test to
cover the empty-ids edge case: create a test named
TestGetEntitiesByIDs_EmptyInput that uses newTestStore and createTestKB, calls
store.GetEntitiesByIDs(ctx, "kb1", []string{}) and asserts no error and that the
returned map length is 0; this verifies the GetEntitiesByIDs early-return
behavior and prevents regressions in the chunking/early-exit logic exercised by
TestGetEntitiesByIDs and TestGetEntitiesByIDs_LargeBatch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/storage/storage_test.go`:
- Around line 296-354: Add a unit test to cover the empty-ids edge case: create
a test named TestGetEntitiesByIDs_EmptyInput that uses newTestStore and
createTestKB, calls store.GetEntitiesByIDs(ctx, "kb1", []string{}) and asserts
no error and that the returned map length is 0; this verifies the
GetEntitiesByIDs early-return behavior and prevents regressions in the
chunking/early-exit logic exercised by TestGetEntitiesByIDs and
TestGetEntitiesByIDs_LargeBatch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c5ad4916-3f28-429e-bb2d-bf98517ad45d

📥 Commits

Reviewing files that changed from the base of the PR and between e6b0b5c and eb4750d.

📒 Files selected for processing (8)
  • internal/search/graph.go
  • internal/search/graph_test.go
  • internal/server/helpers.go
  • internal/server/helpers_test.go
  • internal/storage/entities.go
  • internal/storage/relations.go
  • internal/storage/sqlite.go
  • internal/storage/storage_test.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR removes N+1 lookup behavior during graph/subgraph hydration by adding batch-fetch primitives to the storage layer and updating hydration paths to populate results from in-memory maps.

Changes:

  • Added GetEntitiesByIDs / GetRelationsByIDs to storage.Store, with SQLite implementations using chunked IN (...) queries.
  • Updated HydrateSubgraph and hydrateEntityResults to hydrate via batched lookups rather than per-item store calls.
  • Added tests for batch semantics (scoping, missing IDs, large batches) and for ensuring hydration uses batch methods.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/storage/storage_test.go Adds tests for entity/relation batch lookup semantics and large-batch chunking.
internal/storage/sqlite.go Extends storage.Store with batch lookup methods.
internal/storage/entities.go Implements GetEntitiesByIDs with chunked IN queries returning an ID-keyed map.
internal/storage/relations.go Implements GetRelationsByIDs with chunked IN queries returning an ID-keyed map.
internal/server/helpers.go Updates HydrateSubgraph to batch-fetch nodes/edges once and hydrate from maps.
internal/server/helpers_test.go Adds a regression test asserting batch methods are used by HydrateSubgraph.
internal/search/graph.go Updates hydrateEntityResults to batch-fetch entity metadata and skip missing IDs via map lookup.
internal/search/graph_test.go Adds a regression test asserting batch method usage in hydrateEntityResults.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/storage/entities.go Outdated
}
entities[e.ID] = e
}
if err := rows.Err(); err != nil {

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

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

rows.Err() can return a non-nil error after iteration; in that branch the code returns without closing rows, which can leak resources. Close rows before returning (or defer rows.Close() immediately after QueryContext and then drop the explicit Close() calls).

Suggested change
if err := rows.Err(); err != nil {
if err := rows.Err(); err != nil {
if closeErr := rows.Close(); closeErr != nil {
return nil, fmt.Errorf("iterate entities by ids: %w (close rows: %v)", err, closeErr)
}

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +115
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query relations by ids: %w", err)
}
for rows.Next() {
r, err := scanRelation(rows)
if err != nil {
rows.Close()
return nil, err
}
rels[r.ID] = r
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate relations by ids: %w", err)
}
if err := rows.Close(); err != nil {
return nil, fmt.Errorf("close relations by ids rows: %w", err)
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

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

If rows.Err() is non-nil, the function returns without closing rows, which can leak the underlying statement/connection resources. Ensure rows is closed on all paths (e.g., defer rows.Close() right after QueryContext, or explicitly rows.Close() before returning on the rows.Err() branch).

Suggested change
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query relations by ids: %w", err)
}
for rows.Next() {
r, err := scanRelation(rows)
if err != nil {
rows.Close()
return nil, err
}
rels[r.ID] = r
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate relations by ids: %w", err)
}
if err := rows.Close(); err != nil {
return nil, fmt.Errorf("close relations by ids rows: %w", err)
}
if err := func() error {
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("query relations by ids: %w", err)
}
defer rows.Close()
for rows.Next() {
r, err := scanRelation(rows)
if err != nil {
return err
}
rels[r.ID] = r
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate relations by ids: %w", err)
}
return nil
}(); err != nil {
return nil, err
}

Copilot uses AI. Check for mistakes.
Comment thread internal/search/graph.go Outdated
Comment on lines +185 to +189
entitiesByID, err := store.GetEntitiesByIDs(ctx, kbID, ids)
if err != nil {
slog.Warn("graph entity batch lookup failed", "count", len(ids), "error", err)
return nil, fmt.Errorf("get entities by ids: %w", err)
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

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

hydrateEntityResults now batch-fetches using the full ids slice even though it stops building results once limit is reached. For large traversals with small limits this can cause unnecessary DB work and chunked queries. Consider fetching only as many IDs as needed (e.g., progressively batch/paginate until limit results are filled, or cap the initial batch to a reasonable bound like min(len(ids), limit) with a fallback to fetch more if many IDs are missing).

Copilot uses AI. Check for mistakes.
@vndee

vndee commented Apr 6, 2026

Copy link
Copy Markdown
Owner

@copilot check the reviews and comments

Copilot AI and others added 2 commits April 6, 2026 10:57

Copilot AI commented Apr 6, 2026

Copy link
Copy Markdown
Contributor Author

@copilot check the reviews and comments

Addressed the review items in commit 4fbefaf (plus follow-up fixes in 0810ac8): ensured batch query rows are always closed on all paths, optimized hydrateEntityResults to fetch progressively based on limit instead of the full ID slice, added focused test coverage for the small-limit fetch behavior, and replaced the hydration batch magic number with a named constant + rationale comment. Final validation passes (Code Review clean, CodeQL clean).

Copilot finished work on behalf of vndee April 6, 2026 11:00
@vndee
vndee merged commit d64f6b4 into main Apr 6, 2026
2 checks passed
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.

Add batch entity/relation fetch to eliminate N+1 queries in subgraph hydration

3 participants