Skip to content

Commit 0eef136

Browse files
authored
Logdb: custom filter for RFC 7 (opendata-oss#494)
Implements the custom filter policy defined in RFC 7.
1 parent a37f92c commit 0eef136

9 files changed

Lines changed: 922 additions & 58 deletions

File tree

common/src/storage/mod.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::sync::Arc;
1212

1313
use async_trait::async_trait;
1414
use bytes::Bytes;
15+
use slatedb::FilterContext;
1516
use uuid::Uuid;
1617

1718
use crate::BytesRange;
@@ -244,18 +245,25 @@ pub trait StorageRead: Any + Send + Sync {
244245

245246
/// Returns an iterator over records whose key starts with `prefix`.
246247
///
247-
/// Backends that support prefix-aware bloom filters (e.g. SlateDB with a
248-
/// configured `PrefixExtractor`) consult those filters here, allowing
249-
/// SSTs that contain no matching keys to be skipped without a block
250-
/// read. Backends without such filters fall back to a range scan over
251-
/// the prefix.
252-
///
253-
/// The default implementation delegates to [`Self::scan_iter`] with
254-
/// the range derived from the prefix, which preserves correctness for
255-
/// any backend; backends that can do better should override this.
248+
/// Backends that support prefix-aware SST filters (e.g. SlateDB with a
249+
/// configured `PrefixExtractor` or custom `FilterPolicy`) consult those
250+
/// filters here, allowing SSTs that contain no matching keys — or, with
251+
/// `filter_context`, no keys past a caller-supplied cursor — to be skipped
252+
/// without a block read. Backends without such filters fall back to a range
253+
/// scan over the prefix.
254+
///
255+
/// `filter_context` is an opaque payload forwarded to the backend's custom
256+
/// filter policies (ignored by built-in filters and by backends without
257+
/// filter support); a scan parametrizes its filters through it. See
258+
/// [`slatedb::FilterContext`].
259+
///
260+
/// The default implementation delegates to [`Self::scan_iter`] with the
261+
/// range derived from the prefix, which preserves correctness for any
262+
/// backend; backends that can do better should override this.
256263
async fn scan_prefix_iter(
257264
&self,
258265
prefix: Bytes,
266+
_filter_context: Option<FilterContext>,
259267
) -> StorageResult<Box<dyn StorageIterator + Send + 'static>> {
260268
self.scan_iter(BytesRange::prefix(prefix)).await
261269
}

common/src/storage/slate.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use slatedb::IterationOrder;
1616
use slatedb::config::{CheckpointOptions, CheckpointScope, ScanOptions};
1717
use slatedb::manifest::VersionedManifest;
1818
use slatedb::{
19-
Db, DbIterator, DbReader, DbSnapshot, MergeOperator as SlateDbMergeOperator,
19+
Db, DbIterator, DbReader, DbSnapshot, FilterContext, MergeOperator as SlateDbMergeOperator,
2020
MergeOperatorError, SstReader, WriteBatch, config::WriteOptions as SlateDbWriteOptions,
2121
};
2222
use tokio::sync::watch;
@@ -236,15 +236,18 @@ impl StorageRead for SlateDbStorage {
236236

237237
/// Slatedb consults its SST-level filters on `scan_prefix` but not on
238238
/// `scan`, so routing prefix scans through this path is what lets a
239-
/// configured `PrefixExtractor` actually skip SSTs.
239+
/// configured `PrefixExtractor` (and custom `FilterPolicy`, parametrized by
240+
/// `filter_context`) actually skip SSTs.
240241
#[tracing::instrument(level = "trace", skip_all)]
241242
async fn scan_prefix_iter(
242243
&self,
243244
prefix: Bytes,
245+
filter_context: Option<FilterContext>,
244246
) -> StorageResult<Box<dyn StorageIterator + Send + 'static>> {
247+
let options = default_scan_options().with_filter_context(filter_context);
245248
let iter = self
246249
.db
247-
.scan_prefix_with_options(prefix, &default_scan_options())
250+
.scan_prefix_with_options(prefix, &options)
248251
.await
249252
.map_err(StorageError::from_storage)?;
250253
Ok(Box::new(SlateDbIterator { iter }))
@@ -323,10 +326,12 @@ impl StorageRead for SlateDbStorageSnapshot {
323326
async fn scan_prefix_iter(
324327
&self,
325328
prefix: Bytes,
329+
filter_context: Option<FilterContext>,
326330
) -> StorageResult<Box<dyn StorageIterator + Send + 'static>> {
331+
let options = default_scan_options().with_filter_context(filter_context);
327332
let iter = self
328333
.snapshot
329-
.scan_prefix_with_options(prefix, &default_scan_options())
334+
.scan_prefix_with_options(prefix, &options)
330335
.await
331336
.map_err(StorageError::from_storage)?;
332337
Ok(Box::new(SlateDbIterator { iter }))
@@ -544,10 +549,12 @@ impl StorageRead for SlateDbStorageReader {
544549
async fn scan_prefix_iter(
545550
&self,
546551
prefix: Bytes,
552+
filter_context: Option<FilterContext>,
547553
) -> StorageResult<Box<dyn StorageIterator + Send + 'static>> {
554+
let options = default_scan_options().with_filter_context(filter_context);
548555
let iter = self
549556
.reader
550-
.scan_prefix_with_options(prefix, &default_scan_options())
557+
.scan_prefix_with_options(prefix, &options)
551558
.await
552559
.map_err(StorageError::from_storage)?;
553560
Ok(Box::new(SlateDbIterator { iter }))

log/rfcs/0007-efficient-tail-scans.md

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ combines two cooperating mechanisms:
1717
key advances the watermark for all keys.
1818
2. **Sequence-aware SST filtering** — per-SST filter metadata lets a scan with an
1919
advanced cursor skip entire tables, so a higher watermark actually prunes I/O.
20-
Two filter policies cover the tree: a global-sequence-range policy that prunes
20+
Two filter policies cover the tree: a sequence-range policy that prunes
2121
by sequence (table-level on recent data, per-key once compacted) and a prefix
2222
bloom that prunes by key.
2323

@@ -166,11 +166,14 @@ a reader only applies a filter it can decode.
166166

167167
We register **two policies**, complementary by LSM level:
168168

169-
- **Global-sequence-range policy.** Every SST stores a table-level min/max global
170-
sequence (~16 bytes); a scan passes its cursor `N` as context and the filter
171-
returns *no match* when `max < N`. This is exact in **L0**, where single-writer,
172-
time-ordered flushes have disjoint, monotonic ranges — a caught-up cursor skips
173-
all but the newest L0(s).
169+
- **Sequence-range policy.** Every SST stores a table-level min/max sequence (~16
170+
bytes). Because every scan is segment-scoped (see Evaluation path) and the reader
171+
holds the segment's `start_seq`, the policy stores sequences **relative** to their
172+
segment — exactly what the key encodes — and the scan relativizes its cursor to
173+
`N_rel = N − start_seq` before passing it as context. The filter returns *no match*
174+
when `max < N_rel`. This is exact in **L0**, where single-writer, time-ordered
175+
flushes have disjoint, monotonic ranges — a caught-up cursor skips all but the
176+
newest L0(s).
174177

175178
A single range is coarse on **compacted** SSTs: key-range compaction merges many
176179
keys across a wide window, so the overall `max` stays high even when the scanned
@@ -199,9 +202,16 @@ We register **two policies**, complementary by LSM level:
199202
fall back to the table `max` for the rest. That keeps the size ceiling while
200203
preserving the per-key ranges that matter most.
201204

202-
The builder records *global* sequence (`segment_start + relative_seq`), which the
203-
key alone does not carry; the policy resolves `segment_id → start_seq` to compute
204-
it.
205+
Storing relative sequences keeps the builder **stateless** — it reads `relative_seq`
206+
straight off the key, with no `segment_id → start_seq` resolution. This stays sound
207+
even when a compacted SST mixes segments: the per-key map keys on
208+
`hash(segment_id ‖ user_key)`, so each entry's range shares one segment base and is
209+
internally consistent, while the table-level range mixes bases but still bounds any
210+
single-segment query from above (a matching entry has `relative_seq ≤ table_max`, so
211+
it is never skipped; other segments only inflate the bound — lost selectivity, never
212+
a false negative). An earlier design stored *global* sequence and resolved
213+
`segment_id → start_seq` at build time; relative storage drops the resolver and all
214+
build-time state.
205215

206216
- **Prefix bloom policy.** The builder hashes the **log-key** prefix of each entry
207217
— the `subsystem | version | segment_id | record_type | user_key` prefix up to
@@ -238,21 +248,26 @@ blob := version : u8
238248
if flags.per_key:
239249
count : var_u64
240250
entry × count, sorted by hash (binary-searched at query):
241-
hash : u64 # 64-bit key hash, same family as the
242-
# prefix bloom; collisions union ranges
251+
hash : u64 # 64-bit FNV-1a + fmix64 of the
252+
# (segment, user_key) prefix; need not
253+
# match the bloom hash (private to this
254+
# filter); collisions union ranges
243255
min : var_u64
244256
max_delta : var_u64
245257
```
246258

247-
Each range is stored as `min + (max − min)` so the varints stay small — the delta
248-
is bounded by the records the SST holds, not the absolute sequence.
249-
250-
The cursor reaches the filter through `FilterContext::Inline`: bytes `0..8` hold
251-
`N` as a big-endian `u64`, the remainder reserved (zero). `decode` parses the blob
252-
into `{ table: (min, max), per_key: Option<[(hash, min, max)]> }`. The resume
253-
predicate uses only `max` (`might_match` reads `N`, looks up `hash(K)` in `per_key`
254-
when present — binary search, absent → `table.max` — else `table.max`, and returns
255-
`max ≥ N`); `min` is carried for a future bounded-scan upper-bound prune and is
259+
All stored sequences are **relative** to the entry's segment (see above). Each range
260+
is stored as `min + (max − min)` so the varints stay small — the delta is bounded by
261+
the records the SST holds, and relative sequences keep `min` small too.
262+
263+
The cursor reaches the filter through `FilterContext::Inline`: bytes `0..8` hold the
264+
segment-relative cursor `N_rel = N − start_seq` as a big-endian `u64`, the remainder
265+
reserved (zero). A missing context (or an unrecognized variant) means "cannot prune",
266+
so the filter matches. `decode` parses the blob into
267+
`{ table: (min, max), per_key: Option<[(hash, min, max)]> }`. The resume predicate
268+
uses only `max` (`might_match` reads `N_rel`, looks up `hash(K)` in `per_key` when
269+
present — binary search, absent → `table.max` — else `table.max`, and returns
270+
`max ≥ N_rel`); `min` is carried for a future bounded-scan upper-bound prune and is
256271
otherwise unused.
257272

258273
Compatibility: an incompatible format change bumps the policy `name`, so a reader
@@ -265,10 +280,13 @@ treated as match-everything.
265280
SlateDB evaluates SST filters on `get` and `scan_prefix`, but **not** on plain
266281
range `scan`. A per-key scan is naturally a prefix scan — all sequences for a key
267282
share the prefix `…|segment_id|record_type|key`, with `seq` as the suffix — so it
268-
runs as `scan_prefix` over that prefix, with the cursor `N` supplied as filter
269-
context. This requires an upstream SlateDB change to let `scan_prefix` accept a
270-
**sub-range**, so the scan can begin at `N` within the key's prefix instead of
271-
re-reading the key's whole history while still evaluating the filters.
283+
runs as `scan_prefix` over that prefix, with the segment-relativized cursor supplied
284+
as filter context (threaded through `StorageRead::scan_prefix_iter`). Pruning then
285+
drops whole SSTs the cursor has outrun. A surviving SST is still read from the start
286+
of the key's history; letting the scan *begin* at the cursor within the prefix needs
287+
an upstream SlateDB change to accept a **sub-range** on `scan_prefix`, which would
288+
avoid re-reading already-seen records — an optimization, not a correctness gap, and
289+
out of scope here.
272290

273291
## Wire surface
274292

@@ -281,10 +299,12 @@ or wire today, so this is purely additive.
281299
The standalone reader's SST bound is the sequence of the bound *key*, not the SST's
282300
maximum sequence, so within the newest SST it can fall short of the true tip — a
283301
caught-up follower may re-scan that SST once before finding it empty. The
284-
global-sequence-range policy's per-SST `max` is that maximum, so sourcing the
285-
frontier from it instead would close that part of the gap (the newest SST's `max` is
286-
its exact tip), eliminating the redundant scan — while changing only how
287-
`LogReadView.frontier` is computed, not the `next_sequence` contract.
302+
sequence-range policy's per-SST `max` is that maximum; since it is stored relative
303+
to the segment, the global frontier is `start_seq + table_max` for an SST in the
304+
active segment. Sourcing the frontier from that would close this part of the gap
305+
(the newest SST's `max` is its exact tip), eliminating the redundant scan — while
306+
changing only how `LogReadView.frontier` is computed, not the `next_sequence`
307+
contract.
288308

289309
This is a tightening, not an unblock: the flush-granular frontier above already works
290310
today with no upstream change. The tighter version needs a way to *read* a stored

log/src/filter_prefix.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,26 @@ fn prefix_len_for_point(bytes: &[u8]) -> usize {
154154
}
155155
}
156156

157+
/// Byte length of the `(segment, user_key)` prefix of a **LogEntry** key or
158+
/// scan-prefix: the bytes through the user-key terminator, excluding any
159+
/// trailing `var_u64(relative_seq)`. Returns `None` for malformed input, a
160+
/// record type other than `LogEntry`, or a LogEntry whose terminator is not
161+
/// present in `bytes`.
162+
///
163+
/// Shared with [`crate::filter_sequence`] so the sequence-range filter hashes
164+
/// exactly the prefix bytes this extractor feeds the bloom — keeping the two
165+
/// filters' per-key identities aligned. Unlike [`prefix_len_for_point`], this
166+
/// never panics: it is fed both stored keys and caller-supplied scan prefixes.
167+
pub(crate) fn log_entry_prefix_len(bytes: &[u8]) -> Option<usize> {
168+
if !has_segmented_prefix(bytes) {
169+
return None;
170+
}
171+
match decode_record_type(bytes[RECORD_TYPE_OFFSET])? {
172+
RecordType::LogEntry => terminated_bytes::find_terminator_end(bytes, SEGMENTED_PREFIX_LEN),
173+
_ => None,
174+
}
175+
}
176+
157177
/// Computes the prefix length for a caller-supplied scan prefix. Returns
158178
/// `None` when this impl cannot guarantee the truncation invariant
159179
/// described in the module doc.

0 commit comments

Comments
 (0)