Support for dictionary in ZSTD compression - #35
Open
Eugene Vignanker (brain-eugenevignanker) wants to merge 7 commits into
Open
Support for dictionary in ZSTD compression#35Eugene Vignanker (brain-eugenevignanker) wants to merge 7 commits into
Eugene Vignanker (brain-eugenevignanker) wants to merge 7 commits into
Conversation
Eugene Vignanker (brain-eugenevignanker)
requested a review
from Alex Steere (dasteere)
August 1, 2026 00:35
…aming - store_compressor.rs: revert a manual line-collapse (>100 cols, unrelated to dictionary work) back to its original two-line form. - store/mod.rs: rename/reword test_store_reader_without_dictionary_footer_... -- leftover from the earlier hash-based footer design (reverted); the footer carries no hash at all now, so the old name/comment was misleading.
…ary is used Byte-for-byte block stacking during merge never decompresses, so it can't exercise the zstd dictionary's own frame checksum -- the one thing that would catch the fixed-for-index-life dictionary invariant ever being violated (bug, race, manual meta.json edit). The existing stacking eligibility check only compared Decompressor (compressor family), which can't see a dictionary change within the same family. - Compressor::has_dictionary(): new predicate. - merger.rs: stacking is now also ineligible whenever the output compressor has a dictionary, forcing the decompress/recompress path so a mismatch fails loudly at merge time instead of silently corrupting the merged segment (surfacing only on some later, unrelated read). - New regression test rotating the dictionary path (not the compressor family) between commits before merging, driven directly through IndexMerger/SegmentSerializer to avoid cfg(test)'s merge-panics-on-error behavior in IndexWriter::merge's own scheduling path.
…ening to Other FileDoesNotExist -> ErrorKind::NotFound, IoError -> the wrapped io::Error's own kind, IncompatibleIndex -> InvalidData. A misconfigured dictionary_path should be as easy to diagnose as any other missing-file error.
Eugene Vignanker (brain-eugenevignanker)
marked this pull request as ready for review
August 7, 2026 17:40
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.
Summary
Adds zstd doc-store dictionary support. The dictionary is a recorded, self-describing property of the index:
ZstdCompressorgainsdictionary: Option<ZstdDictionaryDescriptor>, persisted inmeta.json'sindex_settings.docstore_compression(e.g.zstd(dictionary_path=dict.bin.zst)).ZstdDictionaryDescriptorholds a single field,path— aDirectory-relative path to a sibling file holding the dictionary's bytes (zstd-compressed on disk). No content hash. A dictionary can be multiple megabytes; hashing it on every doc store open (every segment, every reader reload) is real, avoidable cost with no enforcement value if the hash can't reliably be checked cheaply.Compressor::resolve_dictionary(&dyn Directory)reads the path via the existingDirectory::atomic_read, decompresses it, and returnsArc<[u8]>. Callers (SegmentReader,SegmentSerializer/segment_writer's resort path) call this themselves fromsettings().docstore_compression— no newDirectorytrait method, no capability plumbing.CParameter::ChecksumFlag), enabled only when compressing with a dictionary and verified automatically by the decompressor on every block read — a few bytes + a sub-microsecond XXH64 pass per block, not proportional to dictionary size. A missing/mismatched dictionary now fails loudly at first decompress, not silently.StoreWriter/StoreReader/Compressor/Decompressortake an explicitdictionary: Option<&[u8]>(orOption<Arc<[u8]>>) parameter through the compress/decompress path.compress_whole/decompress_wholehelpers (whole-bufferzstd::stream::encode_all/decode_all) for the dictionary file itself — distinct from the existing per-block length-prefixed format, since the dictionary file has no skip-index seeking into it.compression_level), dictionary-mismatch/missing detection at decompress time, and confirming a store with no dictionary never resolves one.Why
Different datasets need different dictionaries, and a reader has no way to detect a missing/mismatched dictionary at read time otherwise — a silent-corruption risk. This makes the dictionary a first-class, self-describing part of the index, with the embedding application (Brainstore) only ever needing to supply it at index creation — every later open derives it purely from that index's own
meta.json.Public API changes
IndexBuilder::new/open_or_createandIndex::open— the entry points Brainstore actually calls to create/open an index — are untouched (zero diff vs base inindex/index_builder.rs,index/index.rs). Every change below is one level down, insidetantivy::store:ZstdDictionaryDescriptor { pub path: String };ZstdCompressorgains a new fielddictionary: Option<ZstdDictionaryDescriptor>(#[serde(default)], backward-compatible). Itszstd(...)meta.jsonstring gains a matchingdictionary_path=<path>option.Compressor::resolve_dictionary(&self, directory: &dyn Directory) -> io::Result<Option<Arc<[u8]>>>/Compressor::has_dictionary(&self) -> bool, and new functionscompress_whole/decompress_whole.StoreWriter::new,StoreReader::open,Compressor::compress_into,Decompressor::decompress/decompress_intoeach gain one newdictionaryparameter.Compressor/ZstdCompressorno longer deriveCopy(stillClone), since the newdictionary/pathfield isn'tCopy.Directorytrait,ManagedDirectory, or the doc store footer format/version.Defense in depth
Merge stacking (
indexer/merger.rs) copies compressed blocks byte-for-byte and never decompresses, so it can't exercise the dictionary's zstd checksum. If the fixed-for-index-life dictionary invariant is ever violated externally (bug, race, manualmeta.jsonedit) while the compressor family stays the same, stacking is now explicitly disabled whenever the output compressor has a dictionary (Compressor::has_dictionary), forcing the decompress/recompress path instead — so a violation fails loudly at merge time rather than silently corrupting the merged segment. Covered by a new regression test that rotates the dictionary path between commits before merging.Not in scope / follow-ups
StoreReader/SegmentReaderconstruction re-reads and re-decompresses the dictionary file. Left as a separate future concern.