· 6 min read ·

The bumpalo Bug Meilisearch Hit Is Valid Rust, and That Is the Point

Source: lobsters

Clément Renault’s investigation into Meilisearch’s allocator choices covers three allocators: jemalloc, bumpalo, and mimalloc. The jemalloc and mimalloc sections read like systems tuning case studies — decay timers, free list sharding, tail latency under concurrent indexing and search load. The bumpalo section is the one worth dwelling on. Its failure looks like it should not be possible in Rust.

An Arc<Bump> stored in an index struct, shared across every search request, allocated into by every query, never dropped. RSS climbed steadily with query count. No allocation failures. No panics. No safety violations. The code compiled and ran correctly throughout. This is arena growth that looks structurally identical to a classic C memory leak, occurring in safe Rust.

The reason it can happen is a boundary in bumpalo’s API design that the Meilisearch bug makes precise: the borrow checker enforces that objects allocated into an arena cannot outlive the arena. It does not enforce that the arena cannot live longer than you intended.

What Lifetime Parameters at the Allocation Site Actually Enforce

bumpalo’s Bump::alloc returns a reference whose lifetime is tied to the arena:

pub fn alloc<T>(&self, val: T) -> &mut T

The returned &mut T borrows from self. You cannot use a reference vended by a Bump after the Bump is dropped. You cannot store it somewhere that outlives the arena. The compiler enforces this statically at every allocation site.

The Allocator trait integration (available on nightly) extends this to collections:

let bump = Bump::new();
let mut v: Vec<u32, &Bump> = Vec::new_in(&bump);
v.push(42);
// v cannot outlive bump; the borrow checker says so

This is the use-after-free protection that arena allocators in C and C++ lack entirely. In C, you can allocate an object into an arena, destroy the arena, and hold a pointer to freed memory indefinitely. The program continues until that pointer is dereferenced. In Rust with bumpalo, that scenario does not compile.

The Gap Between References and Ownership

What the borrow checker does not track is how long the arena itself should live relative to the operations it serves. That is an ownership question, not a borrowing question, and Rust treats them differently.

C++‘s std::pmr::monotonic_buffer_resource is the closest equivalent in the standard library. It allocates monotonically from an upstream buffer and releases everything at once when destroyed. Like bumpalo, it offers fast bump allocation and bulk deallocation. Unlike bumpalo, it provides no compile-time enforcement of object lifetimes relative to the resource. Holding a pointer into a destroyed monotonic_buffer_resource is valid C++ code right up until the access, at which point behavior is undefined.

The safety improvement bumpalo offers over monotonic_buffer_resource is real. But it operates at the reference level. Once the arena itself is stored inside an Arc<IndexState> and that struct lives for the full duration of an open index, the lifetime of the arena has been extended far beyond any individual query. Every object ever allocated into it becomes part of the arena’s live set permanently. The references those allocations vend are still correctly scoped and will not be used after the arena drops; the arena just never drops.

// The problematic structure
struct IndexState {
    scratch: Arc<Bump>,   // grows for the lifetime of the index
    // ...
}

fn search(&self, query: &str) -> Results {
    let parsed = parse_query(&self.scratch, query);
    // parsed is correctly borrowed from &self.scratch
    // but the allocation stays forever
    compute_results(parsed)
}

The Arc is doing what Arc does: keeping the value alive as long as any reference to it survives. The borrow checker has no opinion on whether the Arc holding the arena is the right granularity. That judgment belongs to the programmer.

The Same Trap in Zig and Go

Zig’s ArenaAllocator works on the same bump-and-bulk-free principle. You initialize it with a backing allocator, pass it to work functions, and call deinit() when the work is done. Zig’s comptime type system does not enforce arena lifetimes relative to the objects allocated into them. The discipline is the programmer’s. If you store the arena allocator in a global or a long-lived struct, it accumulates indefinitely.

Go’s sync.Pool has a different model — the GC can reclaim pooled objects at any collection cycle — but presents the same conceptual trap. A long-lived reference to a pooled object prevents reclamation regardless of what the pool semantics imply. The language cannot distinguish holding a reference deliberately from holding it past its useful scope.

LLVM’s BumpPtrAllocator is the classic large-scale example. The entire AST and IR for a compilation unit are allocated into bump arenas that share the compilation unit’s lifetime. This works because the intended lifetime — one compilation — matches how the arenas are actually held. The compiler infrastructure is designed around the arena’s natural scope from the start, which is why LLVM reports 5-10x allocation speedups compared to malloc calls for AST nodes without it becoming a memory management problem.

The pattern is not fragile by nature. What it requires is that the arena’s lifetime be designed as deliberately as any other interface boundary.

Diagnosing the Failure

The diagnostic challenge the Meilisearch team faced illustrates why arena ownership errors are harder to find than fragmentation or latency issues.

jemalloc’s retention behavior shows up directly in stats.resident versus stats.allocated via the mallctl API. The ratio climbs after an indexing burst and decays as the dirty pages are returned. You can export this as a Prometheus gauge and correlate it with indexing activity. The signal is available from the allocator itself.

mimalloc’s delayed-free latency spikes show up in p99 histograms under mixed indexing and search load. You need the right workload to observe it, but latency instrumentation is standard in any production service.

Bumpalo’s arena growth does not show up in either place. The arena bypasses the global allocator for individual allocations entirely. jemalloc’s sampling heap profiler, if it is profiling the global allocator, will not capture bumpalo-internal allocations that were issued through the arena’s backing slab at construction time. A heap profile shows the slab allocation sites, not the individual objects within the arena.

Finding this required tracing the ownership graph — specifically, finding every codepath that held a reference to the IndexState, confirming that the IndexState was live for the full index lifetime, and then checking what the scratch field’s drop semantics were given that. Growth rate correlated with query volume rather than time was the first diagnostic hint. The ownership trace was the confirmation.

The Fix Is Architectural

Once the cause is understood, the correction is straightforward:

fn search(index: &IndexState, query: &str) -> Results {
    let arena = Bump::new();              // scoped to this query
    let parsed = parse_query(&arena, query);
    let results = compute_results(index, parsed);
    results
}   // arena drops here; all scratch memory freed at once

The Bump::new() and drop happen on every call. The drop implementation releases a single backing slab, which is cheap. What changes is that the arena’s lifetime now matches the query’s lifetime, which was the semantic intent all along.

This is the same discipline Meilisearch’s indexing pipeline already applied correctly for the batch-scoped intermediate allocations: create the arena at the start of the work unit, allocate into it freely, drop it when the unit completes. The search path had diverged from that pattern by hoisting the arena into shared state.

What This Reveals About Arena Allocators in Rust

The safety properties bumpalo provides are worth having. The borrow checker’s enforcement that references cannot outlive the arena eliminates an entire class of bugs that arena allocators produce in languages without ownership semantics. The code you write with bumpalo is genuinely safer at the reference level than equivalent code with monotonic_buffer_resource or a manually managed C arena.

But those guarantees operate on borrowed references, not on the arena’s own position in the ownership hierarchy. The question of where the arena should live — per-query, per-batch, per-index, per-request — is an architectural decision that the type system assists but does not make. The borrow checker will ensure that once the arena is placed, references into it are correctly scoped. It will not tell you that the arena was placed in the wrong location to begin with.

Meilisearch’s investigation surfaces this as a concrete production case, which is more useful than the abstract principle. The three failure modes across jemalloc, bumpalo, and mimalloc each require different tools to diagnose. The bumpalo one requires reading the ownership graph rather than reading allocator statistics, and that gap in diagnostic approach is the practical consequence of the gap in what Rust’s ownership model actually covers.

Was this interesting?