microsoft / microsoft/bf-tree

Stack overflow in `ScanIter::next` when scanning across many deleted records

Open
#39 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
1.1k
Forks
46
PR merge metrics
No merged PRs in 30d

Description

Summary

bf-tree 0.5.6 can overflow the thread stack while scanning a large or sparse key range containing many consecutive deleted records (tombstones).

The scan iterator recursively calls self.next(out_buffer) when it encounters either a deleted record or the end of a leaf. A sufficiently long sequence of records/pages that do not produce a result therefore creates an unbounded native call stack inside a single call to ScanIter::next.

We observed this through a C ABI wrapper called from .NET. The process reports a stack overflow at the native bftree_scan_next call. The managed caller itself uses an iterative loop; the recursion occurs inside bf-tree.

Affected version

  • bf-tree = "0.5.6"
  • Observed with a release build on Windows x64
  • The same recursive implementation is present in both ScanIter::next and ScanIterMut::next

Triggering workload

The problem is most visible with a deletion-heavy tree:

  1. Insert a large number of ordered keys.
  2. Delete a large consecutive range of those keys.
  3. Start a range scan before or at the beginning of that deleted range.
  4. Call ScanIter::next to obtain the next live record.
  5. The process eventually terminates with a stack overflow if enough tombstones or non-producing leaves are traversed before a live record or the end of the scan is reached.

The exact number of records needed to reproduce the problem depends on the platform, thread stack size, tree layout, and optimized stack-frame size.

Importantly, setting a small scan count does not bound the recursion. scan_cnt is decremented only when a live record is found, not when a deleted record is skipped. Consequently, even a scan requesting one result may recursively skip an arbitrarily large number of tombstones.

Relevant implementation

In src/range_scan.rs, ScanIter::next contains these recursive paths:

GetScanRecordByPosResult::Deleted => {
    self.scan_position.move_to_next();
    self.next(out_buffer)
}

and, after moving to the next leaf:

GetScanRecordByPosResult::EndOfLeaf => {
    // Load the next leaf...
    self.scan_position = pos;
    self.leaf_lock = lock;
    self.next(out_buffer)
}

Equivalent recursion exists in ScanIterMut::next.

In our C ABI wrapper, one native call is effectively:

match handle.iter.next(buffer) {
    Some((key_len, value_len)) => {
        // Return one record to the caller.
        1
    }
    None => 0,
}

Therefore, all skipped tombstones and leaves are processed before that native call returns. The caller cannot prevent the native stack from growing by paging the scan or repeatedly calling the FFI function.

Expected behavior

Scanning should use bounded stack space regardless of:

  • the number of deleted records in the requested range;
  • the number of leaves crossed before finding the next live record;
  • the requested scan count; or
  • the total size of the tree.

next() should either return the next live record or report exhaustion without recursively growing the stack.

Actual behavior

A long sequence of Deleted and/or EndOfLeaf results produces one recursive ScanIter::next frame per skipped item/page. Eventually the native stack is exhausted and the process terminates.

When called through P/Invoke, the visible failure location is the FFI call, for example:

hasNext = NativeBfTreeMethods.bftree_scan_next(
    handle,
    buffer,
    bufferLength,
    &keyLength,
    &valueLength);

This can make the issue initially look like an interop or caller-buffer problem, but the caller invokes bftree_scan_next iteratively and allocates its scan buffer only once. The unbounded recursion is inside ScanIter::next.

Suggested fix

Replace the recursive retry paths with an internal loop. Conceptually:

pub fn next(&mut self, out_buffer: &mut [u8]) -> Option<(usize, usize)> {
    loop {
        if self.scan_cnt == 0 && self.end_key.is_none() {
            return None;
        }

        match self.leaf_lock.get_record_by_pos_with_bound(
            &self.scan_position,
            out_buffer,
            self.return_field,
            &self.end_key,
        ) {
            GetScanRecordByPosResult::Deleted => {
                self.scan_position.move_to_next();
                continue;
            }
            GetScanRecordByPosResult::Found(key_len, value_len) => {
                self.scan_position.move_to_next();
                self.scan_cnt -= 1;
                return Some((key_len as usize, value_len as usize));
            }
            GetScanRecordByPosResult::EndOfLeaf => {
                // Load and install the next leaf using the existing logic.
                // Return None if there is no right sibling.
                continue;
            }
            GetScanRecordByPosResult::BoundKeyExceeded => {
                self.scan_cnt = 0;
                return None;
            }
        }
    }
}

The existing leaf-transition, locking, backoff, cache-only, and circular-buffer behavior can remain unchanged; only the recursive re-entry needs to become loop continuation. The same change should be applied to ScanIterMut::next.

Suggested regression tests

It would be useful to cover at least the following cases:

  1. Scan across a large consecutive run of deleted records and find a live record afterward.
  2. Scan across a large deleted suffix and reach the end of the tree.
  3. Request scan_cnt = 1 while a large tombstone run precedes the next live record.
  4. Repeat the scenarios across many leaf pages.
  5. Exercise both ScanIter and ScanIterMut.

The tests should verify that the result is correct and that the operation completes with bounded stack usage.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/range_scan.rs by reading ScanIter::next and ScanIterMut::next, including their deleted-record and end-of-leaf paths. Replace recursive re-entry with bounded-stack iteration while preserving the existing leaf-transition behavior, then add regression coverage for large tombstone runs, end-of-tree scans, scan_cnt = 1, and both iterator types.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
databases
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.