Skip to content

perf(people): batch handle-alias fetch in list(), dropping an N+1#4536

Open
mysma-9403 wants to merge 2 commits into
tinyhumansai:mainfrom
mysma-9403:perf/batch-people-handles
Open

perf(people): batch handle-alias fetch in list(), dropping an N+1#4536
mysma-9403 wants to merge 2 commits into
tinyhumansai:mainfrom
mysma-9403:perf/batch-people-handles

Conversation

@mysma-9403

@mysma-9403 mysma-9403 commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What

PeopleStore::list() returns every contact with its handle aliases. It fetched those aliases with a load_handles call per person, inside the row loop:

for r in rows {let handles = load_handles(&guard, &id)?;   // 1 SELECT per person}

For N contacts that's 1 + N queries (SELECT … FROM handle_aliases WHERE person_id = ? prepared + executed N times), all while holding the store lock.

Change

Collect the person rows first, then fetch all handles in one batched WHERE person_id IN (…) query keyed by person id (batch_handles_conn), windowed at 900 binds to stay under SQLite's default parameter cap. This is the pattern the same store already uses for batch_interactions_for — I mirrored it. list() drops from 1 + N queries to 1 + ceil(N/900).

load_handles stays for the single-person get() path (one query is correct there). The batch decodes each kind exactly as load_handles does, so behaviour is identical.

Is it worth it? (honest take)

SQLite is in-process, so each avoided query is cheap in absolute terms — this isn't a dramatic speedup. It's worth it because (a) it shortens how long list() holds the store lock (the whole fetch runs under one blocking_lock), (b) statement prepares + index lookups drop from O(contacts) to ~O(1) query, scaling with contact count, and (c) it follows an existing in-repo precedent, so it's low-risk and consistent rather than a speculative tweak.

Correctness

Behaviour-preserving. The batch is keyed by person_id; each person gets exactly its own aliases (ORDER BY person_id, kind, value preserves the per-person kind, value order load_handles used), and a person with no aliases comes back with an empty list (unwrap_or_default).

Tests

list_batches_handles_per_person (new) creates three people — one with two aliases, one with none, one with a single alias — and asserts each gets exactly its own handles and the alias-less person does not inherit a neighbour's (the failure mode a batched IN query risks). The existing insert_list_and_lookup_round_trip still covers the single-person handle round-trip.

cargo test --lib people::store green locally.

Summary by CodeRabbit

  • Bug Fixes
    • Improved the people list to load handle aliases in batches, reducing slowdowns from repeated lookups.
    • Fixed an issue where aliases could be mismatched across people, including ensuring people with no aliases don’t receive others’ aliases.
  • Tests
    • Added coverage to verify correct batched alias assignment and correct behavior for people with no aliases.

PeopleStore::list() returned every contact with its handle aliases, fetching
them with a load_handles call per person inside the row loop — 1 + N queries
(SELECT ... FROM handle_aliases WHERE person_id = ? prepared + executed once
per contact), all under the store lock.

Collect the person rows first, then fetch all aliases in one batched
WHERE person_id IN (...) query keyed by person id (batch_handles_conn),
windowed at 900 binds to stay under SQLite's default parameter cap. Mirrors
the store's existing batch_interactions_for. list() drops from 1 + N to
1 + ceil(N/900) queries and holds the lock for less time. load_handles stays
for the single-person get() path; the batch decodes each kind identically.

Tests: list_batches_handles_per_person asserts three people (two aliases, none,
one) each get exactly their own handles and the alias-less person doesn't
inherit a neighbour's — the failure mode a batched IN query risks.
@mysma-9403
mysma-9403 requested a review from a team July 5, 2026 04:49
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 924aefba-7d89-4f6a-88d1-e4520c4d684f

📥 Commits

Reviewing files that changed from the base of the PR and between f0650b0 and c329383.

📒 Files selected for processing (1)
  • src/openhuman/people/store.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/openhuman/people/store.rs

📝 Walkthrough

Walkthrough

PeopleStore::list now decodes people first, batch-loads aliases in chunked queries, and reconstructs each person from a PersonId-keyed handle map. A test verifies correct alias assignment, including people without aliases.

Changes

Batched handle fetching for list

Layer / File(s) Summary
PersonRow and batched handle loading
src/openhuman/people/store.rs
Adds PersonRow, chunked batch_handles_conn queries, and updates list to assemble people from batched aliases instead of per-person handle loads.
Per-person alias assignment test
src/openhuman/people/store.rs
Verifies distinct aliases remain associated with the correct people and that missing aliases produce empty lists.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant PeopleStore
  participant Database

  Caller->>PeopleStore: list()
  PeopleStore->>Database: query people rows
  Database-->>PeopleStore: PersonRow list
  PeopleStore->>PeopleStore: collect person IDs
  PeopleStore->>Database: chunked handle queries
  Database-->>PeopleStore: handle map by PersonId
  PeopleStore-->>Caller: Vec<Person>
Loading

Poem

One query hopped where many did tread,
Handles gathered in a map instead,
Each alias found its proper home,
Empty paws where none had roamed,
The rabbit cheers: no leaks to comb! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: batching handle-alias fetches in PeopleStore::list() to remove the N+1 pattern.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 5, 2026
Comment thread src/openhuman/people/store.rs Outdated
for window in ids.chunks(HANDLE_BATCH) {
if window.is_empty() {
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nitpick (consistency): the PR mirrors batch_interactions_for, which builds placeholders with std::iter::repeat_n("?", n) (see line 491). Here you use the older std::iter::repeat("?").take(n) idiom. Since repeat_n is already used one function up in the same file, prefer it for consistency — and Clippy’s manual_repeat_n lint flags exactly this form.

// current
let placeholders = std::iter::repeat("?")
    .take(window.len())
    .collect::<Vec<_>>()
    .join(",");

// suggested — matches batch_interactions_for
let placeholders = std::iter::repeat_n("?", window.len())
    .collect::<Vec<_>>()
    .join(",");

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.

Done in c329383e2 — switched to std::iter::repeat_n("?", window.len()) to match batch_interactions_for and silence clippy::manual_repeat_n.

ids: &[PersonId],
) -> SqlResult<HashMap<PersonId, Vec<Handle>>> {
let mut out: HashMap<PersonId, Vec<Handle>> = HashMap::new();
for window in ids.chunks(HANDLE_BATCH) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nitpick (dead branch): slice::chunks never yields an empty slice — for an empty ids it yields zero windows, and for non-empty ids every window has ≥1 element. So if window.is_empty() { continue; } is unreachable. Harmless, but it can be dropped. (The sibling batch_interactions_for guards emptiness once, up front, via an early return Ok(HashMap::new()) — the empty-ids case is already handled here implicitly by the zero-window loop, so no guard is needed at all.)

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.

Dropped in c329383e2. Agreed — slice::chunks never yields an empty window (empty ids → zero windows), so the guard was unreachable. Removed it entirely; the empty-ids case is handled implicitly by the zero-window loop.

@M3gA-Mind M3gA-Mind left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR #4536 — perf(people): batch handle-alias fetch in list(), dropping an N+1

Walkthrough

This PR replaces the per-person load_handles call inside PeopleStore::list()'s row loop with a single batched WHERE person_id IN (…) fetch (batch_handles_conn), windowed at 900 binds to stay under SQLite's 999 bound-parameter cap. It mirrors the store's existing batch_interactions_for precedent almost line-for-line. The change is behaviour-preserving and well-tested. Assessment: clean, low-risk, does exactly what the title claims. No blockers.

Changes

File Summary
src/openhuman/people/store.rs Adds PersonRow tuple alias + batch_handles_conn helper (chunked IN (…) handle fetch); rewrites list() to materialise rows then batch-fetch handles keyed by id; adds list_batches_handles_per_person test. load_handles retained for the single-person get() path.

Actionable comments (0 blocking)

No blockers, no majors. Two nitpicks posted inline; both optional.

Correctness — verified

  • Alias round-trip is exact. PersonId: Display writes the plain UUID (types.rs:24), matching the person.id.to_string() written by insert_person, so the IN (…) string keys resolve back correctly.
  • Per-person handle ordering is preserved. load_handles used ORDER BY kind, value; the batch uses ORDER BY person_id, kind, value. For rows of the same person_id the secondary keys yield an identical order, and results are assembled by iterating the ordered people_rows Vec (the HashMap is lookup-only), so person order (ORDER BY display_name) is preserved too.
  • Empty / no-alias cases hold. Zero people → ids empty → chunks yields no windows → empty map. A person with no aliases is absent from the map and gets unwrap_or_default()[] (the exact failure mode the new test guards against).
  • Error semantics unchanged. Unknown kind still returns Err(InvalidColumnName(...)), same as load_handles; the whole fetch still runs under one blocking_lock.
  • No dup keys. people.id is the PK, so ids has no duplicates and handles_by_id.remove(&id) is safe.

Nitpicks (2) — posted inline

  • store.rs:580 — uses std::iter::repeat("?").take(n) while the mirrored batch_interactions_for (line 491) uses std::iter::repeat_n("?", n); prefer the latter for consistency (also what Clippy's manual_repeat_n prefers).
  • store.rs:577if window.is_empty() { continue; } is unreachable: slice::chunks never yields an empty slice. Harmless dead branch.

Questions for the author (0)

None.

Verified / looks good

  • Follows the in-repo batch_interactions_for precedent; keeps load_handles for the correct single-query get() path.
  • New test exercises the real risk of a batched IN query: cross-person leakage, an alias-less person, and kind preservation.
  • spawn_blocking + error-mapping wrapper unchanged; no new lock-ordering or async concerns.

Note: CodeRabbit (CHILL profile) generated no actionable comments and auto-approved. Leaving this as a comment-only review — approval/merge is the maintainer's call.

@Coding-Dev-Tools

Copy link
Copy Markdown

🤖 Council Merge Gate — APPROVE (merge blocked by branch protection)

The model council review gate returned APPROVE (session council-e72433e9-93a7-47e5-9947-6f08c6eb1b9e), so the change is council-cleared.

However, gh pr merge was rejected by GitHub branch protection: the base branch policy prohibits the merge. This repo requires passing status checks and/or an approving review before merge, and those requirements are not currently satisfied (e.g. failing required CI checks).

Action needed (human): resolve the failing required checks / obtain the required review, then merge. A maintainer with admin rights may also merge explicitly. The PR is left OPEN.

Posted automatically by the council_gate_merge cron job.

@Coding-Dev-Tools

Copy link
Copy Markdown

🤖 Council Merge Gate — APPROVE (merge blocked by branch protection)

The model council review gate returned APPROVE (session council-2e6a89f2-ef0e-4483-8068-826ce84d716f), so the change is council-cleared.

However, gh pr merge was rejected by GitHub branch protection: the base branch policy prohibits the merge. This repo requires passing status checks and/or an approving review before merge, and those requirements are not currently satisfied (e.g. failing required CI checks).

Action needed (human): resolve the failing required checks / obtain the required review, then merge. A maintainer with admin rights may also merge explicitly. The PR is left OPEN.

Posted automatically by the council_gate_merge cron job.

@Coding-Dev-Tools

Copy link
Copy Markdown

🤖 Council Merge Gate — APPROVE (merge blocked by branch protection)

The model council review gate returned APPROVE (session council-2a5ecd70-7361-4ba7-a3e9-57c896998b86), so the change is council-cleared.

However, gh pr merge was rejected by GitHub branch protection: the base branch policy prohibits the merge. This repo requires passing status checks and/or an approving review before merge, and those requirements are not currently satisfied (e.g. failing required CI checks).

Action needed (human): resolve the failing required checks / obtain the required review, then merge. A maintainer with admin rights may also merge explicitly. The PR is left OPEN.

Posted automatically by the council_gate_merge cron job.

…d in batch_handles_conn

- repeat_n("?", n) matches the sibling batch_interactions_for idiom and
  satisfies clippy::manual_repeat_n (vs repeat().take()).
- slice::chunks never yields an empty window (empty ids -> zero windows), so
  the if window.is_empty() { continue } branch was unreachable; remove it.

@senamakel senamakel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Automated technical review: not approved.

Blocking issue -- compilation errors on all CI lanes

The PR introduces a second type PersonRow definition (with PersonId as the first element) at the module level in src/openhuman/people/store.rs, but the pre-existing type PersonRow = (String, Option<String>, Option<String>, Option<String>, i64, i64) on the same file (lines 19-26 on main) is NOT removed. This creates a duplicate type alias at module scope, producing E0428: the name 'PersonRow' is defined multiple times and cascading type-mismatch errors (E0277: Vec<PersonId> cannot be built from iterator over String).

The Rust Quality (fmt, clippy), Rust Feature-Gate Smoke (gates off), and PR CI Gate checks all fail because of this.

Remediation: Either (a) remove the old type PersonRow = (String, ...) definition and change its first element to PersonId, or (b) rename the new type alias (e.g. PersonRowWithId) to avoid the name conflict.

N+1 claim: The refactoring approach (batched WHERE person_id IN (...) handle fetch, windowed at 900 binds) is correct and follows the existing batch_interactions_for precedent. Once the compilation error is fixed, this will be a clean N+1 fix.

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.

4 participants