Skip to content

fix: batch upserts in miner to prevent ChromaDB 1.5.x compaction crashes - #796

Closed
IzmanIzy wants to merge 1 commit into
MemPalace:developfrom
IzmanIzy:fix/batch-upsert-chromadb-compaction
Closed

fix: batch upserts in miner to prevent ChromaDB 1.5.x compaction crashes#796
IzmanIzy wants to merge 1 commit into
MemPalace:developfrom
IzmanIzy:fix/batch-upsert-chromadb-compaction

Conversation

@IzmanIzy

Copy link
Copy Markdown

Summary

  • Batch all chunks per file into a single collection.upsert() call instead of upserting each chunk individually. This reduces WAL write pressure that causes the Rust compactor in ChromaDB >= 1.5 to crash.
  • Add periodic checkpoints (every 200 files) that release and re-acquire the collection, giving the compactor time to flush background work.
  • Release collection reference at the end of mining for a clean shutdown.

Problem

When mining projects with 100+ files, the miner issues thousands of individual upserts. On ChromaDB 1.5.x this causes:

  1. Segfault (exit code 139) — the Rust compactor corrupts the metadata segment during concurrent individual writes
  2. InternalError: Failed to apply logs to the metadata segment — WAL entries accumulate faster than compaction can process them

Both errors are intermittent and depend on project size, making them hard to reproduce in small test suites but consistent on real-world knowledge bases (300+ files).

Root Cause

ChromaDB's Rust compactor (introduced in 1.5.x) runs in a background thread. Individual upserts create one WAL entry each, and 2000+ entries in rapid succession overwhelm the compactor's ability to merge them atomically. The previous code already had a comment about hnswlib's thread-unsafe updatePoint path causing segfaults on macOS ARM — this is the same class of bug on the compaction side, now affecting all platforms.

Testing

  • 37 existing miner tests pass (pytest tests/ -k "mine" — 37 passed, 0 failed)
  • Verified on a real 406-file knowledge base (3026 drawers) with ChromaDB 1.5.7 — zero crashes, clean completion with two checkpoint flushes at file 200 and 400
  • Lint (ruff check) and format (ruff format --check) pass
  • No changes to public API or CLI interface

Test plan

  • ruff check . passes
  • ruff format --check . passes
  • pytest tests/ -v --ignore=tests/benchmarks -k "mine" — 37 passed
  • Manual test: mine 406-file project with ChromaDB 1.5.7 — 3026 drawers, 0 crashes
  • Verify re-mining (modified files) still works correctly
  • Test with ChromaDB 0.6.x to confirm backward compatibility

🤖 Generated with Claude Code

@jphein

jphein commented Apr 13, 2026

Copy link
Copy Markdown
Collaborator

This addresses the same WAL pressure issue we tackled in #629 — worth noting that PR also adds bulk mtime pre-fetch (bulk_check_mined()) and optional concurrent mining via --workers. The single-file batch approach here is cleaner for a targeted fix though. Might be worth coordinating so the two PRs don't conflict — happy to rebase #629 on top of this if it merges first.

1 similar comment
@IzmanIzy

Copy link
Copy Markdown
Author

This addresses the same WAL pressure issue we tackled in #629 — worth noting that PR also adds bulk mtime pre-fetch (bulk_check_mined()) and optional concurrent mining via --workers. The single-file batch approach here is cleaner for a targeted fix though. Might be worth coordinating so the two PRs don't conflict — happy to rebase #629 on top of this if it merges first.

@igorls igorls added area/mining File and conversation mining bug Something isn't working labels Apr 14, 2026
Adapts the approach from @IzmanIzy's PR MemPalace#796 to current develop, which
diverged after that PR was opened — ``add_drawer`` now populates
additional metadata fields (``normalize_version``, ``hall``, ``entities``,
``source_mtime``) that a verbatim rebase of MemPalace#796 would have regressed.

Changes:

- Extract ``_build_drawer_payload()`` helper — single source of truth for
  drawer ID + metadata construction, shared by ``add_drawer`` (single
  drawer, kept as a thin wrapper for backwards compat with tests) and
  ``process_file`` (batched per-file path).
- ``process_file`` now accumulates all chunks from one file into batch
  lists and issues a single ``collection.upsert()`` call with them all,
  instead of one upsert per chunk. Preserves every metadata field
  ``add_drawer`` sets, including the ones added after MemPalace#796 was opened.
- ``mine()`` adds a periodic checkpoint every 200 files that releases and
  re-acquires the collection (and closets collection), letting the Rust
  compactor flush buffered WAL entries between batches.
- ``mine()`` releases both collection references at the end of mining
  so the final WAL entries flush cleanly before the process exits.

## Problem

A typical project mine issues thousands of individual ``col.upsert()``
calls. ChromaDB 1.5.x's Rust compactor runs concurrently with writes
and falls behind under this pattern, surfacing as:

- ``Segfault (exit 139)`` — the compactor corrupts the metadata segment
  during concurrent individual writes
- ``chromadb.errors.InternalError: Error in compaction: Failed to apply
  logs to the metadata segment``
- Later reads crash with ``mismatched types; Rust type u64 (as SQL type
  INTEGER) is not compatible with SQL type BLOB`` — the compactor left
  half-migrated rows with ``seq_id`` values still stored as BLOB instead
  of decoded to INTEGER

## Scope

``tests/test_miner.py::test_add_drawer`` and
``tests/test_hall_detection.py::test_add_drawer_includes_hall`` both call
``miner.add_drawer()`` directly and expect ``True`` + a single drawer
with full metadata. Both pass with the backwards-compat wrapper.

Related: MemPalace#796 (original PR, now has develop conflicts), MemPalace#899 (MCP
server library staleness — the other vector that creates compaction
crashes upstream of this fix).

Co-Authored-By: IzmanIzy <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@messelink

messelink commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Hi @IzmanIzy — I hit the exact compaction crash this PR fixes while mining into an existing palace that had accumulated BLOB-typed seq_id rows from a stale long-running MCP server (see #899 for that adjacent bug). The batching approach here solved my immediate problem, so thank you for writing it up.

I wanted to flag that this PR has a non-trivial rebase conflict against current develop: since it was opened, add_drawer() on develop has grown three new metadata fields that your PR's inlined version in process_file() doesn't reproduce — a verbatim cherry-pick would silently regress them. Specifically:

  • normalize_version (from palace.NORMALIZE_VERSION)
  • hall (from detect_hall(content))
  • entities (from _extract_entities_for_metadata(content))

The source_mtime try/except is also now in the common path.

I rebased your approach onto current develop and adapted it to preserve those fields. Rather than inlining metadata construction in process_file(), I extracted a _build_drawer_payload() helper and kept add_drawer() as a thin single-drawer wrapper (for backwards compat with the tests/test_miner.py::test_add_drawer and tests/test_hall_detection.py::test_add_drawer_includes_hall tests, which call add_drawer directly and check for normalize_version / hall in the stored metadata). The batched per-file upsert and the periodic checkpoint logic are kept as you wrote them.

Commit: messelink/mempalace@9cc1365
Branch: messelink:fix/batch-upsert-chromadb-compaction (clean off current upstream/develop)

Test I ran

A delete-and-remine of a 20-file subtree (mix of markdown docs and chat transcripts) into an existing ~14.8k-drawer palace. Result: 1,324 drawers filed in a single end-to-end run without any compaction errors. The largest single-file batch was 618 drawers in one collection.upsert() call — exactly the stress case your PR targets. Search against the re-mined content works correctly afterward.

Previously (without the batching), the same mine crashed with Error in compaction: Error reading from metadata segment reader: error occurred while decoding column 0: mismatched types; Rust type u64 (as SQL type INTEGER) is not compatible with SQL type BLOB on the first upsert, and I lost a chunk of drawers to silent file-already-mined skipping on retry.

How to proceed

Totally your call — I don't want to step on your PR. Three options:

  1. You fetch my branch and apply it to yoursgit fetch https://github.com/messelink/mempalace.git fix/batch-upsert-chromadb-compaction && git reset --hard FETCH_HEAD on your local fix/batch-upsert-chromadb-compaction, then force-push. Cleanest attribution (your PR, just rebased). The commit already credits you as Co-Authored-By.
  2. You enable "Allow edits from maintainers" on this PR, and I can't push directly (I'm not a maintainer of milla-jovovich/mempalace), but one of the maintainers could push my patch to your branch.
  3. If you're unavailable or prefer, I'm happy to open a separate PR that supersedes this one, credits you, and links back here.

Let me know which you prefer. Happy to wait a few days for a reply before doing option 3.

cc @milla-jovovich for awareness — this PR becomes unblocked with the rebase.

@bensig

bensig commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

hey @IzmanIzy — this conflicts with develop now. pls rebase and we can merge. thanks!

@bensig

bensig commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

@IzmanIzy pls check conflict

@messelink

Copy link
Copy Markdown
Contributor

@bensig — just flagging that my comment above (03:26 UTC) has the rebased version ready if it helps move this along. Branch: messelink:fix/batch-upsert-chromadb-compaction, clean off current develop. Up to @IzmanIzy whether they want to pull it into their branch or I open a new PR.

@IzmanIzy
IzmanIzy force-pushed the fix/batch-upsert-chromadb-compaction branch from f15bffd to 9cc1365 Compare April 15, 2026 14:11
@IzmanIzy
IzmanIzy requested a review from igorls as a code owner April 15, 2026 14:11
@IzmanIzy

Copy link
Copy Markdown
Author

Rebased onto current develop using @messelink's cleaned-up version (thanks! 🙏). Conflict resolved, ready to merge.

@bensig good to go.

messelink added a commit to messelink/mempalace that referenced this pull request Apr 16, 2026
Adapts the approach from @IzmanIzy's PR MemPalace#796 to current develop, which
diverged after that PR was opened — ``add_drawer`` now populates
additional metadata fields (``normalize_version``, ``hall``, ``entities``,
``source_mtime``) that a verbatim rebase of MemPalace#796 would have regressed.

Changes:

- Extract ``_build_drawer_payload()`` helper — single source of truth for
  drawer ID + metadata construction, shared by ``add_drawer`` (single
  drawer, kept as a thin wrapper for backwards compat with tests) and
  ``process_file`` (batched per-file path).
- ``process_file`` now accumulates all chunks from one file into batch
  lists and issues a single ``collection.upsert()`` call with them all,
  instead of one upsert per chunk. Preserves every metadata field
  ``add_drawer`` sets, including the ones added after MemPalace#796 was opened.
- ``mine()`` adds a periodic checkpoint every 200 files that releases and
  re-acquires the collection (and closets collection), letting the Rust
  compactor flush buffered WAL entries between batches.
- ``mine()`` releases both collection references at the end of mining
  so the final WAL entries flush cleanly before the process exits.

## Problem

A typical project mine issues thousands of individual ``col.upsert()``
calls. ChromaDB 1.5.x's Rust compactor runs concurrently with writes
and falls behind under this pattern, surfacing as:

- ``Segfault (exit 139)`` — the compactor corrupts the metadata segment
  during concurrent individual writes
- ``chromadb.errors.InternalError: Error in compaction: Failed to apply
  logs to the metadata segment``
- Later reads crash with ``mismatched types; Rust type u64 (as SQL type
  INTEGER) is not compatible with SQL type BLOB`` — the compactor left
  half-migrated rows with ``seq_id`` values still stored as BLOB instead
  of decoded to INTEGER

## Scope

``tests/test_miner.py::test_add_drawer`` and
``tests/test_hall_detection.py::test_add_drawer_includes_hall`` both call
``miner.add_drawer()`` directly and expect ``True`` + a single drawer
with full metadata. Both pass with the backwards-compat wrapper.

Related: MemPalace#796 (original PR, now has develop conflicts), MemPalace#899 (MCP
server library staleness — the other vector that creates compaction
crashes upstream of this fix).

Co-Authored-By: IzmanIzy <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rosschurchill added a commit to rosschurchill/mempalace that referenced this pull request Apr 18, 2026
…lace#796)

Pre-Phase-5 each chunk of a file was upserted one-at-a-time via
add_drawer in a per-chunk loop. On the local (PersistentClient) backend
this was merely wasteful; after Phase 5 introduced the HTTP client mode,
it became one network round-trip per chunk. Mining a 250-chunk file over
the QNAP stack paid 250 RTTs.

Collapse that into a single batched call path:

- palace.py: add upsert_in_batches(collection, ids, docs, metas) with a
  DRAWER_UPSERT_BATCH_SIZE = 100 cap (tuned conservatively — bigger
  batches give diminishing returns and risk embedder working-set growth
  on local mode).
- miner.py: extract _build_drawer_record() from add_drawer so process_file
  can build all records up front, then upsert in ⌈N/100⌉ calls. add_drawer
  stays as a thin single-chunk compat shim for existing callers/tests.
- convo_miner.py: same pattern in _file_chunks_locked.

Behavior change worth flagging: the old convo_miner had a per-chunk
`except "already exists"` swallow. Upsert is idempotent by contract so
that branch was dead — batching naturally removes it. If ChromaDB ever
starts surfacing "already exists" from upsert (it shouldn't), we'd now
fail the whole batch instead of silently skipping.

New fake-collection tests assert the batching contract without needing a
live embedder:
- test_upsert_in_batches_splits_into_fixed_size_calls: 250 items → 3
  calls of 100/100/50, ids preserved in order
- test_process_file_batches_upserts_for_large_file: end-to-end via
  process_file, asserts ⌈N/100⌉ upsert calls with unique ids

Not a crash fix (the notes' stale framing); a round-trip cost fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@messelink

Copy link
Copy Markdown
Contributor

@IzmanIzy heads-up — looks like the failure modes this PR was preventing have been addressed in other paths since your rebase. As of current develop:

The one piece not in develop is the 200-file periodic collection release/re-acquire from this PR. If you've seen crashes that only the checkpoint specifically catches, worth flagging here. Otherwise this PR may be safe to close.

@igorls
igorls changed the base branch from main to develop May 17, 2026 19:34
@igorls

igorls commented Jun 6, 2026

Copy link
Copy Markdown
Member

Thanks! Per-file upsert batching already landed on develop (DRAWER_UPSERT_BATCH_SIZE), which covers the 1.5.x compaction crash; the remaining 200-file checkpoint here is speculative. Closing as superseded — please reopen if you still hit the crash on current develop.

@igorls igorls closed this Jun 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/mining File and conversation mining bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants