Skip to content

perf(kv-index): fuse cache-aware match+insert into a single tree descent - #1615

Merged
slin1237 merged 2 commits into
mainfrom
perf/radix-match-and-insert
Jun 9, 2026
Merged

slin1237 merged 2 commits into
mainfrom
perf/radix-match-and-insert

Conversation

@slin1237

@slin1237 slin1237 commented Jun 9, 2026 •

Copy link
Copy Markdown
Member

Description

Problem

Cache-aware routing does two full top-to-bottom descents of the same request prefix on every request — match_prefix…(tokens) immediately followed by insert_tokens(tokens, tenant). For long-context workloads (100K–200K-token prefixes, Kimi-class) that doubles the per-request traversal, lock acquisitions, and tenant-timestamp writes on the routing hot path.

Solution

Fuse the pair into a single descent. Insert's reach is a depth-wise superset of match's, so we walk the prefix once (doing the match), then splice only the unmatched suffix — the already-matched prefix is never re-walked.

Changes

  • crates/kv_index/src/token_tree.rs: extract insert_tokens' descent into insert_from; add match_and_insert (tenant known up front) and match_and_insert_with (tenant derived from the match result).
  • crates/kv_index/src/string_tree.rs: same for Tree.
  • model_gateway/src/policies/cache_aware.rs: select_worker_with_tokens / _with_text and the imbalanced min-load path call the fused method instead of match-then-insert.
  • model_gateway/benches/radix_tree_benchmark.rs: bench_match_and_insert (legacy pair vs fused, up to 131072 tokens / 524288 chars).

Size: ~1.2K lines — over the 400-line target. The bulk is the second method variant for both trees + equivalence tests + the new benchmark. The two token-tree variants duplicate splice logic; collapsing them is a sensible follow-up once this is validated. Flagging per the guideline.

For reviewers: match_and_insert_with replays the insert onto the node chain captured during the match. If another thread splits a matched node in the microsecond window between match and replay, the tenant lands on the stale node; it self-heals on the next request and the window is shorter than today's two-call sequence. Acceptable for an approximate cache-routing tree, but worth a look. (The suffix splice uses insert_from, which re-walks from the fall-off node and is unaffected.)

Test Plan

Pre-PR gate, run locally:

  • cargo +nightly fmt --all → clean.
  • cargo clippy -p kv-index -p smg --all-targets --all-features -- -D warnings → zero warnings.
  • cargo test -p kv-index → 190 passed, including 10 new equivalence tests asserting the fused path produces identical PrefixMatchResult and tenant_{token,char}_count as separate match+insert (token: disjoint / diverge-split / prefix-of-child-split / fresh / full-match; string: basic / deep-chain / unicode; + select-and-skip on both).
  • cargo test -p smg --lib policies::cache_aware (21) and cargo test -p smg --test routing_tests (92) → pass — exercise the new call sites end-to-end.

Benchmark — cargo bench --bench radix_tree_benchmark -- match_and_insert (legacy pair → fused):

Tokens pair fused speedup
1,024 644 ns 386 ns 1.67×
16,384 7.47 µs 3.95 µs 1.89×
65,536 29.4 µs 14.8 µs 1.99×
131,072 58.5 µs 29.3 µs 2.00×

String side ~1.75× (524288 chars: 278 µs → 158 µs). The win scales with context length — largest at long context.

Checklist
  • cargo +nightly fmt passes
  • cargo clippy --all-targets --all-features -- -D warnings passes

Summary by CodeRabbit

  • New Features

    • Added fused "match and insert" operations for text and tokens that perform prefix matching and insertion in a single traversal.
  • Performance

    • Optimized cache/routing logic to use the combined operations, reducing redundant work and improving routing/cache-affinity behavior.
  • Tests

    • Added equivalence tests ensuring fused APIs match legacy two-step results.
  • Benchmarks

    • Added benchmarks measuring fused vs legacy flows.

Cache-aware routing did two full top-to-bottom descents over the same request prefix per request (match then insert). Add match_and_insert / match_and_insert_with that descend once, and use them in cache_aware.rs. Benchmarked ~2x faster on the match+insert hot path, scaling with context length (131072 tokens: 58.5us -> 29.3us). Equivalence unit tests assert the fused path matches the separate match+insert on results and tenant counts.

Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dded9174-faf1-4785-ad39-daa0a344ae35

