fix(vectors): harden large-repo merge_insert (mem pool + PK scalar index)#393
Merged
Merged
Conversation
… subprocess cocoindex is a bare pass-through to lancedb — it never sets a Session or memory_limit, and has zero references to memory_pool / LANCE_MEM_POOL_SIZE across its Python package and 56 MB Rust engine. So it inherits lance's ~100 MiB FairSpillPool default, which is tuned for query workloads and is too small for the single big merge_insert a flow component emits at commit. On --full-reprocess (all rows match the existing table → bulk-update path) the hash-join build side can exceed the pool around 75k–100k chunks, raising: RuntimeError: lance error: Resources exhausted: Failed to allocate ~200 MB for HashJoinInput ... 100.0 MB remain available for the total pool The crash is allocation-granularity dependent and non-deterministic (observed once under sustained bench load; does not reproduce on isolated reruns up to 200k), but the mechanism and the LANCE_MEM_POOL_SIZE knob are source-confirmed (the error string matches DataFusion 52.4.0 embedded in lancedb._lancedb.abi3.so; the var is read once at session init). Raise the default to 1 GiB via cocoindex_subprocess_env_defaults() — applied with setdefault, so an operator's own LANCE_MEM_POOL_SIZE still wins. The pool is a reservation ceiling, not a pre-allocation, so 1 GiB costs nothing upfront and is safe on constrained hosts. Covers ~500k-chunk bulk-update build sides; larger repos can raise it further. Increment is unaffected (tiny batch → tiny hash table); only the full-reprocess write path is at risk. Co-Authored-By: Claude <noreply@anthropic.com>
cocoindex's merge_insert defaults to use_index=True but never creates a scalar index on the primary key (declaring primary_key in the schema does NOT auto- build a lance index), so every merge_insert — increment included — is a forced full scan of the id column, O(existing rows). Measured ~flat through 100k (<5 ms) because the constant is tiny (columnar PK-only scan), but it is O(N) and would dominate increment wall-clock on a multi-million-chunk repo. With the index present the join does lookups (~O(batch*log N)). Add a best-effort BTREE scalar index on "id" in optimize_lance_tables(), right after table.optimize() succeeds, mirroring the existing FTS block: local import of the config class (lancedb.index.BTree), replace=True for idempotency, and a non-fatal broad except that never flips the "ok" status. Runs after every init/increment/reprocess, so the index exists before the next merge_insert; table.optimize() maintains it on subsequent runs. Verified end-to-end (real lancedb): optimize on a temp index yields [Index(BTree, id), Index(FTS, text)] on all three tables, and empty tables (sql/yaml at count=0) create the index without error. Tests: the existing test_optimize_builds_fts_index_after_success defined only FTS in its fake lancedb.index module, so the BTree import silently no-op'd under the broad except — fixed by also defining BTree (via a shared _install_fake_lancedb_index helper) and asserting by column rather than call count. Add test_optimize_builds_btree_pk_index_after_success, mutation-checked (fails without this change). Co-Authored-By: Claude <noreply@anthropic.com>
HumanBean17
added a commit
that referenced
this pull request
Jul 6, 2026
…row-delete hang (#394) reprocess (cocoindex ``update --full-reprocess`` against an EXISTING table) took the in-place bulk-update merge_insert path, which cocoindex implements as ~one deletion-vector + version commit PER matched row — O(rows) of tiny file IO. Measured on Shopizer (3475 chunks): 3481 versions / 3474 deletion files / 83s sys time, degrading to a multi-minute 99%-CPU hang (native ``_lancedb.abi3.so``, main thread parked in ``kevent``) that reproduces every run and worsens with repo size — a larger repo hung 15+ min at "99%". init (insert into a fresh table) is unaffected: 3 versions / 0 deletions / 4.5s sys. drop+recreate is semantically identical to a full rebuild (all rows are recomputed either way), so make reprocess DROP the Lance target tables first and let cocoindex recreate them via the fast INSERT path. Applied at both ``--full-reprocess`` sites: - ``pipeline.run_cocoindex_update`` → covers ``reprocess --vectors-only`` - ``server.run_refresh_pipeline`` → covers the default ``reprocess`` (vectors+graph) Drop failure is non-fatal: a non-preflight failure logs and falls back to the slow in-place path; a 127/126 preflight stub (graph-only installs) stays silent. Validated on Shopizer: reprocess 105s→hang → 48s (≈ init), sys 83s→4.9s, deletions 3474→0, versions 3481→3. Independent of #392 (concurrency) and #393 (mem-pool/PK-index): ``LANCE_MEM_POOL_SIZE=4GiB`` did not help — this is file-IO amplification, not memory. Tests: ``tests/test_pipeline.py`` — full_reprocess drops exactly once; increment does not drop (would lose the table); drop failure falls back to in-place; a preflight drop stub is silent. Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
Follow-up to the init-performance investigation (lever #3). After #392 (concurrent file drain) shipped, I characterized how
merge_insertbehaves at scale. Two independent issues surfaced; this PR fixes both. They are separate commits, independently revertable.1.
--full-reprocesscan crash the hash-join memory pool on large reposcocoindex is a bare pass-through to lancedb — it never sets a
Session/memory_limit, with zero references tomemory_pool/LANCE_MEM_POOL_SIZEacross its Python package and its 56 MB Rust engine (source-confirmed). So it inherits lance's ~100 MiBFairSpillPooldefault, tuned for query workloads — too small for the single bigmerge_inserta flow component emits at commit.On
--full-reprocess, every row matches the existing table (bulk-update path), so the hash-join build side is large. Around 75k–100k chunks it can exhaust the pool:Honest caveat: the crash is allocation-granularity dependent and non-deterministic — observed once under sustained bench load (a real CLI run does lots of embed/graph memory churn before the final merge_insert, so it's closer to the crash-prone case than an isolated probe); it did not reproduce on clean isolated reruns up to 200k. The mechanism and the
LANCE_MEM_POOL_SIZEknob are source-confirmed (error string matches DataFusion 52.4.0 embedded inlancedb._lancedb.abi3.so; var read once at session init).Fix: raise the default to 1 GiB in
cocoindex_subprocess_env_defaults()(applied viasetdefault, so an operator's ownLANCE_MEM_POOL_SIZEstill wins). The pool is a reservation ceiling, not a pre-allocation — 1 GiB costs nothing upfront and is safe on constrained hosts. Covers ~500k-chunk build sides; larger repos can raise it further.Scope: first
init(insert into fresh table) andincrement(small batch) are unaffected — onlyreprocesswalks into this.2. Every
merge_insertdoes a full PK scan (no scalar index)cocoindex's
merge_insertdefaults touse_index=True, but cocoindex never creates a scalar index on the primary key (declaringprimary_keyin the schema does NOT auto-build a lance index). So everymerge_insert— increment included — is a forced full scan of theidcolumn, O(existing rows).Measured ~flat through 100k (<5 ms) because the constant is tiny (columnar PK-only scan), but it is O(N) and would dominate increment wall-clock on a multi-million-chunk repo. With the index present, the join does lookups (~O(batch·log N)).
Fix: build a BTREE scalar index on
idinoptimize_lance_tables(), right aftertable.optimize()succeeds — mirroring the existing FTS block (locallancedb.index.BTreeimport,replace=Truefor idempotency, non-fatal broadexceptthat never flips the "ok" status). Runs after every init/increment/reprocess, so the index exists before the nextmerge_insert;table.optimize()maintains it afterward.Validation
optimize_lance_tables()on a temp index yields[Index(BTree, id), Index(FTS, text)]on all three tables; empty tables (sql/yaml at count=0) create the index without error.cocoindex_subprocess_env_defaults()now returns bothCOCOINDEX_MAX_INFLIGHT_COMPONENTS=256andLANCE_MEM_POOL_SIZE=1073741824.lance_optimizechange (the pre-existing FTS test's fakelancedb.indexmodule defined onlyFTS, so the BTree import silently no-op'd under the broadexcept; fixed by also definingBTreeand asserting by column rather than call count).Bench:
~/jrag-bench/bench_merge_insert.py.🤖 Generated with Claude Code