Skip to content

Replace the trie index format - #15534

Open
zaidoon1 wants to merge 2 commits into
meta:mainfrom
zaidoon1:zaidoon/udi-12-trie-index-v2
Open

zaidoon1 wants to merge 2 commits into
meta:mainfrom
zaidoon1:zaidoon/udi-12-trie-index-v2

Conversation

@zaidoon1

@zaidoon1 zaidoon1 commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

This replaces the on-disk format of the experimental trie index (TrieIndexFactory). The LOUDS format is deleted outright, with no format option or migration path. As far as I know nobody uses it.

Why not keep LOUDS

The point of the trie was an index smaller than the standard one, and LOUDS never got there. On every key shape I tried it was 1.3x to 2.7x bigger than the standard index even at restart interval 1, and Get was up to 2x slower. Most of the bytes go to per-edge child position tables, which also get copied to the heap when the SST opens, and to a seqno table that's written whether it's needed or not.

It also has bugs:

  • an empty user key as a block separator gets dropped, so the flush fails with Corruption and writes stop (in kStandardDefault the trie is silently skipped instead)
  • a data block offset or size past 4 GiB fails the flush or compaction with NotSupported, in every mode
  • DB::Open accepts a non-bytewise comparator, and then every flush fails
  • a block with bad counts but a valid checksum can throw or read out of bounds
  • arrays are stored in host byte order, so big-endian is broken

Fixing those would still leave an index that loses to the standard one, so I replaced the format.

The new format

It's the block-tree layout Lucene and tantivy use. The prefix shared by all separators is stored once. Separators are grouped 8 to a bucket and front coded, and a path-compressed trie over each bucket's first separator sends a Seek to one bucket. Block offsets are a linear frame-of-reference column, and sizes come from consecutive offsets when blocks are evenly spaced. Two small side tables cover user keys that span several blocks and blocks that need a seqno tag. Everything is little-endian, 64-bit and read in place; the spec is at the top of utilities/trie_index/trie.h.

Opening an index validates the header, the section and column layouts, the side tables and the trie. It doesn't decode every block offset or bucket position. Instead the iterator checks a block's derived size when it lands on the block, keeps bucket reads inside the bucket data, and bounds-checks every entry it decodes, returning Corruption for anything out of bounds. Like the other index blocks, the trie relies on the block checksum for anything subtler.

#14409 turned mmap_read off in the crash test and db_stress because of LOUDS's zero-copy pointers. The new reader also reads its block in place, but the block always outlives it: an mmap'd block points into the table reader's own mapping, and the block cache only stores heap copies. So the exclusion is gone.

TrieIndexFactory::ValidateOptions() rejects non-bytewise comparators, so DB::Open() and CreateColumnFamily() fail up front instead of every flush failing later. SetOptions() doesn't run that validation yet; #15582 fixes that. kStandardOnly ignores the factory, so it still works with any comparator.

The docs also said rolling back from kCustomDefault takes effect immediately. It doesn't: index routing is fixed when a table reader opens, so the docs now say an index_mode change only reaches SSTs opened afterwards.

Compatibility

This is a one-way door for any DB that writes SSTs with the trie. DBs without TrieIndexFactory, or running kStandardOnly, aren't affected. The block keeps the old magic but bumps the version to 2.

  • Upgrading: an old trie block fails with NotSupported, and the message says what to do. In kStandardDefault the SST falls back to the standard index; the other modes fail to open it. Run kStandardDefault and compact before switching. SSTs that 11.14 writes in kCustomOnly have no standard index, so they have to be rewritten in another mode before upgrading.
  • Downgrading: older releases reject the new blocks with Corruption. 11.14 falls back to the standard index in kStandardDefault; 11.13 and earlier can't open the new SSTs in any mode while the factory is configured. Remove the factory first.

The release note covers this.

Numbers

Isolated index

Release db_bench on an Apple Silicon laptop, single thread. 3M keys per shape, 100-byte values, no compression, 4 KiB blocks, format_version 8, 10-bit bloom, everything in the block cache. Each variant is written with SstFileWriter from the same sorted keys and ingested, so the data blocks are identical and only the index differs. Medians of 3 rounds in random order; anything under about 3% is noise.

Shapes: rand16 is 16 random bytes, uuid a textual UUIDv4, url reversed host + path + id, tsdb series id + tag hash + timestamp, and dbint db_bench's --key_size=48 --prefix_size=32 --keys_per_prefix=100.

rand16 uuid url tsdb dbint
index B/block, restart 1 15.0 17.6 28.7 25.8 44.1
index B/block, restart 16 5.4 6.2 9.8 6.5 16.0
index B/block, trie 4.4 5.3 9.1 5.5 17.0
Get us/op, restart 1 1.412 1.637 1.752 1.494 1.619
Get us/op, restart 16 1.493 1.685 1.834 1.472 1.641
Get us/op, trie 1.346 1.612 1.704 1.422 1.607
Seek+10 us/op, restart 1 2.704 2.752 3.212 2.639 2.780
Seek+10 us/op, restart 16 2.654 2.802 3.343 2.660 2.777
Seek+10 us/op, trie 2.449 2.686 3.241 2.502 2.689
reverse Seek+10 us/op, restart 1 3.611 3.877 4.695 3.551 3.924
reverse Seek+10 us/op, restart 16 3.680 3.945 4.914 3.646 3.905
reverse Seek+10 us/op, trie 3.539 3.892 4.766 3.584 3.856
build CPU s, restart 1 0.245 0.574 0.609 0.236 0.263
build CPU s, restart 16 0.242 0.580 0.614 0.234 0.259
build CPU s, trie 0.244 0.585 0.615 0.237 0.259

So it's about the size of restart 16 with lookups as fast as restart 1 or better, and reverse seeks and build time are a wash. Where it doesn't win:

  • the trie block isn't compressed, so with compression on, files were up to 0.6% bigger than restart 16
  • it's 6% bigger than restart 16 on dbint, and 3-5% bigger on long URL-like keys with 16 KiB blocks
  • full scans are 0-3% slower than restart 1
  • opening a table still costs more with the trie than without it (see below)

Through the DB

Release db_bench on the same laptop: fillrandom of 20M keys with 16-byte keys, 100-byte values, LZ4, 4 KiB blocks, 16 MiB target files and 10-bit bloom, then reads on the resulting DB (56 SSTs over L0-L2) with an 8 GiB block cache warmed by readseq. Writes are the median of 2 runs, everything else of 3.

write no trie kStandardDefault kCustomOnly
fillrandom us/op 2.422 2.508 2.429
flush s 4.6 4.7 4.6
compaction s 14.9 15.3 15.0
compaction CPU s 13.9 14.3 14.0
SST MiB 934 952 933

The two no-trie runs were 5% apart, so only the compaction CPU of building both indexes in kStandardDefault (about 3%) looks real.

read, us/op standard index (mode 1) trie (mode 2)
readrandom 1.604 1.557
seekrandom, 10 nexts 7.205 6.709
reverse seekrandom, 10 nexts 10.289 9.612

Table open: Trie::Init() used to decode every block offset and the whole bucket table, which was about two thirds of its time. Those checks moved into the iterator, and Init on a 14.8K-block index went from 26-28 us to 5-8 us; most of what's left is the trie walk. With --open_files=20, which leaves room for 10 table readers, most Gets reopen a table:

readrandom, open_files=20, us/op no trie mode 1 mode 2
this PR 88.5 96.7 94.9
before the open-time change 108.5 108.6

So under table cache churn the trie adds about 7 us per Get, down from about 20 us. DB::Open() of the 56-SST DB took 25-27 ms in every variant; at that size, loading tables on 16 threads is lost in the noise.

Testing

  • trie_index_test (96 tests), trie_index_db_test (277) and table_test pass, also with ASSERT_STATUS_CHECKED=1. Besides randomized Seek/Next/Prev against a model, golden bytes and malformed blocks, they cover NotSupported for other versions, bad offsets and bucket positions failing on read, an old-version block through DB::Open() in kStandardDefault, kStandardRequired and kCustomDefault, and trie reads with allow_mmap_reads across table cache evictions, compaction and reopen.
  • A corruption fuzzer under ASAN/UBSAN (byte flips, planted 32/64-bit values, truncation) ran 22M cases clean, 7.2M of which opened.
  • db_crashtest.py blackbox --simple --use_trie_index=1 --mmap_read=1 ran 15 minutes each in index modes 1 and 3, with mmap on in all 32 invocations.

@meta-cla meta-cla Bot added the CLA Signed label Oct 31, 2026
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

⚠️ clang-tidy: 2 warning(s) on changed lines

Completed in 2628.3s.

Summary by check

Check Count
performance-inefficient-vector-operation 2
Total 2

Details

utilities/trie_index/trie_index_test.cc (2 warning(s))
utilities/trie_index/trie_index_test.cc:296:5: warning: 'push_back' is called inside a loop; consider pre-allocating the container capacity before the loop [performance-inefficient-vector-operation]
utilities/trie_index/trie_index_test.cc:321:5: warning: 'push_back' is called inside a loop; consider pre-allocating the container capacity before the loop [performance-inefficient-vector-operation]

@zaidoon1
zaidoon1 force-pushed the zaidoon/udi-12-trie-index-v2 branch 2 times, most recently from cd9b272 to a4d2c11 Compare October 31, 2026 17:47
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Claude Code Review - OBSOLETE

Superseded by a newer AI review. Expand to see the original review.

✅ Claude Code Review

Auto-triggered after CI passed — reviewing commit a4d2c11


Summary

This PR adds trie index format 2 (path-compressed trie over front-coded buckets with frame-of-reference columns) and fixes several correctness bugs in format 1 (empty user key handling, malformed block validation, memory accounting). The changes are well-structured and the bug fixes are sound. I identified 3 HIGH and 5 MEDIUM findings, none of which are blockers -- the HIGHs are existing bugs that this PR correctly fixes.

High-severity findings (3):

  • H1: ApproximateAuxMemoryUsage() underreports heap memory by omitting overflow_base_, aligned_copy_, and chain vectors -- correctly fixed by this PR
  • H2: overflow_base_ prefix sum uses uint32_t running_sum which could silently wrap on crafted input -- correctly fixed by this PR (changed to uint64_t with early overflow check)
  • H3: InitFromData() does not reject unknown flags, allowing silent misinterpretation of future format extensions -- correctly fixed by this PR
Full review (click to expand)

Findings

🔴 HIGH

H1: ApproximateAuxMemoryUsage() underreports memory (existing bug, fixed by PR)

louds_trie.h:280-283 currently only counts s_child_start_pos_ and s_child_end_pos_. The PR correctly adds overflow_base_, aligned_copy_, and chain-related vectors (s_chain_suffix_offsets_, s_chain_lens_, s_chain_end_child_idx_). This fix is important because ApproximateMemoryUsage() feeds into block cache accounting via TrieIndexReader::ApproximateMemoryUsage() at trie_index_factory.cc:683.

H2: overflow_base_ prefix sum overflow (existing bug, fixed by PR)

At louds_trie.cc:1486, the current uint32_t running_sum could silently wrap to 0 if total overflow blocks exceed UINT32_MAX. While unlikely with legitimate data (kMaxReasonableKeys caps at 2^30), a malformed trie with large block_counts could trigger this. The PR changes to uint64_t running_sum with early overflow checking during accumulation -- correct fix.

H3: No unknown flags rejection (existing bug, fixed by PR)

Current InitFromData() at louds_trie.cc:1088 reads the flags field but does not reject unknown bits. A trie written by a future version with new flag bits would be silently read with incorrect interpretation. The PR adds if (flags & ~kKnownFlags) rejection -- correct forward-compatibility measure.

🔶 MEDIUM

M1: TrieIndexBuilder constructor signature change adds defaulted parameter

Per CLAUDE.md, defaulted parameters are the "#1 trap on refactoring." The change from explicit TrieIndexBuilder(const Comparator*) to TrieIndexBuilder(const Comparator*, int format_version = 1) means existing callers silently get format 1, but a future caller who forgets the parameter gets unexpected behavior. Consider making format_version explicit (no default) since the factory always knows the version.

M2: Format version dispatch asymmetry (write vs. read)

TrieIndexFactory takes format_version to control writing, but NewReader dispatches on the version in the serialized header. This is architecturally correct (any reader handles any version), but the factory's format_version member is ignored during reads. This should be documented to prevent confusion -- the factory's format_version is write-only configuration.

M3: TrieScanBounds extraction must preserve exact CheckBounds semantics

The extraction of scan bound checking from TrieIndexIterator::CheckBounds into a shared TrieScanBounds helper must preserve the exact reference_key semantics: for Seek, reference_key = seek target; for Next, reference_key = previous separator. The conservative bound check (comparing reference_key, not current separator, against limit) is subtle and must not be altered. Verify that TrieV2IndexIterator passes the same reference_key as TrieIndexIterator in each call site.

M4: db_crashtest.py module-level random choices reduce per-invocation coverage diversity

