Skip to content

fix(storage): skip caching splits larger than the split cache budget - #6635

Open
zchataiwala-abstract wants to merge 6 commits into
quickwit-oss:mainfrom
zchataiwala-abstract:split-cache-skip-oversized-splits
Open

fix(storage): skip caching splits larger than the split cache budget#6635
zchataiwala-abstract wants to merge 6 commits into
quickwit-oss:mainfrom
zchataiwala-abstract:split-cache-skip-oversized-splits

Conversation

@zchataiwala-abstract

Copy link
Copy Markdown

What

The searcher on-disk split cache (split_cache) has no per-split size guard: it will download a split even when that split is larger than the entire cache budget (split_cache.max_num_bytes). A single oversized split therefore transiently writes its full size to the searcher disk (risking ENOSPC) and evicts the entire small-split working set while (failing to) make room for it.

This PR adds a size-based guard so the cache refuses to download a split larger than its budget; such queries fall back to the normal cold-storage range warmup.

How

  • Skip oversized candidates, and continue. The download loop now selects the best fitting candidate: it skips any candidate whose file size exceeds max_num_bytes and advances to the next one, instead of stalling on a permanently-hot oversized split (which would otherwise starve the whole cache).
  • Size-aware eviction. Eviction now makes room for the incoming split's bytes (on_disk_bytes + incoming <= max_num_bytes), so on-disk usage no longer overshoots the budget by a whole split.
  • Plumb the split size to the cache two ways, so both new and pre-existing splits are covered:
    • a new ReportSplit.num_bytes field populated by the indexer at publish time (from the split footer offset), and
    • the searcher's split-open path (open_split_bundle), which registers the size from the split's footer offsets — so splits that were never reported with a size are still guarded the first time they are queried.
  • Backward compatible. An unknown size (0) is treated as "fits", so behavior is byte-for-byte unchanged when the size is unknown.
  • Observability. Adds a counter for downloads skipped because the split exceeds the budget.

Search results are unaffected — skipped splits are served by the existing cold-storage range warmup.

Tests

Unit tests in split_table.rs cover: an oversized candidate is skipped while a smaller one behind it is selected; oversized-only → nothing is downloaded; eviction accounts for the incoming split size; unknown size (0) behaves as before; and a size attached to a candidate discovered via the search path causes the guard to skip it.

Before merge

  • Regenerate the checked-in codegen with cargo build -p quickwit-proto (requires protoc) so search_descriptor.bin matches the new ReportSplit field.

🤖 Generated with Claude Code

The searcher on-disk split cache had no per-split size guard: it would
download a split even when the split was larger than the whole cache
budget (`split_cache.max_num_bytes`). A single oversized split therefore
transiently wrote its full size to the searcher disk and evicted the
entire small-split working set to (fail to) make room.

Add a size-based guard to the split cache:

- The download loop now skips any candidate whose file size exceeds
  `max_num_bytes` and advances to the next-best fitting candidate, rather
  than stalling on a permanently-hot oversized split (which would starve
  the whole cache). Eviction also accounts for the incoming split's size,
  so on-disk usage no longer overshoots the budget by a whole split.
- The split file size is plumbed to the cache two ways: a new
  `ReportSplit.num_bytes` field populated by the indexer, and the search
  open path (`open_split_bundle`), so pre-existing splits that were never
  reported with a size are guarded the first time they are queried.
- An unknown size (0) is treated as "fits", so behavior is byte-for-byte
  unchanged when the size is not known.
- Add a counter for downloads skipped because the split exceeds the budget.

Oversized splits are served by the existing cold-storage range warmup, so
search results are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zchataiwala-abstract
zchataiwala-abstract requested a review from a team as a code owner July 27, 2026 13:36

@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: 4192615fcb

ℹ️ 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".

Comment thread quickwit/quickwit-storage/src/split_cache/split_table.rs Outdated
Comment thread quickwit/quickwit-storage/src/split_cache/split_table.rs Outdated
zchataiwala-abstract and others added 3 commits July 27, 2026 19:11
…ndidates

Address review feedback on the split-cache size guard:

- Reserve the reported size of in-flight downloads against the budget, so
  with `num_concurrent_downloads > 1` two sub-budget splits can no longer
  both start and overshoot the cache once they land. `Status::Downloading`
  now carries the split size and `would_exceed_limits_with` counts it.
- When the hottest fitting candidate cannot make room without evicting a
  fresher on-disk split, advance to the next-best fitting candidate instead
  of returning early, so smaller cacheable splits are not starved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gate the per-split size guard behind a new
`SplitCacheLimits::skip_oversized_splits` flag (default false), so the
default searcher behavior is unchanged.

