Skip to content

Enforce the warmup memory limit while warmup data is downloaded - #6990

Open
fulmicoton-dd wants to merge 2 commits into
mainfrom
paul.masurel/warmup-memory-hard-limit
Open

fulmicoton-dd wants to merge 2 commits into
mainfrom
paul.masurel/warmup-memory-hard-limit

Conversation

@fulmicoton-dd

@fulmicoton-dd fulmicoton-dd commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Context

Second step of the work to prevent searcher OOMs caused by warmup. It builds on #6988, which moved the warmup memory accounting to a shared atomic (GlobalWarmupMemory).

Until now, a split search only checked searcher.warmup_memory_budget when it was granted a permit, with a pessimistic estimate. The memory actually used by the warmup was only known after the fact, and could go over the budget. Heavy queries (for example leading wildcards) caused OOMs.

What

The split's CachingDirectory now reserves memory before each cache miss, so the memory is reserved before the data is downloaded.

In order to avoid too many "allocation" we allocate at least 1.7x the current amount of memory held by the split.

If not enough memory is available, the allocation fails with an explicit error.
We will improve the treatment of those error in the next PR.

Allocating is simple and does not need much synchronization.
Deallocating happens when WarmupMemoryGuard is dropped. The SearchPermitProvider then get waken up via a message.

Load test

Run on a build with this PR, #7005 and #6993, compared with an earlier build without any warmup memory limit.

Setup: one searcher (64 vCPU, 512 GiB pod), cold caches, warmup_memory_budget: 100G, warmup_single_split_initial_allocation: 60MiB. Each query is a count over one day of logs: 338 splits, 3.8B docs. For 15 min, 2 QPS of normal counts, plus 2 leading-wildcard queries (message:*ms* OR message:*elapsed*) every 30 s. Each of those needs about 1.4 GB of warmup memory per split.

Without limit With the stack, default per-split limit (25G)
Searcher restarts (OOMKilled) 5 0
Peak RSS 531 GiB 81 GiB
Normal counts answered 885 / 1802 1799 / 1802
Normal counts p95 14.2 s 2.08 s
Leading-wildcard queries searcher OOMKilled fail (500) after about 20 s

With the default limit, the leading-wildcard queries are stopped by the node-wide budget, not by the per-split limit. The split searches already running keep downloading until then: 7.7 TiB was read from object storage during the 15 min.

With the per-split limit of #7005 set to warmup_single_split_max_allocation: 1G, below the 1.4 GB these queries need, a single leading-wildcard query on a cold searcher fails all 338 splits in 8.4 s instead of about 20 s, without retries. This was not run under the full load.

@fulmicoton
fulmicoton requested a review from Mallets November 6, 2026 10:20
Base automatically changed from paul.masurel/warmup-memory-atomic-accounting to main November 6, 2026 12:33
@fulmicoton-dd
fulmicoton-dd force-pushed the paul.masurel/warmup-memory-hard-limit branch from 1037b1f to 15a6da3 Compare November 6, 2026 13:31
Until now, a split search only checked the warmup memory budget when it was
granted a permit, using a pessimistic estimate. The memory actually used by
the warmup was only known after the fact and could go over the budget.

The split's CachingDirectory now reserves memory before each cache miss,
through a new MemoryReserver trait in quickwit-directories. SplitWarmupMemory
implements it: the permit's initial allocation is used first, then the
reservation grows by 1.7x, or by the exact need when 1.7x does not fit. If
the reservation cannot grow without going over warmup_memory_budget, the read
fails with OutOfMemoryError, and the split search fails. Failed splits are
still retried for now.

SplitWarmupMemory is shared by the SearchPermit and the CachingDirectory, so
its memory is freed when the cache holding the warmup data is dropped. After
opening the index, the reservation shrinks to the index size, and after the
warmup, to the memory in use. WarmupMemoryGuard::grow_unchecked is removed.

List terms uses the same mechanism. Its initial permit size is now clamped
to warmup_memory_budget, like leaf search, so that it can always be granted.

A new leaf_search_split_warmup_out_of_memory_total counter counts the split
searches that ran out of warmup memory.
@fulmicoton-dd
fulmicoton-dd force-pushed the paul.masurel/warmup-memory-hard-limit branch from 15a6da3 to ba5893c Compare November 9, 2026 10:24
@fulmicoton
fulmicoton marked this pull request as ready for review November 9, 2026 17:09
@fulmicoton
fulmicoton requested a review from a team as a code owner November 9, 2026 17:09
@datadog-official datadog-official Bot added area:search Root and leaf search, caches, sorting, pagination, Lambda offload type:bug Fixes something that is broken labels Nov 9, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T17:16:32.649198Z ba5893c Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: ba5893c641

ℹ️ 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 +138 to +139
self.memory_reserver
.try_reserve_memory(byte_range.len() as u64)?;

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 Account only for bytes actually added to the cache

When a requested range partially overlaps an existing cached range—or two concurrent reads miss on the same range—this reserves the full request length even though FileByteRangeCache::put_slice stores only the newly covered union (and may add nothing). SplitWarmupMemory::in_use is never reduced to the cache's actual size, so later reads can incorrectly fail with OutOfMemory while the warmup cache remains below the configured budget. Reserve the incremental cache growth or reconcile the reservation after insertion.

Useful? React with 👍 / 👎.

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.

Addressed by #6993

&split,
Some(ctx.doc_mapper.tokenizer_manager()),
Some(byte_range_cache.clone()),
Some(search_permit.warmup_memory()),

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 Keep warmup OOM failures out of the retry path

When the newly attached reserver rejects a warmup read, the error is converted through io::Error/anyhow into SearchError::Internal; leaf_search_single_split_wrapper then emits it with retryable_error: true, and LeafSearchRetryPolicy retries every response containing failed splits without inspecting that flag. Under memory pressure this immediately repeats the same memory-intensive warmup, contrary to SCHEDULING_AND_MEMORY_MANAGEMENT.md:63-64, which specifies that these OOM failures are non-retryable; preserve an identifiable OOM classification and make the retry policy exclude it.

AGENTS.md reference: AGENTS.md:L23-L24

Useful? React with 👍 / 👎.

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.

Comment on lines +69 to +71
For this reason, we also have a warmup_max_split_initial_allocation that will fail the request if the
single split allocation is greater than a given amount; hence increasing the chance that the faulty query is the one that
will be OOMed first.

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 Remove the nonexistent warmup limit setting

This documents warmup_max_split_initial_allocation as an available setting that rejects oversized requests, but a repo-wide rg finds no implementation or configuration field outside this paragraph; SearcherConfig exposes only warmup_memory_budget and warmup_single_split_initial_allocation (quickwit-config/src/node_config/mod.rs:435-436), and scheduling clamps an initial reservation to the total budget rather than applying the described per-split rejection. Operators therefore cannot configure the behavior promised here; either implement the setting or describe the limits that actually exist.

AGENTS.md reference: AGENTS.md:L23-L24

Useful? React with 👍 / 👎.

Comment on lines +138 to +139
self.memory_reserver
.try_reserve_memory(byte_range.len() as u64)?;

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.

Addressed by #6993

fn try_reserve_memory(&self, num_bytes: u64) -> Result<(), OutOfMemoryError> {
self.reserved
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |reserved| {
let new_reserved = reserved + num_bytes;

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.

Even if an overflow with u64 is very unlikely, we can use checked_add and safely return OutOfMemoryError.

 reserved
     .checked_add(num_bytes)
     .filter(|&new_reserved| new_reserved <= self.limit)

&split,
Some(ctx.doc_mapper.tokenizer_manager()),
Some(byte_range_cache.clone()),
Some(search_permit.warmup_memory()),

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.

}
self.memory_reserver
.try_reserve_memory(byte_range.len() as u64)?;
let owned_bytes = self.underlying_filehandle.read_bytes(byte_range.clone())?;

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.

If read_bytes fails then memory is not released.

let read_bytes = self
.underlying_filehandle
.read_bytes_async(byte_range.clone())
.await?;

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.

If read_bytes fails then memory is not released.

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.

IIUC the description below also covers #6993 and #7005, not only this PR.
If this is not the case, the description should be updated to cover what's implemented in this PR (e.g. OutOfMemory queries are retried).

///
/// On error, the read fails with an `io::Error` of kind `OutOfMemory`: the underlying directory
/// is not called and nothing is cached.
fn try_reserve_memory(&self, num_bytes: u64) -> Result<(), OutOfMemoryError>;

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.

Not a blocker but worth discussing: the trait MemoryReserver returns () upon success. However, to release memory the user calls release_unused_memory() on a search_permit.

Would it make sense that try_reserve_memory() returns some sort of MemoryGuard that automatically releases the memory when dropped? This also relates to https://github.com/quickwit-oss/quickwit/pull/6990/changes#r4525592085 and https://github.com/quickwit-oss/quickwit/pull/6990/changes#r4525593712.

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

Labels

area:search Root and leaf search, caches, sorting, pagination, Lambda offload type:bug Fixes something that is broken

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants