How to Build a Persistent B+ Tree for Range Queries
A dictionary answers a question such as “What is the reading with key 110?” A time-series browser often asks a different question: “Give me every reading from 105 through 115, in order.” Sorting all records after every request is unnecessary work when ordered lookup is part of the storage structure. A B+ tree keeps search keys organised and links its leaf nodes for range traversal.
Sphere10 Framework includes a stream-mapped B+ tree whose nodes live in a backing stream. We will create one in a file, insert deliberately unordered keys, close it and reopen it. The final query returns an inclusive range in ascending order. The example is small enough to inspect, but it exercises the persistent representation rather than demonstrating only an in-memory tree.
Contents
Why a B+ Tree Fits This Job
Internal nodes contain separator keys that guide the search. Data entries live in leaves, and the leaves are linked. A range lookup finds the leaf containing the lower bound and then walks forward until the upper bound has been passed. This combines targeted entry into the tree with sequential delivery of the matching records.
Choose a B+ tree when ordering and intervals are actual requirements: timestamps, sequence numbers, numeric identifiers or other comparable keys. A hash dictionary can be excellent for exact equality lookup, but it does not supply this ordering contract. Conversely, a B+ tree is not automatically the best answer for every lookup. Key size, comparison cost, write patterns and the selected storage backend affect the result.
Install a Reproducible Example
dotnet new console -n PersistentRange --framework net10.0
dotnet add PersistentRange package Sphere10.Framework --version 3.1.3
The supplied project pins that package version. It uses long keys and int values, both with constant-size primitive serializers. The stream-mapped B+ implementation needs constant-size key and value encodings because its node records contain predictable slots. Variable application data can instead live in another store, with the tree mapping an ordered key to its record position or stable identifier.
Create and Query the Tree
using System;
using System.IO;
using System.Linq;
using Sphere10.Framework;
namespace ArticleSample;
public static class Program {
public static void Main(string[] args) {
var folder = args.Length > 0 ? args[0] : "range-example";
Directory.CreateDirectory(folder);
var path = Path.Combine(folder, "events.bin");
const int order = 8;
using (var file = File.Open(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) {
using var tree = new StreamMappedBTreePlus<long, int>(order, file, PrimitiveSerializer<long>.Instance, PrimitiveSerializer<int>.Instance);
foreach (var key in new long[] { 105, 100, 120, 110, 115 })
tree.Add(key, checked((int)key * 10));
Console.WriteLine($"Entries: {tree.Count}");
}
// The persisted tree must retain its original order and serializer layout.
using (var file = File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) {
using var tree = new StreamMappedBTreePlus<long, int>(order, file, PrimitiveSerializer<long>.Instance, PrimitiveSerializer<int>.Instance);
var range = tree.FindRange(105L, 115L).ToArray();
if (!range.Select(item => item.Key).SequenceEqual(new long[] { 105, 110, 115 }))
throw new InvalidDataException("The inclusive range differs.");
foreach (var entry in range)
Console.WriteLine($"{entry.Key}: {entry.Value}");
}
}
}
The insertion order is intentionally different from the resulting order. Use Add for a new key. It rejects duplicates rather than replacing the existing value. Where replacement is intended, use the tree's explicit Set operation with the correct overwrite choice and make that decision part of the application's contract. Repeated measurements with equal timestamps need an explicit identity strategy because a unique-key tree does not allow duplicate keys.
Observe the Inclusive Bounds
Entries: 5
105: 1050
110: 1100
115: 1150
Both boundary keys, 105 and 115, appear. The key 110 lies between them, so it appears too. The data is ordered even though it was inserted as 105, 100, 120, 110 and 115. A useful variation uses a missing lower bound. Request 106 through 115 and confirm that the result begins at 110. Then request an interval outside the stored keys and confirm that it returns no entries.
Preserve the Storage Specification
The sample declares order = 8 once and reuses it for both openings. The small order is for demonstration. Choose a production order using measurements of the actual workload. The new-store convenience constructors have their own defaults, while existing storage needs the settings that created it. Preserve those values in an application-owned format/version manifest when files must survive application upgrades.
The tree writes nodes directly to the supplied stream. This does not add a surrounding file transaction, crash-recovery policy, access control or multi-process locking protocol. If the application's requirements include an atomic batch, compose the appropriate transactional backend instead of assuming IDisposable means commit. Close the tree before releasing its borrowed backing file.
Design the Application Boundary
Treat a deferred range enumeration as an operation over the current tree. In shared code, materialize the intended result while holding the access boundary that protects the tree, then release that boundary before awaiting I/O or presenting a dialog. Returning an enumerable and changing the store underneath it is a different concurrency contract, not a free consequence of lazy iteration.
For a timestamp index, the value might be a record ID rather than a whole event. That keeps the ordered key/index representation separate from the larger payload. The application then resolves only matching records. Document the unit and epoch of every timestamp, and decide how duplicate times are represented before storing real data.
This pattern is a useful building block for catalogues, local indexes and storage engines. Explore the stream-mapped implementation , the range traversal algorithm and the Sphere10 Framework overview for related object-store and collection capabilities.
Download the Example
Download the complete NuGet sample project. The archive contains the project, its model files and run instructions. Keep the pinned package version when reproducing the example.
Continue the Series
How to Build a Disk-Backed C# Collection
How to Query Objects Without Scanning Every Record
How to Build a Self-Describing Object Store