📥 Commits

Reviewing files that changed from the base of the PR and between 09b0a5c and 0bc14d2.

📒 Files selected for processing (2)
  • crates/kv_index/src/string_tree.rs
  • crates/kv_index/src/token_tree.rs

📝 Walkthrough

Walkthrough

Adds fused single-descent match_and_insert/match_and_insert_with to StringTree and TokenTree, refactors insertion to use internal insert_from helpers, updates CacheAwarePolicy routing to use the fused calls, and adds tests plus a Criterion benchmark comparing fused vs legacy two-step flows.

Changes

Radix Tree Fused Match+Insert API

Layer / File(s) Summary
StringTree fused match+insert API
crates/kv_index/src/string_tree.rs
Refactors insert_text to delegate traversal to insert_from. Adds resolve_tenant_readonly and public match_and_insert / match_and_insert_with that perform single-pass prefix match+insert while replaying match side effects. Includes equivalence tests vs legacy match-then-insert.
TokenTree fused match+insert API
crates/kv_index/src/token_tree.rs
Refactors insert_tokens to use insert_from and return tokens_added for caller accounting. Adds public match_and_insert / match_and_insert_with that record match traversal and splice only the unmatched suffix, with tests validating equivalence and selector skip behavior.
CacheAwarePolicy fused API integration
model_gateway/src/policies/cache_aware.rs
Replaces separate match_prefix_with_counts + insert_* calls with fused match_and_insert / match_and_insert_with in min-load and balanced routing paths. Worker selection is consolidated into selector closures and hash-index population stores the matched-prefix payload from the fused result.
Fused API performance benchmarks
model_gateway/benches/radix_tree_benchmark.rs
Adds bench_match_and_insert Criterion benchmark comparing fused match_and_insert_with against legacy two-step sequence for TokenTree and StringTree across large sizes; integrates call into run_benchmarks.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • lightseekorg/smg#1364: Refactors hash-index ownership per-model; related to the PR's switch to fused match+insert when populating the same hash-index.

Suggested reviewers

  • CatherineSue
  • claude

Poem

🐰 I hopped the radix branches, swift and spry,
Matched a root, then stitched the suffix by.
One descent now, no double roam,
Tenants snug in their interned home.
A tiny benchmark nods — hop, not sigh.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly reflects the primary change: fusing match+insert operations into a single tree descent for performance improvement in the kv-index module.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/radix-match-and-insert

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added benchmarks Benchmark changes model-gateway Model gateway crate changes kv-index KV index crate changes labels Jun 9, 2026

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces fused match_and_insert and match_and_insert_with methods to both StringTree and TokenTree to perform prefix matching and insertion in a single tree descent, significantly reducing traversal overhead on the request hot path. It also updates the cache-aware routing policy to utilize these fused operations and adds comprehensive tests and benchmarks. The review feedback suggests minor performance optimizations on the hot path, specifically pre-allocating the path vector to avoid re-allocations and using intern_tenant("empty") instead of Arc::from("empty") to prevent unnecessary heap allocations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

let mut match_curr = Arc::clone(&self.root);
// (node, char_count) for each full-match edge, in order.
let mut path: Vec<(NodeRef, usize)> = Vec::new();

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.

medium

On the request hot path, we can avoid re-allocating the Vec for path on every call by pre-allocating it with a small capacity (e.g., 16), which is sufficient for most tree depths.

Suggested change
let mut path: Vec<(NodeRef, usize)> = Vec::with_capacity(16);

Comment thread crates/kv_index/src/string_tree.rs Outdated
.iter()
.next()
.map(|kv| Arc::clone(kv.key()))
.unwrap_or_else(|| Arc::from("empty"));

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.

medium

Using Arc::from("empty") allocates a new heap-allocated str inside Arc on every fallback. We can use intern_tenant("empty") instead, which leverages the global static intern pool to completely avoid heap allocations.