_USE_TRIE_INDEX and _INDEX_MODE are drawn once at module level, fixing them for the entire test run. This is documented as intentional, but it means a single crash test invocation exercises only one configuration. The trie_index_format_version and read_index randomized per invocation partially compensate.

M5: Relaxed atomics in TrackHandle are correct but deserve a comment

max_block_step_, max_block_size_, and irregular_block_gaps_ use std::memory_order_relaxed for cross-thread size estimation during parallel compression. This is correct because: (1) these are approximate estimates, (2) they're read-only after the parallel phase completes (serialized Finish() runs single-threaded). A brief comment noting the ordering rationale would help future readers.

⚪ LOW / OBSERVATIONS

L1: Empty key fix is correct

The cutoff_level_=1 when max_depth_==0 ensures the root becomes a dense node, which is required for prefix key emission. The level > 0 guard on skip-pure-leaf prevents the root from being incorrectly skipped. The DescendToRightmostLeaf fix handles the case where root has a prefix key but no labels by checking d_is_prefix_key_ before attempting label traversal. Test coverage for empty keys should include: (a) single empty key, (b) empty key + non-empty keys, (c) seek to empty key, (d) reverse iteration from first non-empty key to empty key.

L2: ValidateStructure() performance is documented and acceptable

~200us per SST open is well within tolerance. The validation runs once per SST open for format 1 indexes and catches structural corruption that would otherwise cause silent wrong results or crashes during read operations.

L3: Format 2 portability improvement

Format 2 uses DecodeFixed32/64 (little-endian reads) instead of format 1's host-byte-order memcpy. This makes format 2 portable to big-endian architectures (e.g., s390x). Format 1's host-byte-order limitation is pre-existing and documented.

L4: num_chains bounded by num_internal in InitFromData

Good defense-in-depth. Prevents crafted input from causing oversized vector allocations that could OOM the process during SST open.

Cross-Component Analysis

  • DB Mutex impact: None. Trie code executes during SST build (table builder, no mutex) and SST open (table reader, no mutex). No path through DBImpl::mutex_ is affected.
  • Alternative execution contexts: WritePreparedTxnDB, ReadOnly DB, and SecondaryDB all use the standard table reader path. The trie index integrates through IndexFactory SPI, so these contexts are transparent.
  • Block cache interaction: ApproximateMemoryUsage() fix (H1) directly affects cache charge accounting. The fix correctly includes all heap allocations.

Test Coverage Assessment

The PR should include tests for:

  1. Empty key round-trip (build trie with empty key, verify Seek/Next/Prev)
  2. ValidateStructure() with crafted malformed tries (corrupt node counts, invalid child positions)
  3. ValidateDerivedData() with corrupted rank LUT / select hints
  4. Format 2 round-trip (build with format 2, read back, verify correctness)
  5. Format version cross-compatibility (write format 1, read with format 2 factory; write format 2, read with format 1 factory)
  6. ApproximateAuxMemoryUsage() includes all vectors
  7. TrieScanBounds semantic equivalence with inline CheckBounds

Behavioral Compatibility

No breaking behavioral changes for existing users:

  • Format 1 remains the default
  • Existing SST files continue to be read correctly
  • The use_udi_as_primary_index -> index_mode change affects only db_stress tooling, not the production API
  • Format 2 is opt-in via format_version=2 on the factory

ℹ️ About this response

Generated by Claude Code.
Review methodology: claude_md/ci_review_prompt.md

Limitations:

  • Claude may miss context from files not in the diff
  • Large PRs may be truncated
  • Always apply human judgment to AI suggestions

Commands:

  • /claude-review [context] — Request a code review
  • /claude-query <question> — Ask about the PR or codebase

@zaidoon1
zaidoon1 force-pushed the zaidoon/udi-12-trie-index-v2 branch from a4d2c11 to 30ff02b Compare November 2, 2026 21:05
@zaidoon1 zaidoon1 changed the title Add trie index format 2 and fix format 1 bugs Replace the trie index format Nov 2, 2026
@zaidoon1
zaidoon1 force-pushed the zaidoon/udi-12-trie-index-v2 branch 2 times, most recently from 339b04d to 5942de8 Compare November 4, 2026 02:22
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

✅ Claude Code Review

Auto-triggered after CI passed — reviewing commit 5942de8


Summary

This is a well-structured replacement of the experimental LOUDS trie index with a new block-tree format. The changes are comprehensive: build system updates, public API documentation, validation flow improvements, crash test coverage, and release notes. The approach of keeping the same magic but bumping the version field to detect incompatible formats is sound.

High-severity findings (1):

  • [trie_index_factory.h] The TrieIndexFactory class visible in the current codebase lacks a ValidateOptions() override, yet BlockBasedTableFactory::ValidateOptions() now calls factory->ValidateOptions(). If the new trie_index_factory.h (in the truncated part of the diff) does not add this override, the comparator check at DB::Open time will silently pass (base Configurable::ValidateOptions() returns OK), and the error would only surface at NewBuilder() / first flush time.
Full review (click to expand)

Findings

🔴 HIGH

H1. Missing ValidateOptions() override on TrieIndexFactory — trie_index_factory.h
  • Issue: The diff adds a new validation pathway where BlockBasedTableFactory::ValidateOptions() calls factory->ValidateOptions(db_opts, cf_opts) in non-kStandardOnly modes (block_based_table_factory.cc:833-843). However, the current TrieIndexFactory class (trie_index_factory.h:334-350) has no ValidateOptions() override. The base class Configurable::ValidateOptions() returns Status::OK() by default. Without an override that checks for BytewiseComparator, DB::Open would succeed with a non-bytewise comparator, and the error would only be caught at NewBuilder() time (first flush), which is the exact behavior the PR claims to fix.
  • Root cause: The truncated diff likely adds this override in the new version of trie_index_factory.h, but since it's not visible in the provided diff, this is flagged for explicit verification.
  • Suggested fix: Ensure the new trie_index_factory.h includes:
    Status ValidateOptions(const DBOptions& db_opts,
                           const ColumnFamilyOptions& cf_opts) const override;
    with an implementation that rejects cf_opts.comparator != BytewiseComparator().

🟡 MEDIUM

M1. SetValidateFunc bypass may surprise future maintainers — block_based_table_factory.cc:421-432
  • Issue: The PR adds a SetValidateFunc that unconditionally returns OK for user_defined_index_factory, bypassing the automatic per-option Configurable::ValidateOptions() call that OptionTypeInfo::Validate() would normally make (options_helper.cc:1782-1784). This is correct for the stated goal (kStandardOnly should not validate the factory), but the pattern is subtle: a reader seeing AsCustomSharedPtr<IndexFactory> might expect standard Configurable validation to apply.
  • Root cause: The Configurable validation framework doesn't have a notion of "validate only in certain modes" — it validates unconditionally. The workaround is to disable automatic validation and do it explicitly in BlockBasedTableFactory::ValidateOptions().
  • Suggested fix: The code comment in the diff ("BlockBasedTableFactory::ValidateOptions() validates the factory only in the index modes that use it") is necessary and sufficient. Consider adding a similar note at the ValidateOptions() call site to cross-reference back.
M2. has_unsupported_handle_ still enforces 32-bit limit — trie_index_factory.cc:142,164,364
  • Issue: The PR description says the new format "supports data blocks past 4 GiB in an SST," and the release note says so too. However, trie_index_factory.cc still checks handle.offset > UINT32_MAX || handle.size > UINT32_MAX (lines 142-143 and 364-365) and returns NotSupported in Finish() (line 164-167). The header comment in trie_index_factory.h (lines 33-38) also documents the 32-bit limit. If the new format supports 64-bit offsets, these checks and comments need updating.
  • Root cause: The truncated diff may include updates to trie_index_factory.cc that remove these checks (the diff modifies this file), but since those changes aren't visible, this is flagged.
  • Suggested fix: Verify the new trie_index_factory.cc either removes the has_unsupported_handle_ check entirely (if the new format supports 64-bit) or documents why it's retained despite the format supporting it.
M3. Documentation says "64-bit offsets and sizes" but code may still enforce 32-bit — docs/components/index_factory/index.md
  • Issue: The docs change removes the "32-bit integer" warning and the text about UINT32_MAX limitations. The PR description says "64-bit offsets and sizes." But if has_unsupported_handle_ still enforces 32-bit in the builder (M2), the documentation is inaccurate.
  • Suggested fix: Align documentation with actual code behavior. Either remove the limit or document it.
M4. Release note for udi_validation_and_cache_statistics.md removes 32-bit text — unreleased_history/behavior_changes/udi_validation_and_cache_statistics.md
  • Issue: The release note edit removes the paragraph about NotSupported for offsets/sizes exceeding UINT32_MAX. If this limit is actually lifted, the removal is correct. If not (see M2), the release note should retain a version of this text.
  • Suggested fix: Verify consistency with actual code.

🟢 LOW / NIT

L1. index_factory.h example string change — include/rocksdb/index_factory.h:363
  • Issue: The comment changes the example from "trie" to "trie_index". This is correct — the registered name is TrieIndexFactory::kClassName() which returns "trie_index". Good fix.
L2. Documentation terminology change "warn" → "log" — docs/components/index_factory/index.md
  • Issue: The mode table changes "warn, fall back to standard" to "log, fall back to standard" for kStandardDefault missing/unusable custom block. This is a precision improvement — the behavior is a log message, not a warning-level alert. Appropriate change.
L3. Crash test block_size may be overridden — tools/db_crashtest.py
  • Issue: The new block_size = random.choice([1024, 4096, 16384]) for use_trie_index=1 (inserted around line 1652) can be overridden later at line 2203 (block_size = 1024 for the auto_readahead_size case). This is not a bug — the readahead case just always uses 1024, which is already in the trie's list — but it means the trie won't always get its random block size diversity.
  • Suggested fix: No action needed; the override happens rarely (1/4 probability with specific conditions).
L4. db_bench_tool.cc description simplification — tools/db_bench_tool.cc:773
  • Issue: The use_trie_index flag description is simplified from mentioning "LOUDS-encoded succinct trie" to just "trie from separator keys." Appropriate since the LOUDS format is removed.

Cross-Component Analysis

Context Affected? Assessment
WritePreparedTxnDB No Trie index operates at block-level, not key-level visibility
ReadOnly DB No Reader-only, no builder involvement
CompactionService Potentially If remote compaction builds SSTs with trie, the new format must be serializable
User-defined timestamps No Explicitly rejected by existing check + new ValidateOptions
MemPurge No Trie index is for SST files only
BlobDB No Trie indexes data blocks, not blob files
FIFO / Universal compaction No Index format is independent of compaction style
Prefix seek No Trie operates on full keys, prefix is handled by data iterator
SetOptions Indirectly index_mode change through SetOptions only affects newly opened SSTs — now documented

DB Mutex Analysis

The changed code paths (ValidateOptions, factory registration, crash test parameters) do not execute while DBImpl::mutex_ is held. ValidateOptions runs during DB::Open before the mutex is typically contested. No DB mutex impact.

Positive Observations

  • Well-structured compatibility story: Version field bump with same magic, clear upgrade/downgrade paths documented in release notes.
  • Crash test block_size diversification: Good insight that 16 KiB blocks produce too few separators for meaningful trie testing.
  • SetValidateFunc pattern: Clean separation of per-option validation (bypassed) vs. explicit mode-aware validation in ValidateOptions. This correctly handles kStandardOnly ignoring the factory.
  • Documentation update for SetOptions routing: The clarification that "index routing is fixed when a table reader opens" is an important correction of prior documentation that implied immediate effect.
  • Release notes: Appropriately detailed for a behavior change with compatibility impact, without being overly verbose.

ℹ️ About this response

Generated by Claude Code.
Review methodology: claude_md/ci_review_prompt.md

Limitations:

  • Claude may miss context from files not in the diff
  • Large PRs may be truncated
  • Always apply human judgment to AI suggestions

Commands:

  • /claude-review [context] — Request a code review
  • /claude-query <question> — Ask about the PR or codebase

@zaidoon1
zaidoon1 force-pushed the zaidoon/udi-12-trie-index-v2 branch 2 times, most recently from 0a8e540 to f6c034c Compare November 4, 2026 05:54
The LOUDS trie index was larger than the standard index on every key
shape tried, and up to 2x slower on Get. Replace it with a block-tree
layout: a shared prefix, front-coded buckets of 8 separators, a
path-compressed trie over the bucket heads, and frame-of-reference
offset columns.

The old format has no known users, so it is removed rather than kept
readable. Its blocks fail the version check, and earlier releases
reject the new blocks the same way.

The new builder stores the empty user key and 64-bit block handles.
Opening an index checks everything but the separators inside buckets,
whose bounds are checked as they are decoded, and bounds the key length
an iterator reserves. DB::Open now rejects a non-bytewise comparator
except in kStandardOnly, which ignores the factory:
BlockBasedTableFactory calls the factory's ValidateOptions() only in
the modes that use it.

Also correct the rollback text in table.h and the IndexFactory doc: an
index_mode change through SetOptions() only reaches SSTs opened
afterwards. When the trie is on and no block size is given, the crash
test now draws smaller block sizes, so its SSTs have enough separators
to reach multi-bucket tries and user keys that span blocks.
@zaidoon1
zaidoon1 force-pushed the zaidoon/udi-12-trie-index-v2 branch from f6c034c to 4be1067 Compare November 4, 2026 08:08
@meta-codesync

meta-codesync Bot commented Nov 4, 2026

Copy link
Copy Markdown

@xingbowang has imported this pull request. If you are a Meta employee, you can view this in D129647129.

@pdillinger pdillinger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Old trie blocks: NotSupported rather than Corruption

utilities/trie_index/trie.cc, Trie::Parse():

  if (DecodeFixed32(p + kVersionPos) != kVersion) {
    return Corrupt("unsupported format version");
  }

A version-1 block isn't corrupt; it's a valid block in a format this release no longer reads. The status matters for anyone upgrading in a mode that requires the trie. That includes users who never set index_mode: GetEffectiveIndexMode() maps the legacy use_udi_as_primary_index to kCustomDefault and fail_if_no_udi_on_open to kStandardRequired. In those modes the table open fails, and with max_open_files=-1 so does DB::Open(), with a status that operators and automation can reasonably read as data corruption (e.g. restore from backup) rather than "change an option and compact".

Suggest returning NotSupported for a recognized magic with an unrecognized version, with a message that says what to do, e.g.:

  const uint32_t version = DecodeFixed32(p + kVersionPos);
  if (version != kVersion) {
    return Status::NotSupported(
        "Trie index: format version " + std::to_string(version) +
        " is not readable by this release; open with "
        "index_mode=kStandardDefault and compact to rewrite the trie");
  }

kStandardDefault behavior is unchanged: the fallback in BlockBasedTable::Open() already treats IsNotSupported() like IsCorruption(). And the advice is always actionable: as far as I can tell, no released version writes trie SSTs without a full standard index (stub-only writing came with #14963, which isn't in a release branch), so every old SST is readable through kStandardDefault.

TrieTest.RejectsOtherVersions could then assert the status code, and a DB-level test could cover an old-version block falling back in kStandardDefault and failing with NotSupported in kCustomDefault. The summary's "each version rejects the other's blocks with Corruption" would need updating for the new-reads-old direction.


Write, read, and table open benchmarks through the DB

The numbers in the summary isolate the index nicely (identical data blocks via SstFileWriter + ingest, everything cached), but they don't show the end-to-end cost on the normal write and read paths, or the cost of opening a table reader. I'd like to see db_bench runs along these lines:

Write: fillrandom (or fillseq + overwrite) with normal flush and compaction, comparing

  • --use_trie_index=0 (baseline)
  • --use_trie_index=1 --index_mode=1 (kStandardDefault: both indexes built, the rollout mode)
  • --use_trie_index=1 --index_mode=3 (kCustomOnly: trie only)

reporting throughput and flush/compaction time or CPU.

Read: on the resulting multi-level DB (--use_existing_db=1), readrandom, seekrandom with --seek_nexts, and a reverse scan (readreverse or seekrandom --reverse_iterator=1), comparing --index_mode=1 vs --index_mode=2 (kCustomDefault).

Table open: every table reader open reads the trie block, and Trie::Init() validates it in full: every block offset is decoded and checked, along with the bucket table, the side tables, and a walk of the whole trie. That is linear in the number of blocks in the SST, and unlike the standard index with cache_index_and_filter_blocks=true, the custom index is loaded eagerly at open. By the summary's own numbers it adds roughly 20-30 us per 64 MiB SST over restart 16. That cost is paid by DB::Open() with max_open_files=-1, on every table cache miss with a bounded max_open_files, and for each new flush or compaction output. Please include DB open time on a DB with many SSTs, and readrandom with a small --open_files so table readers are reopened. If it shows up, the per-block offset check looks like the main candidate: as far as I can tell it exists only so that derived block sizes cannot wrap, which could instead be checked where value() derives the size.


tools/db_crashtest.py: is the mmap_read=0 exclusion still needed?

finalize_and_sanitize() still forces mmap_read=0 with the trie:

        # Trie UDI uses zero-copy pointers into block data, which is
        # incompatible with mmap_read.
        dest_params["mmap_read"] = 0

The new format is read in place with unaligned loads, at any alignment. What is the remaining incompatibility with mmap_read? If it no longer applies, the exclusion can go and the crash test regains mmap_read coverage with the trie; if it does, the comment should say why.

@@ -1,3 +1 @@
Custom index factories are rejected with user-defined timestamps in every index mode except `kStandardOnly`, which ignores the factory. Custom-index block-cache hits, misses, and adds now contribute to `BLOCK_CACHE_INDEX_*` instead of `BLOCK_CACHE_DATA_*`.

Trie index construction returns `NotSupported` when a data-block offset or size exceeds `UINT32_MAX`, including when the trie is optional in `kStandardDefault`. Flush, compaction, and `SstFileWriter::Finish()` can therefore fail for such output; an SST larger than 4 GiB is not itself the rejection condition. File-size targets do not split L0 outputs or `SstFileWriter` outputs. Bound those outputs or disable trie construction with `kStandardOnly`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This editing is not going to make sense with a release cut in the middle. Please only add new release notes. I generally prefer one release note per feature, choosing the most appropriate section.

- A trie block of another version now fails with NotSupported and a
  hint to compact in kStandardDefault, instead of Corruption. The
  version is checked before the header size.
- Init() no longer decodes every block offset or bucket position. The
  iterator reads a block's handle when it lands on it and returns
  Corruption if a derived size would wrap; bucket positions are clamped
  to the bucket data, where every entry is bounds-checked. Init on a
  14.8K-block index goes from 26-28 us to 5-8 us.
- db_crashtest.py and db_stress no longer turn mmap_read off with the
  trie; trie reads are tested with allow_mmap_reads.
- The edits to existing release notes are reverted, and the trie
  changes get a single new note.
@zaidoon1

zaidoon1 commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, this was useful. Everything is addressed in 74fe042, and the summary is updated.

Old trie blocks: NotSupported

Done, with your message. The version is now checked right after the magic and before the header size, so a block whose format has a shorter header still gets NotSupported. RejectsOtherVersions asserts the code. TrieIndexDBTest.OtherTrieVersionFallsBackOrFails writes SSTs whose trie block says version 1: kStandardDefault reads them through the standard index and counts the load failure, while kStandardRequired and kCustomDefault fail DB::Open() with NotSupported.

One correction to the reasoning: 11.14.fb was cut today (3f56cbf) and it contains #14963. So 11.14.0 will be able to write version-1 tries into kCustomOnly SSTs, which have only the stub standard index. After this PR, nothing can read those files. The release note now tells users to rewrite them in another mode before upgrading. If you'd rather 11.14.0 not ship that state, picking this PR into 11.14.fb before the tag would avoid it.

Benchmarks

They're in the summary under "Through the DB". In short: on a 56-SST LZ4 DB, Get through the trie was 3% faster than through the standard index, and Seek with 10 nexts was 7% faster in both directions. fillrandom throughput was within noise in all three modes. Building both indexes in kStandardDefault cost about 3% more compaction CPU, and kCustomOnly matched the baseline without the trie.

Open time

It showed up, and the offset check was the main cost, as you guessed. On a 14.8K-block index it took about 14-18 us of Init's 25-28 us, and the bucket table loop took another 2-4 us. Both are gone from Init:

  • The iterator now reads the block handle when it lands on a block, and returns Corruption if a derived size would wrap. value() became a plain return, so there are no extra column reads.
  • A bucket position is clamped to the bucket data, where every entry is already bounds-checked.

Init on that index went from 26-28 us to 5-8 us; most of what's left is the trie walk. With --open_files=20 (10 table readers for 56 SSTs), the trie's cost per readrandom Get, over not loading it at all, dropped from about 20 us to about 7 us. Opening that DB took 25-27 ms in every variant, so the difference doesn't show at that size.

mmap_read

The exclusion no longer applies, so it's gone from db_crashtest.py and db_stress. The trie keeps pointers into its block, but the block outlives the reader. An mmap'd block is copied to the heap before it goes into the block cache. Otherwise it stays uncached and points into the table reader's own mapping.

TrieIndexDBTest.MmapReads reads through the trie with allow_mmap_reads across table cache evictions, compaction and reopen, with the index cached and uncached, in all three modes. The crash test with --mmap_read=1 ran clean for 15 minutes each in modes 1 and 3.

I also couldn't reproduce a crash on main with the exclusion removed: 10 minutes per mode, about half of it with mmap on. So I can't say what the original failure was.

Release notes

There's now a single new note, in behavior_changes. It covers the format, the upgrade path including the kCustomOnly case, the empty-key fix and the comparator check.

@zaidoon1
zaidoon1 requested a review from pdillinger November 6, 2026 02:47

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants