@@ -17,7 +17,7 @@ combines two cooperating mechanisms:
1717 key advances the watermark for all keys.
18182 . ** 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
167167We 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
256271otherwise unused.
257272
258273Compatibility: an incompatible format change bumps the policy ` name ` , so a reader
@@ -265,10 +280,13 @@ treated as match-everything.
265280SlateDB evaluates SST filters on ` get ` and ` scan_prefix ` , but ** not** on plain
266281range ` scan ` . A per-key scan is naturally a prefix scan — all sequences for a key
267282share 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.
281299The standalone reader's SST bound is the sequence of the bound * key* , not the SST's
282300maximum sequence, so within the newest SST it can fall short of the true tip — a
283301caught-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
289309This is a tightening, not an unblock: the flush-granular frontier above already works
290310today with no upstream change. The tighter version needs a way to * read* a stored
0 commit comments