When disabled (the default), the download loop and eviction use the
original on-disk-only logic and the search-path size registration is a
no-op. When enabled, the cache skips splits larger than `max_num_bytes`,
reserves the bytes of in-flight downloads against the budget, and advances
past candidates it cannot make room for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.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

https://github.com/quickwit-oss/quickwit/blob/864c00fbc5465f32cb3c1a966228c547f171ab78/quickwit-storage/src/split_cache/split_table.rs#L430-L436
P2 Badge Exclude failed downloads from reserved bytes

When a download fails, perform_eviction_and_download drops the candidate's last strong token without removing its Downloading entry, but this calculation still reserves that entry's full reported size. Because dead entries are garbage-collected only after the downloading set grows beyond num_concurrent_downloads + 10, a single failed 600 MB download in a 1 GB cache can indefinitely prevent an unrelated 500 MB candidate from starting. Ignore entries whose alive_token has no strong references, or eagerly remove them when the download task fails.


https://github.com/quickwit-oss/quickwit/blob/864c00fbc5465f32cb3c1a966228c547f171ab78/quickwit-storage/src/split_cache/split_table.rs#L468-L470
P2 Badge Avoid reserving a phantom split during startup

With skip_oversized_splits enabled, this condition always reserves one incoming split, but SearchSplitCache::open calls make_room_for_split_if_necessary(..., 0) solely to trim existing files and has no incoming split. Consequently, restarting with exactly max_num_splits valid cached files evicts the oldest one even though the cache is already within its configured limit. The startup trim needs a way to omit the + 1 reservation.


https://github.com/quickwit-oss/quickwit/blob/864c00fbc5465f32cb3c1a966228c547f171ab78/quickwit-storage/src/split_cache/split_table.rs#L358-L361
P2 Badge Avoid recording cache hits as evictions

When the guard is enabled, every open_split_bundle calls this method even for a split that is already OnDisk. Delegating to report runs the entry through mutate_split, whose remove path increments evict_num_items and evict_num_bytes; reinsertion restores the current cache gauges but cannot undo those cumulative eviction counters. Normal searches of cached splits therefore appear as evictions, corrupting the cache telemetry. Return without mutating non-candidate entries, or update candidate sizes without removing on-disk entries.

ℹ️ 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".

Two follow-up fixes to the split-cache size guard (only active when
`skip_oversized_splits` is enabled):

- `downloading_bytes` now ignores dead `Downloading` entries (whose living
  token has no strong references). A failed download otherwise kept its
  reported size reserved until garbage collection, which could block an
  unrelated candidate from starting.
- `report_split_size_from_search` only attaches a size to candidate or
  not-yet-known splits. Routing an already-on-disk split through `report`
  (remove + reinsert) spuriously incremented the cache eviction counters,
  making normal searches of cached splits look like evictions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zchataiwala-abstract

Copy link
Copy Markdown
Author

Thanks for the follow-up review — addressed the findings in 9cea95b:

Exclude failed downloads from reserved bytes — Fixed. downloading_bytes() now ignores dead Downloading entries (a living token with no strong references), so a failed download no longer keeps its reported size reserved until garbage collection. Covered by test_failed_download_bytes_not_reserved.

Avoid recording cache hits as evictions — Fixed. report_split_size_from_search now only attaches a size to candidate or not-yet-known splits; already-downloading/on-disk splits are left untouched, so a search of a cached split no longer runs through report's remove+reinsert and no longer bumps the evict_* counters.

Avoid reserving a phantom split during startup — This one is not a regression. With the guard enabled the count check is on_disk + downloading + 1 > max_num_splits, which is arithmetically identical to the default path's on_disk + downloading >= max_num_splits. So the startup trim (make_room_for_split_if_necessary(u64::MAX, 0)) evicts one split when exactly at max_num_splits whether the guard is on or off — this reserve-one-slot behavior is pre-existing upstream, not introduced by this PR. Happy to change it separately if you'd prefer the startup trim to omit the reservation, but I've left the default path untouched here.

All three paths are behind the opt-in skip_oversized_splits flag (default off).

@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: 60158ff5f3

ℹ️ 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".

Comment on lines +487 to +488
if self.on_disk_splits.len() + self.downloading_splits.len() + 1
> self.limits.max_num_splits.get() as usize

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore dead downloads when enforcing split-count limits

When skip_oversized_splits is enabled, this split-count check includes every entry in downloading_splits, even entries whose alive_token is already dead after a failed download. For example, with max_num_splits = 1, a failed download leaves one dead Downloading entry; a different candidate then always sees 0 + 1 + 1 > 1, there is no on-disk split to evict, and find_download_opportunity returns None forever because GC only runs after many downloading entries accumulate. The byte accounting already ignores dead downloads, so the count path should either GC first or count only live downloads.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant