chroma-core / chroma-core/chroma

PersistentLocalHnswSegment loses in-memory state on process exit when sync_threshold not crossed

Open
#6,975 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
29.3k
Forks
2.5k
Avg merge
1d 4h
Merged PRs (30d)
38

Description

## Versions

- chromadb 1.5.5 (Python package, Rust bindings)
- Python 3.12
- macOS 25.1

## Summary

`collection.upsert()` returns successfully but the writes are not durable through clean process exit if fewer than `sync_threshold` (default 1000) records have accumulated since the last persist. The on-disk HNSW pickle is never updated. Writes survive only as `embeddings_queue` WAL — which is purged once both segments' `max_seq_id` advances past them. If that happens before any subsequent operation crosses the threshold, the writes are silently lost.

This affects any deployment with low write volume (e.g. ingestion of a few hundred records per day): clean process exits — including those triggered by `launchctl kickstart`, `systemctl restart`, container redeployment, or manual `Ctrl+C` — produce silent data loss that the API does not surface.

## Reproduction

```python
import chromadb, random

# Run 1 — write 500 records, exit cleanly
client = chromadb.PersistentClient(path='/tmp/repro_chroma')
coll = client.get_or_create_collection('repro', metadata={'hnsw:space': 'cosine'})
ids = [f'i{i}' for i in range(500)]
embs = [[random.random() for _ in range(64)] for _ in range(500)]
coll.upsert(ids=ids, embeddings=embs)
# upsert returned successfully; process exits here
```

After this script, observe that `index_metadata.pickle` in the segment directory has **not** been written. No file at all if first run. Stale mtime if a previous persist had occurred.

```python
# Run 2 — re-open
client2 = chromadb.PersistentClient(path='/tmp/repro_chroma')
coll2 = client2.get_or_create_collection('repro', metadata={'hnsw:space': 'cosine'})
print(coll2.count()) # prints 500 — but only because queue WAL replayed
```

The 500 records appear to be present, but they are reconstructed from the `embeddings_queue` WAL on every reopen. The HNSW pickle on disk does not contain them. **If between Run 1 and Run 2 another writer crosses the 1000 threshold using a different set of records, the original 500 are lost** — `_persist()` fires with the in-memory state at that moment, which by then no longer includes the original 500, and `max_seq_id` advances past their queue rows so they get purged on the next vacuum.

## Expected behaviour

A successful `collection.upsert()` should be durable through clean process exit. Either the data should be persisted on a normal exit path, or there should be a public API to force a flush.

## Actual behaviour

`PersistentLocalHnswSegment._persist()` (`chromadb/segment/impl/vector/local_persistent_hnsw.py:238-272`) is the only place the pickle is written. It is called only from `_apply_batch()` when:

```python
if self._num_log_records_since_last_persist >= self._sync_threshold:
self._persist()
```

`sync_threshold` defaults to 1000 (`hnsw_params.py:80`).

Cross-checks confirming there is no other persist trigger:

- `grep -rn 'atexit' chromadb/` → no matches
- `grep -rn 'signal\.signal\|SIGTERM\|SIGINT' chromadb/` → no matches
- `Collection` and `Client` expose no public `persist()` / `flush()` / `sync()` method
- Rust `Bindings` class exposes only data ops (`add`, `get`, `query`, `upsert`, `delete`, `count`) — no persist API
- `PersistentLocalHnswSegment.stop()` (line 536-538) calls `super().stop()` and `close_persistent_index()`. The latter closes file descriptors only; it does not write.

## Real-world impact

We hit this in a low-write-volume deployment (a few hundred document chunks per day). A 62-chunk ingest run on 16 April 2026 produced 116 stranded chunks total: 61 SQL-only orphans (writes that never reached the HNSW pickle) + 50 HNSW-only ghosts (deletes from the same run, where the SQL row was removed but the HNSW entry was not).

Symptom surfaced weeks later as `kb_search` queries with `where={"source": "document"}` returning `Internal error: Error finding id` — the SQL prefilter included ids the HNSW segment couldn't resolve.

Recovery required diagnosing the SQL/HNSW desync, re-embedding the affected chunks via `collection.upsert()`, then forcing a persist by writing 1100 idempotent re-upserts to cross the threshold deterministically. Recovery tooling and post-mortem available at: `https://github.com/tailormemory/tailor/blob/main/scripts/maintenance/repair_hnsw_index.py` (and the matching `.md`).

## Proposed fixes

Any one of these would resolve the issue. Listed in increasing order of API surface change:

1. **Call `self._persist()` from `PersistentLocalHnswSegment.stop()`** before `close_persistent_index()`. Smallest change. Persists on every clean shutdown.

2. **Register an `atexit` handler in `__init__`** that calls `_persist()` if `_num_log_records_since_last_persist > 0`. Catches more shutdown paths than `stop()` alone.

3. **Expose a public `persist()` / `flush()` method** on `Collection` and `Client`. Lets callers force durability when they know they need it. The most explicit API contract; pairs naturally with #1 for safety.

The first two are non-breaking. The third extends the public API.

## Workaround

For 1.5.5 users running in low-write environments, the workaround we deployed is an idempotent re-upsert burst: after a normal write, re-write the same N records ~⌈1000/N⌉ times to deterministically cross `sync_threshold`. Same data, no semantic effect, just forces persist. Kludgy but reliable.

## Why I'm filing this

Quiet data loss after a successful API call is a serious surprise for anyone treating the persistent client as a database. Documenting the threshold contract more loudly would also help, but a real durability guarantee on shutdown would be much better.

Happy to provide more reproduction detail or test against a candidate fix branch.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with PersistentLocalHnswSegment._persist() and stop() in chromadb/segment/impl/vector/local_persistent_hnsw.py, then check the sync_threshold definition in hnsw_params.py. Reproduce the low-volume upsert and clean-exit sequence, and verify that reopening preserves the records without requiring a threshold-crossing write or leaving the HNSW pickle stale.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, rust
Domain
databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.