Suggested change
.unwrap_or_else(|| Arc::from("empty"));
.unwrap_or_else(|| intern_tenant("empty"));
References
  1. For types that are frequently cloned on hot paths and represent a small, repeated set of values (e.g., worker IDs or tenant IDs), use an interned string type like Arc<str> to improve performance by making clones cheap (atomic reference count increments). Implement an interning mechanism where methods take &str and intern internally.

Comment thread crates/kv_index/src/token_tree.rs Outdated
let mut remaining = tokens;
let mut current = Arc::clone(&self.root);
// (node, advance) for each edge we descended through, in order.
let mut path: Vec<(NodeRef, usize)> = Vec::new();

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.

medium

On the request hot path, we can avoid re-allocating the Vec for path on every call by pre-allocating it with a small capacity (e.g., 16), which is sufficient for most tree depths.

Suggested change
let mut path: Vec<(NodeRef, usize)> = Vec::new();
let mut path: Vec<(NodeRef, usize)> = Vec::with_capacity(16);

Comment thread crates/kv_index/src/token_tree.rs Outdated
tenant: self
.root
.get_any_tenant()
.unwrap_or_else(|| Arc::from("empty")),

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.

medium

Using Arc::from("empty") allocates a new heap-allocated str inside Arc on every fallback. We can use intern_tenant("empty") instead, which leverages the global static intern pool to completely avoid heap allocations.

Suggested change
.unwrap_or_else(|| Arc::from("empty")),
.unwrap_or_else(|| intern_tenant("empty")),
References
  1. For types that are frequently cloned on hot paths and represent a small, repeated set of values (e.g., worker IDs or tenant IDs), use an interned string type like Arc<str> to improve performance by making clones cheap (atomic reference count increments). Implement an interning mechanism where methods take &str and intern internally.

Comment thread crates/kv_index/src/token_tree.rs Outdated
}

PrefixMatchResult {
tenant: last_tenant.unwrap_or_else(|| Arc::from("empty")),

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.

medium

Using Arc::from("empty") allocates a new heap-allocated str inside Arc on every fallback. We can use intern_tenant("empty") instead, which leverages the global static intern pool to completely avoid heap allocations.

Suggested change
tenant: last_tenant.unwrap_or_else(|| Arc::from("empty")),
tenant: last_tenant.unwrap_or_else(|| intern_tenant("empty")),
References
  1. For types that are frequently cloned on hot paths and represent a small, repeated set of values (e.g., worker IDs or tenant IDs), use an interned string type like Arc<str> to improve performance by making clones cheap (atomic reference count increments). Implement an interning mechanism where methods take &str and intern internally.

Comment thread crates/kv_index/src/token_tree.rs Outdated
tenant: self
.root
.get_any_tenant()
.unwrap_or_else(|| Arc::from("empty")),

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.

medium

Using Arc::from("empty") allocates a new heap-allocated str inside Arc on every fallback. We can use intern_tenant("empty") instead, which leverages the global static intern pool to completely avoid heap allocations.

Suggested change
.unwrap_or_else(|| Arc::from("empty")),
.unwrap_or_else(|| intern_tenant("empty")),
References
  1. For types that are frequently cloned on hot paths and represent a small, repeated set of values (e.g., worker IDs or tenant IDs), use an interned string type like Arc<str> to improve performance by making clones cheap (atomic reference count increments). Implement an interning mechanism where methods take &str and intern internally.

Comment thread crates/kv_index/src/token_tree.rs Outdated

// ---- Decide the insert tenant from the match result ----
let result = PrefixMatchResult {
tenant: last_tenant.unwrap_or_else(|| Arc::from("empty")),

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.

medium

Using Arc::from("empty") allocates a new heap-allocated str inside Arc on every fallback. We can use intern_tenant("empty") instead, which leverages the global static intern pool to completely avoid heap allocations.

Suggested change
tenant: last_tenant.unwrap_or_else(|| Arc::from("empty")),
tenant: last_tenant.unwrap_or_else(|| intern_tenant("empty")),
References
  1. For types that are frequently cloned on hot paths and represent a small, repeated set of values (e.g., worker IDs or tenant IDs), use an interned string type like Arc<str> to improve performance by making clones cheap (atomic reference count increments). Implement an interning mechanism where methods take &str and intern internally.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/kv_index/src/string_tree.rs`:
- Around line 815-843: Recorded ancestor nodes in path must be revalidated
before folding their cached char_count into tenant_char_count because
intervening splits can change which node represents the matched edge; update the
loop that iterates path (the block that checks node.tenant_last_access_time and
updates self.tenant_char_count) to first confirm each NodeRef still corresponds
to the same matched edge/length (compare the node's current edge length or
parent/child linkage against the recorded char_count or expected child on the
path), and if any mismatch is detected abort the replay and fall back to a fresh
insertion (call self.insert_from(current, remaining, tenant_id) or otherwise
re-run insert_text path discovery) instead of applying stale char_count or
attaching the tenant to a stale node; ensure tenant_last_access_time updates
only occur for validated nodes.

In `@crates/kv_index/src/token_tree.rs`:
- Around line 907-939: The current match_and_insert interleaves match-side and
insert-side touches per node (child.touch_tenant calls), changing ancestor
last_access_time; instead, traverse the full-match chain performing only
match-side logic (get_any_tenant, match_frozen, matched_tokens,
child.touch_tenant for the match-side only) and record the nodes/tenants that
need insert-side touches (including duplicates when match and insert are same
tenant) into a temporary list; after the traversal finishes (i.e., when you know
the full sequence like match_prefix_with_counts would), iterate that recorded
list and call child.touch_tenant(&tenant_id, track_lfu) in the original
ancestor-to-leaf order to preserve legacy access-order semantics; keep existing
behavior around match_frozen, last_tenant, common_len, and Step::Continue
semantics.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a6a308cb-c9cc-4e7b-8ffc-8aa98ceeedad

📥 Commits

Reviewing files that changed from the base of the PR and between 6117978 and 09b0a5c.

📒 Files selected for processing (4)
  • crates/kv_index/src/string_tree.rs
  • crates/kv_index/src/token_tree.rs
  • model_gateway/benches/radix_tree_benchmark.rs
  • model_gateway/src/policies/cache_aware.rs

Comment on lines +815 to +843
// Re-attach the inserting tenant to every full-match ancestor node, the
// epoch-0 intermediate attach `insert_text` performs while descending.
for (node, char_count) in &path {
if !node
.tenant_last_access_time
.contains_key(tenant_id.as_ref())
{
self.tenant_char_count
.entry(Arc::clone(&tenant_id))
.and_modify(|count| *count += *char_count)
.or_insert(*char_count);
node.tenant_last_access_time
.insert(Arc::clone(&tenant_id), 0);
}
}

if remaining.is_empty() {
// Loop-end: `current` is the final leaf; give it the real timestamp
// (insert_text's tail). It already received the epoch-0 attach above
// if it is on the path.
let epoch = get_epoch();
current
.tenant_last_access_time
.insert(Arc::clone(&tenant_id), epoch);
} else {
// Fall-off: splice only the unmatched suffix below `current`,
// reusing insert_text's exact loop (handles split-continue + the
// final-leaf real timestamp). The matched prefix is not re-walked.
self.insert_from(current, remaining, tenant_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Revalidate replayed nodes before folding their cached char_count into tenant_char_count.

This replay assumes every recorded NodeRef still represents the same matched edge it did in Phase 1. If another insert splits one of those nodes before Lines 815-843 run, that ref can now be the suffix child while char_count still reflects the old unsplit edge. The replay then attaches the tenant to the stale node, increments tenant_char_count by the stale full-edge length, and skips the new intermediate. The route may “self-heal” on a later request, but the inflated size accounting does not, so size-based eviction will act on corrupted data. Please validate that each recorded node is still on the matched path or fall back to a fresh insert when the path has changed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/kv_index/src/string_tree.rs` around lines 815 - 843, Recorded ancestor
nodes in path must be revalidated before folding their cached char_count into
tenant_char_count because intervening splits can change which node represents
the matched edge; update the loop that iterates path (the block that checks
node.tenant_last_access_time and updates self.tenant_char_count) to first
confirm each NodeRef still corresponds to the same matched edge/length (compare
the node's current edge length or parent/child linkage against the recorded
char_count or expected child on the path), and if any mismatch is detected abort
the replay and fall back to a fresh insertion (call self.insert_from(current,
remaining, tenant_id) or otherwise re-run insert_text path discovery) instead of
applying stale char_count or attaching the tenant to a stale node; ensure
tenant_last_access_time updates only occur for validated nodes.

Comment on lines +907 to +939
// --- Match side (pre-insert node state) ---
// Read the routed tenant BEFORE insert touches the node:
// insert's touch would add `tenant_id` to the map and
// corrupt both the all-evicted check and the routed
// tenant. This reproduces `match_prefix_with_counts`'s
// Continue arm exactly (it touches `get_any_tenant()`).
if !match_frozen {
match child.get_any_tenant() {
None => {
// All tenants evicted: match stops here.
// Insert still continues (re-populates node).
match_frozen = true;
}
Some(t_match) => {
matched_tokens += common_len;
child.touch_tenant(&t_match, track_lfu);
last_tenant = Some(t_match);
}
}
}

// --- Insert side ---
// `insert_tokens` always touches the inserting tenant on
// a full-match continuation. When the match side above
// already touched this same tenant, the original code
// ALSO touched twice (match then insert), so we keep both
// touches to preserve LFU hit_count / timestamp behavior
// byte-for-byte.
child.touch_tenant(&tenant_id, track_lfu);
Step::Continue {
next: child,
advance: common_len,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

match_and_insert no longer preserves the legacy access-order semantics.

On a full-match chain, the old pair does all match-side touches first and only then does the insert-side touches. This code interleaves them per node (A(match), A(insert), B(match), B(insert)…). That changes the persisted last_access_time of ancestors, so LRU/MRU/FIFO/FILO eviction order can diverge once a child is evicted and its parent becomes a leaf. The returned match counts stay the same, but the API is not actually equivalent to match_prefix_with_counts + insert_tokens for eviction behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/kv_index/src/token_tree.rs` around lines 907 - 939, The current
match_and_insert interleaves match-side and insert-side touches per node
(child.touch_tenant calls), changing ancestor last_access_time; instead,
traverse the full-match chain performing only match-side logic (get_any_tenant,
match_frozen, matched_tokens, child.touch_tenant for the match-side only) and
record the nodes/tenants that need insert-side touches (including duplicates
when match and insert are same tenant) into a temporary list; after the
traversal finishes (i.e., when you know the full sequence like
match_prefix_with_counts would), iterate that recorded list and call
child.touch_tenant(&tenant_id, track_lfu) in the original ancestor-to-leaf order
to preserve legacy access-order semantics; keep existing behavior around
match_frozen, last_tenant, common_len, and Step::Continue semantics.

Comment on lines +1281 to +1299
// Replay insert's per-node work on every edge the match descended:
// `insert_tokens` touches the inserting tenant on each full-match node
// and counts its `advance` tokens.
for (node, advance) in &path {
node.touch_tenant(&tenant_id, track_lfu);
tokens_added += *advance;
}

// Splice only the unmatched suffix at the fall-off node (`current`),
// reusing insert_tokens' exact descent loop. It re-probes `current`'s
// children for `remaining` (a single child-map op, not a re-walk of the
// matched prefix) and handles the vacant / split / — and, under a
// concurrent split race, full-match-continue — cases identically to a
// standalone `insert_tokens`. The matched prefix above `current` was
// already re-attached by the loop above and is never re-walked.
if remaining.len() >= PAGE_SIZE {
tokens_added +=
Self::insert_from(current, remaining, Arc::clone(&tenant_id), track_lfu);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The replay path can permanently overcount tenant_token_count after a concurrent split.

Phase 2 trusts the recorded (NodeRef, advance) pairs from the match walk. If one of those full-match nodes is split before Lines 1281-1299 run, the recorded ref can now be the suffix child, but the replay still adds the old advance and touches that stale node. A later request can repair reachability, but tenant_token_count stays inflated by the old prefix length, so eviction decisions are now based on bad accounting. Please revalidate the recorded path before replaying counts, or abandon replay and fall back to a fresh insert when the structure changed.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thorough review of the fused match+insert optimization. No issues found — approving.

What I verified:

  • insert_from extraction (both trees): The descent loop is correctly factored out. insert_text/insert_tokens delegate to it with (root, full_text, tenant_id). The string tree's version preserves the post-loop leaf timestamp write; the token tree's returns tokens_added for the caller to fold in.

  • match_and_insert (token tree, interleaved single-loop): Match-side reads get_any_tenant() before insert-side touch_tenant() on each node, preserving the "match sees pre-insert state" invariant. The match_frozen flag correctly handles all-evicted nodes (match stops, insert continues). Split branches (vacant, prefix-of-child, diverge) are verbatim from insert_tokens with the match-side touch correctly interleaved before the split so the intermediate's cloned tenant_last_access_time inherits it.

  • match_and_insert_with (both trees, two-phase): Phase 1 records the path with read-only ops (get() / children.get()). Phase 2 replays insert's per-node work on recorded nodes and splices the remainder via insert_from. The string tree's resolve_tenant_readonly + finish_match_and_insert pair correctly reproduces the cache-populate and probabilistic-touch side effects of match_prefix_with_counts. The remaining.is_empty() branch correctly writes the real timestamp on the final leaf.

  • cache_aware.rs call sites: record_cache_hit correctly uses match_and_insert (tenant known upfront, skips the insert when hash index isn't populated). select_worker_with_tokens/_with_text correctly uses match_and_insert_with with closure-based tenant selection. The selected_idx mutable capture through the FnOnce closure is safe.

  • Edge cases: empty text/tokens, sub-PAGE_SIZE sequences (short-circuit), full-match loop-end (leaf timestamp), partial match + split, all-evicted nodes, select returning None, Unicode strings.

  • Concurrency: The acknowledged window between match and replay in _with is no weaker than the original two-call sequence, which also releases all guards between match and insert.

  • Tests: Comprehensive equivalence tests covering the key scenarios. The assert_fused_matches_pair harness is well-designed — it runs both paths step-by-step and compares match results and final tenant_{token,char}_count.

Summary: 0 important, 0 nit, 0 pre-existing.

…th Vec

Address review (gemini): use the global intern pool for the rare empty-tenant fallback instead of Arc::from (no per-call heap alloc), and pre-size the match-path Vec. Re-benchmarked: fused-vs-pair speedup holds at ~2x (131072 tokens 61.0us->30.4us); kv-index 190 tests pass.

Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0bc14d2ef2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// Fall-off: splice only the unmatched suffix below `current`,
// reusing insert_text's exact loop (handles split-continue + the
// final-leaf real timestamp). The matched prefix is not re-walked.
self.insert_from(current, remaining, tenant_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid splicing from stale matched nodes

When another request splits the matched path between Phase 1 and this splice, current and the recorded path can refer to nodes whose prefix changed. For example, one request can match abcdef... and pause with current at the abcdef node; a concurrent insert of abc... splits it into abc + def; resuming here inserts the suffix under the old def node and never attaches the tenant to the new abc intermediate, while the old match_prefix_with_counts + insert_text path re-walked from the root and would attach both nodes. This leaves tenant-specific lookups and eviction counts inconsistent under concurrent same-prefix inserts.

Useful? React with 👍 / 👎.

Comment on lines +1297 to +1299
if remaining.len() >= PAGE_SIZE {
tokens_added +=
Self::insert_from(current, remaining, Arc::clone(&tenant_id), track_lfu);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid replaying inserts on stale token nodes

The read-only match phase records node refs, then this insert replay uses them after all locks have been released. Under concurrent inserts that split one of those token nodes, the replay touches/counts the old suffix node and starts insert_from below it without attaching the selected tenant to the newly created intermediate prefix; the previous separate insert_tokens call re-walked from the root, so it would see the split and populate the correct prefix nodes. This can make cache routing miss prefixes and leave per-tenant token counts wrong for concurrent requests sharing a prefix.

Useful? React with 👍 / 👎.

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

Labels

benchmarks Benchmark changes kv-index KV index crate changes model-gateway Model gateway crate changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant