Repository navigation
Conversation
`memory_tree::tree::flush::flush_stale_buffers` previously issued `store::get_tree(config, &buf.tree_id)?` once per stale buffer — one SQLite round-trip per row returned by `list_stale_buffers`. On hosts with many stale L0 buffers (e.g. after a long backfill or a cron miss) each flush pass paid N round-trips before the cascade even started. Replace the per-buffer loop with one upfront batched read: - `memory_store::trees::store::get_trees_batch(config, &[ids]) -> Result<HashMap<String, Tree>>` `flush_stale_buffers` now dedups buffer tree_ids into a `HashSet`, issues one `SELECT … WHERE id IN (?,?,…)` (1 round-trip), then per-id HashMap lookup routes each buffer to its own `Tree` for `cascade_all_from`. The L0-only filter in `list_stale_buffers` keeps the same tree_id at most once today, but the dedup is defensive against future widenings of that filter that would otherwise re-pay the cost. ### Defensive chunking — TREES_MAX_FETCH_BATCH = 500 `get_trees_batch` iterates via `chunks(TREES_MAX_FETCH_BATCH)`. - SQLite's compile-time `SQLITE_MAX_VARIABLE_NUMBER` has been ≥ 32 766 since 3.32 → 500 leaves a ~65× safety margin. - The `flush_stale_buffers` call-site passes the deduped distinct tree_id set (typically << 500 in practice) → runs as one query. - Loop is purely defensive: future callers passing larger id lists will not blow up against a host with a lower compile-time SQLite cap. - No volume reduction: all input ids in → all matching rows out; the merged HashMap is byte-identical to one giant query. ### Semantic preservation — no behavioural change - `get_trees_batch` reuses `row_to_tree` so decoded `Tree` values stay bit-identical to the per-row path. - Missing ids stay silently absent from the HashMap, mirroring the per-row `get_tree` `Ok(None)` contract — the existing orphan-buffer warn-and-skip path keeps working unchanged. - Per-buffer iteration order preserved by walking `stale` directly; HashMap lookup keys by `&buf.tree_id`. ### Tests - `get_trees_batch_returns_present_ids_in_map` — both ids present, decoded `Tree` equal to the per-row path bit-for-bit. - `get_trees_batch_empty_input_and_missing_ids` — empty input → empty map (no SQL); missing id silently absent so callers keep their `Ok(None)` skip path. - `flush_seals_multiple_distinct_trees_via_batched_lookup` — two real trees each carry a stale L0 buffer; flush seals both via the batched fetch. Pins the HashMap-keyed-by-id contract: swapping the lookup key to position would route buffers to the wrong `Tree` and mis-write summaries against the wrong `root_id`. ### Companion to tinyhumansai#2927 / tinyhumansai#2941 / tinyhumansai#2948 Same playbook as the topic-rerank batch (tinyhumansai#2927), `fetch_leaves` batch (tinyhumansai#2941), and `hydrate_summary_inputs` batch (tinyhumansai#2948): partition / batch / decorate. Different table, different call-site, same pattern.
📝 WalkthroughWalkthroughThis PR introduces a batched tree-fetch optimization: a new ChangesBatch tree-fetch and flush optimization
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. 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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/openhuman/memory_store/trees/store.rs (1)
130-160: ⚡ Quick winAdd debug logging to the new batched fetch flow.
Logic is correct: numbered placeholders bind positionally, missing ids stay absent from the map (preserving the prior
Ok(None)skip semantics), and the chunking keeps results identical to a single query. However, this new external-call path emits no diagnostics. Adebug/traceline at entry/exit (counts and chunk windows) would aid grep-based debugging of the cron flush path.♻️ Suggested logging
with_connection(config, |conn| { let mut out: HashMap<String, Tree> = HashMap::with_capacity(tree_ids.len()); for window in tree_ids.chunks(TREES_MAX_FETCH_BATCH) { + log::trace!( + "[tree::store] get_trees_batch window_len={} total_ids={}", + window.len(), + tree_ids.len() + ); let placeholders = (1..=window.len())As per coding guidelines: "add substantial, development-oriented logs on new/changed flows at entry/exit points, branch decisions, external calls ... use structured, grep-friendly context (stable prefixes like
[domain]...)".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/openhuman/memory_store/trees/store.rs` around lines 130 - 160, Add development-oriented debug/trace logging to the batched fetch flow in get_trees_batch: log at entry the incoming tree_ids count and relevant config/Context, log each chunk window (index, size, and the placeholder range or first/last id) before executing the SELECT, and log exit with total found rows and out.len(); emit logs using the project's structured logger (e.g., tracing::debug! or log::debug!) so they include a stable tag like "[mem-tree]" and reference TREES_MAX_FETCH_BATCH, mem_tree_trees, and row_to_tree for grepability. Ensure logs are inexpensive (only format when enabled) and do not change behavior or return values from with_connection/get_trees_batch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/openhuman/memory_store/trees/store.rs`:
- Around line 130-160: Add development-oriented debug/trace logging to the
batched fetch flow in get_trees_batch: log at entry the incoming tree_ids count
and relevant config/Context, log each chunk window (index, size, and the
placeholder range or first/last id) before executing the SELECT, and log exit
with total found rows and out.len(); emit logs using the project's structured
logger (e.g., tracing::debug! or log::debug!) so they include a stable tag like
"[mem-tree]" and reference TREES_MAX_FETCH_BATCH, mem_tree_trees, and
row_to_tree for grepability. Ensure logs are inexpensive (only format when
enabled) and do not change behavior or return values from
with_connection/get_trees_batch.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a2ef0810-50b7-440a-a725-46946a92dd65
📒 Files selected for processing (3)
src/openhuman/memory_store/trees/store.rssrc/openhuman/memory_store/trees/store_tests.rssrc/openhuman/memory_tree/tree/flush.rs
oxoxDev
left a comment
There was a problem hiding this comment.
LGTM — clean, behavior-preserving perf fix. get_trees_batch replaces the per-id get_tree loop in flush_stale_buffers with one batched SELECT … WHERE id IN (…) per 500-id window, mirroring the existing get_chunks_batch/get_scores_batch pattern. De-dups tree_ids defensively, and missing rows stay absent from the map so the orphan-buffer warn-and-skip contract is preserved bit-for-bit. Tests cover present/empty/missing. Approving.
staimoorulhassan
left a comment
There was a problem hiding this comment.
CodeRabbit-style Review — PR #2976
Walkthrough
Replaces N per-buffer get_tree() SQLite calls in flush_stale_buffers with one batched SELECT … WHERE id IN (…) via new get_trees_batch(). Adds 3 targeted unit tests. Companion to the batch-fetch series (#2927, #2941, #2948).
Findings
🟡 MINOR — Dynamic SQL construction via format!() — add a safety comment
get_trees_batch builds the WHERE id IN ({placeholders}) clause with format!(). The values are correctly bound via typed rusqlite::ToSql params (no interpolation), but a future maintainer who doesn't notice this distinction could be misled into thinking the code is SQL-injectable. A one-line comment is all that's needed:
// placeholders are positional bind params (?1,?2,…) — values are never interpolated
let sql = format!(
"SELECT … WHERE id IN ({placeholders})"
);🟡 MINOR — Prefer params_from_iter over Vec<&dyn rusqlite::ToSql>
The current approach:
let params: Vec<&dyn rusqlite::ToSql> =
window.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
let rows = stmt.query_map(params.as_slice(), row_to_tree)?;is valid but fragile (raw trait-object lifetime). The idiomatic rusqlite pattern for dynamic params is:
let rows = stmt.query_map(
rusqlite::params_from_iter(window.iter().map(String::as_str)),
row_to_tree,
)?;params_from_iter was introduced specifically for this use-case and avoids the manual &dyn dance.
🟡 MINOR — Document that the dedup is for DB efficiency, not semantic ordering
let distinct_tree_ids: Vec<String> = {
let mut seen = std::collections::HashSet::new();
// …
};The insertion-order-preserving dedup is correct and deterministic (because list_stale_buffers returns rows in ORDER BY order). But a future reader simplifying this to .sort().dedup() would still be correct today — the per-buffer loop walks stale directly for ordering, not distinct_tree_ids. A short comment prevents the "why not just sort+dedup?" question:
// Preserve list_stale_buffers order so the HashMap lookup below
// stays predictable; dedup is purely to avoid redundant DB params.🔵 NITPICK — Consider splitting the large flush regression test
flush_seals_multiple_distinct_trees_via_batched_lookup does two things: verifies that both trees are sealed (happy path) and pins the HashMap-keyed-by-id contract (regression). Splitting into:
flush_seals_multiple_trees_batched— happy pathflush_keyed_by_tree_id_not_position— regression assertion
would make the test intent clearer and failure messages more diagnostic.
Verdict
No blockers. Four minor/nitpick comments. The algorithm, chunking strategy, semantic-preservation argument, and test coverage are all excellent — this is a well-engineered perf improvement. Approve once comments are considered.
# Conflicts: # src/openhuman/memory_store/trees/store_tests.rs
Addresses CodeRabbit (add diagnostics to the new batched-fetch flow) and @staimoorulhassan (document that get_trees_batch interpolates only the placeholder count, never id values — all ids bound via typed ToSql params).
…sai#2976) Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
…inyhumansai#2976 follow-up) (tinyhumansai#3020) Co-authored-by: Maciej Myszkiewicz <mmyszkiewicz@bwcoders.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai> Co-authored-by: Taimoor <astikkosapparel009@gmail.com>
Summary
memory_tree::tree::flush::flush_stale_bufferspreviously calledstore::get_tree(config, &buf.tree_id)?once per stale buffer returned bylist_stale_buffers— i.e. one SQLite round-trip per stale row before the cascade even started. On hosts with many stale L0 buffers (e.g. after a long backfill, a cron miss, or a process restart during ingest) every flush pass re-paid that cost.Replace the per-buffer loop with one upfront batched read:
memory_store::trees::store::get_trees_batch(config, &[ids]) -> Result<HashMap<String, Tree>>flush_stale_buffersnow dedups buffertree_ids into aHashSet, issues oneSELECT … WHERE id IN (?,?,…)(so 1 round-trip), then per-idHashMaplookup routes each buffer to its ownTreeforcascade_all_from. Thelevel = 0filter inlist_stale_bufferskeeps the sametree_idat most once today, but the dedup is defensive against future widenings of that filter that would otherwise re-pay the cost.Defensive chunking —
TREES_MAX_FETCH_BATCH = 500get_trees_batchiterates viachunks(TREES_MAX_FETCH_BATCH).SQLITE_MAX_VARIABLE_NUMBERhas been ≥ 32 766 since 3.32 → 500 leaves a ~65× safety margin.flush_stale_bufferscall-site passes the deduped distincttree_idset (typically << 500 in practice) → runs as one query today.HashMapis byte-identical to one giant query.Semantic preservation — no behavioural change
get_trees_batchreusesrow_to_treeso decodedTreevalues stay bit-identical to the per-row path.HashMap, mirroring the per-rowget_treeOk(None)contract — so the existing orphan-buffer warn-and-skip path keeps working unchanged.staledirectly;HashMaplookup keys by&buf.tree_id(not byenumerate()position).cascade_all_fromfailure still bubbles up with the same error semantics as before.Test plan
New unit tests (3 total)
trees store (
src/openhuman/memory_store/trees/store_tests.rs):get_trees_batch_returns_present_ids_in_map— both ids present in the map, decodedTreeequal to the per-row path bit-for-bit (samerow_to_treedecoder).get_trees_batch_empty_input_and_missing_ids— empty input → empty map (no SQL); missing id silently absent so callers keep theirOk(None)skip path.flush (
src/openhuman/memory_tree/tree/flush.rs):flush_seals_multiple_distinct_trees_via_batched_lookup— two real trees each carry a stale L0 buffer; oneflush_stale_bufferspass seals both via the batched fetch. Pins the HashMap-keyed-by-id contract: swapping the lookup key toenumerate()position would route buffers to the wrongTreeand mis-write summaries against the wrongroot_id— the test assertscount_summaries(tree_a) == 1 && count_summaries(tree_b) == 1.Existing surface — verified no regression
memory_store::trees::andmemory_tree::tree::flush::continue to pass (25 + 4 = 29 tests, 0 failures).cargo fmt --check— clean.Companion to #2927 / #2941 / #2948
Same playbook as the topic-rerank batch (#2927),
fetch_leavesbatch (#2941), andhydrate_summary_inputsbatch (#2948): partition / batch / decorate. Different table, different call-site, same pattern.get_trees_batchis a building block several remaining N+1 hot-paths in the graph layer will reuse.Summary by CodeRabbit
Release Notes
Performance
Tests