Skip to content

Segmented Compaction

Split a dataset into independent LSM trees, each with its own compaction and retention policy

Segmented compaction splits a database into independent LSM trees keyed by a prefix. Segments let each tree independently configure compaction policy to match its access pattern needs, and let a whole segment be dropped cheaply once it ages out. All segments share a WAL and writes across segments can be done transactionally/atomically.

A PrefixExtractor derives a segment prefix from each key, and each segment becomes its own logical LSM tree. Because segments own disjoint key ranges, they are compacted and retired independently, and reads/scans automatically prune to the segments overlapping the query.

Common fits:

  • Timeseries / append-ordered data: one segment per time bucket or log segment; old segments freeze and age out as a unit.
  • Metadata/data separation: isolate a churny, frequently-read metadata keyspace from bulky, write-once data so each gets its own compaction and caching policy.
  • Column-family-like isolation: give small, frequently-overwritten structures an aggressive policy while slow-moving data uses a low-write-amplification one.

See RFC 0024 for the full design.

Configure an extractor at creation with DbBuilder::with_segment_extractor. The application encodes segment boundaries in the key (e.g. an hour bucket, a log segment id); the extractor returns the prefix length that identifies the segment. The example below routes every key to the segment named by its first three bytes.

use std::sync::Arc;
use slatedb::{Db, PrefixExtractor, PrefixTarget};
struct FixedThreeByteExtractor;
impl PrefixExtractor for FixedThreeByteExtractor {
fn name(&self) -> &str { "fixed_three_byte" }
fn prefix_len(&self, target: &PrefixTarget) -> Option<usize> {
let (PrefixTarget::Point(key) | PrefixTarget::Prefix(key)) = target;
(key.len() >= 3).then_some(3)
}
}
let db = Db::builder(path, object_store)
.with_segment_extractor(Arc::new(FixedThreeByteExtractor))
.build()
.await?;

List the segments that currently exist (in the manifest or in memtables) with DbStatus::list_segments().

An extractor’s name is fixed, but its implementation may evolve when the change is backward-compatible. For example, a schema byte can reserve disjoint key ranges for successive segmentation policies:

# all records below use a schema marker as the first part of the key prefix
# initially use weekly partitions
[01][week]... -> [01][week] # v1: weekly segments
# later migrate to daily partitions
[02][day]... -> [02][day] # v2: daily segments

The new implementation must keep extracting the original weekly prefixes for all v1 keys while adding daily prefixes only for v2 keys. In general:

  • Keep the same stable name and preserve the behavior of previous PrefixExtractor implementations.
  • Ensure segment prefixes from all versions form an antichain: no prefix may be a prefix of another (a schema discriminator at the start of the key is a simple way to reserve disjoint ranges).

SlateDB checks the name and, on open, asks the configured extractor to recognize every persisted segment prefix. It also rejects new writes that would introduce a nested prefix. These are guardrails rather than complete compatibility verification: SlateDB does not persist the implementation’s version or revalidate every existing key. Maintaining backward-compatible routing under a stable name is therefore the application’s responsibility.

Each segment is compacted on its own schedule. The default size-tiered CompactionScheduler applies per segment, and compactions in different segments are parallel-safe (disjoint keys, no cross-segment ordering). Embed a semantic hint in the prefix (e.g. a time bucket) and a custom scheduler can vary policy per segment.

The default scheduler never drops data; segment retention is the application’s job. To retire a whole segment, schedule a CompactionSpec::drain_segment compaction. Unlike a merge, a drain reads and rewrites nothing. Instead it detaches the segment’s L0s and sorted runs from the manifest, leaving a drain marker (l0=[], compacted=[]) that the writer prunes, after which the garbage collector reclaims the files.

A drain is a CompactionSpec::drain_segment submitted through Admin.submit_compaction, built from the segment’s L0 SSTs and sorted runs read from read_compactor_state_view. Every binding mirrors this API:

use bytes::Bytes;
use slatedb::admin::Admin;
use slatedb::compactor::{CompactionSpec, SourceId};
let admin = Admin::builder(path, object_store).build();
let view = admin.read_compactor_state_view().await?;
let segment = Bytes::from_static(b"hour=10/");
if let Some(seg) = view.manifest().segment(&segment) {
// Drain every L0 SST and sorted run currently visible in the segment.
let sources: Vec<SourceId> = seg
.l0()
.iter()
.map(|v| SourceId::SstView(v.id))
.chain(seg.compacted().iter().map(|sr| SourceId::SortedRun(sr.id)))
.collect();
admin.submit_compaction(CompactionSpec::drain_segment(segment, sources)).await?;
}

To retire segments automatically (e.g. by a TTL), implement CompactionScheduler and return drain_segment specs from propose, wired in with CompactorBuilder::with_scheduler_supplier.