Storage for your search engine
You can find the latest state of
delta-searchin the repository.
In the previous chapter, we've explored how to build our own database index using B-Trees and Roaring Bitmaps. With our implementation, we can apply custom filtering and sorting using only the indexed data for fast data retrieval.
In this chapter, we'll take a look at LMDB, an embedded transactional key-value store with full ACID semantics, and how to integrate it with our index implementation. Also, we'll implement the first steps towards our very own database, called delta-search.
LMDB
LMDB is a fast and lightweight transactional key-value store. Internally, it uses B+ tree, copy-on-write semantics, and multiversion concurrency control to achieve high performance and scalability. LMDB supports multi-threading and has great performance for read operations. It allows multiple readers at the time, and a single writer. However, the writer does not block any reader, making it an ideal candidate for storing data in a high-available database or search engine.
LMDB is overall a great candidate for the storage layer. Also, thanks to heed, a library built by meilisearch, we have great support for Rust 🦀 and it supports serde-compatible types.
Setup
The README of heed is a great place to get started. We can start building an EntityStorage instance, which contains all the logic related to the storage layer of our database. As we want to have separate entities stored in our database, each entity is stored in a separate entry.
First of all, we'll initialize the LMDB environment and the different internal databases:
As shown above, creating the first database in LMDB is very straight-forward. We only need to define the limits of our storage in disk, and the amount of databases that are needed.
Write
For identifying each data item, the DataItem structure includes an id property. Each entry in the database uses this property as a key.
type DataItemId = u64;
Then, adding an item is as easy as:
However, when adding a new data entry, we also need to update our indices, so that they both reflect the same state:
This is a simplified version, as it does not cover certain edge cases and it's not well optimized. Check the final version in the repository.
Since we are using a single transaction, in case an error occurs during the multiple operations, we won't end up in a broken state.
In a similar way, deleting a data entry from our database needs to also refresh the index state:
It turned out to be a bit long, but at the end is the same principle: adapt the data and the associated index.
Read
Reading data is quite simple. Given a data ID, read that entry from the data database. To reduce the amount of read transactions needed, we'd implement our read operation for a list of data IDs. Thus, when querying our data results we'd need a single transaction.
Querying
In the previous chapter, we've explored how to implement different inequality operators for our Index. We can then implement our own filter and sorting operators.
For the first iteration, we won't use any SQL query for our query expression. Instead, we'll use actual types.
Filter
A query execution is composed by a filter expression, sorting, pagination, among others. The most important feature of a database is to filter for the data the user is interested in. Thus, it needs to run fast and reliable. A filter could be expressed using types as follows:
/// A composite filter allows to combine multiple filter expressions using
/// logical conjunction.
/// A single filter expression with a `name` identifying the field to match
/// the filter against, and the filter operation.
/// A filter operation collects all the available filter operations.
Then, given a CompositeFilter and a set of indices, we could match the filter expression against the index. The result is a bitmap defining which elements in our data set are a positive hit, and which aren't.
Matching the filter operation against the index is implemented similar to what was discussed in the previous chapter.
Sort
Our Index implementation is built on top of a B-Tree, meaning it can be used to sort the filter hits, so we get as a result a collection of sorted IDs identifying each data item.
The index sort implementation would be similar to the one presented in the previous chapter.
Execution
And so, the complete query execution based on a filter expression, a sort criteria and a pagination limit can be implemented as follows:
Benchmarks
After our implementation is fully implemented, we've made sure it works as expected by writing unit and integration tests, we can measure how fast running queries against our own database engine is. For that, we'll generate dumb data and store it in LMDB before running the benchmark. Afterwards, we'll define different query scenarios and so we can analyze which steps are more expensive.
const COUNT: usize = 100000;
const PAGE_SIZE: usize = 500;
lazy_static!
The results in my computer are as follows:
running 7 tests
test bench_filter_numeric_between ... bench: 6,308,875.00 ns/iter (+/- 162,387.71)
test bench_filter_numeric_eq ... bench: 6,297,158.40 ns/iter (+/- 497,664.54)
test bench_filter_or ... bench: 6,344,937.50 ns/iter (+/- 355,226.66)
test bench_sort ... bench: 10,367,891.70 ns/iter (+/- 615,670.41)
So, it takes 5-10ms to fetch the first page of 500 elements from a total of 100k data items. For starters, it's quite a good result!
You can use the wonderful flamegraph to analyze the performance and see for yourself which parts of the implementation contribute to the overall performance.
Conclusions
In this chapter, we've taken a look at how to implement a simple but efficient storage solution using LMDB and heed. We've seen how to store read and store data, as well as, querying results by defining custom filters and sorting criteria.