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().

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.