chroma-core / chroma-core/chroma
PersistentLocalHnswSegment loses in-memory state on process exit when sync_threshold not crossed
- 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