This is the full developer documentation for SlateDB
# Block Transforms
> How SlateDB transforms SST blocks before it writes them to object storage
[`BlockTransformer`](https://docs.rs/slatedb/latest/slatedb/trait.BlockTransformer.html) lets SlateDB apply a reversible byte transform to SST blocks, usually for encryption. SlateDB applies it to data blocks and to filter, index, and stats blocks in both WAL SSTs and compacted SSTs.
SlateDB does not transform manifest files, compaction-state files, or the trailing [`SsTableInfo`](https://github.com/slatedb/slatedb/blob/main/slatedb/src/db_state.rs) record and final offset/version footer in an SST. It reads that footer first so it can find the stored blocks and learn which compression codec to use.
## Order
[Section titled “Order”](#order)
On write, SlateDB encodes the block, optionally compresses it, passes the bytes to [`BlockTransformer::encode`](https://docs.rs/slatedb/latest/slatedb/trait.BlockTransformer.html#tymethod.encode), computes CRC32 over the transformed bytes, and writes the result to object storage.
On read, SlateDB fetches the stored bytes, verifies the CRC32, calls [`BlockTransformer::decode`](https://docs.rs/slatedb/latest/slatedb/trait.BlockTransformer.html#tymethod.decode), optionally decompresses the decoded bytes, and then decodes the block contents.
The checksum covers the transformed payload, not the original plaintext block. Corruption in storage is detected before SlateDB calls `decode`. A mismatched transformer usually fails later, during `decode`, decompression, or block decoding.
If you combine a transformer with [Compression](/docs/design/compression), the transformer sees compressed bytes, not raw row encodings.
## Contract
[Section titled “Contract”](#contract)
The trait has two async methods, [`encode`](https://docs.rs/slatedb/latest/slatedb/trait.BlockTransformer.html#tymethod.encode) and [`decode`](https://docs.rs/slatedb/latest/slatedb/trait.BlockTransformer.html#tymethod.decode). `decode` must invert `encode` for every block SlateDB writes. The methods are async so implementations can offload CPU-heavy work or call an external service.
SlateDB does not record transformer identity, key ID, or format version in `SsTableInfo`. If you need key rotation or format changes, make the stored block self-describing or keep compatible configuration outside SlateDB.
## Configuration
[Section titled “Configuration”](#configuration)
Every process that reads or writes SST blocks needs compatible transformer logic.
* Writers use [`DbBuilder::with_block_transformer`](https://docs.rs/slatedb/latest/slatedb/struct.DbBuilder.html#method.with_block_transformer).
* Standalone compactors use [`CompactorBuilder::with_block_transformer`](https://docs.rs/slatedb/latest/slatedb/struct.CompactorBuilder.html#method.with_block_transformer).
* Read-only handles use [`DbReaderBuilder::with_block_transformer`](https://docs.rs/slatedb/latest/slatedb/struct.DbReaderBuilder.html#method.with_block_transformer).
* SST inspection uses [`SstReader::new`](https://docs.rs/slatedb/latest/slatedb/struct.SstReader.html#method.new).
If a writer used a transformer and a reader does not, SlateDB can still open the footer metadata, but reads of the filter, index, stats, or data blocks fail. [Readers](/docs/design/readers) covers the reader-side configuration in more detail.
[`WalReader`](https://docs.rs/slatedb/latest/slatedb/struct.WalReader.html) does not expose a `BlockTransformer` setting today. WAL SSTs written with a custom transformer are not inspectable through that API.
# Caching
> How SlateDB caches SST data and object-store reads
SlateDB has two separate cache layers for SST reads. The block cache stores decoded SST blocks and metadata. The object-store cache stores raw bytes fetched from the object store. You can use either layer or both.
## Block Cache
[Section titled “Block Cache”](#block-cache)
The block cache sits in the SST reader. It stores decoded data blocks, index blocks, filter blocks, and stats blocks. A hit here avoids the remote `GET`, and it also avoids decoding the block again.
[`DbBuilder`](https://docs.rs/slatedb/latest/slatedb/db/struct.DbBuilder.html) installs a [`SplitCache`](https://docs.rs/slatedb/latest/slatedb/db_cache/struct.SplitCache.html) by default. `SplitCache` keeps data blocks separate from SST metadata so large reads are less likely to evict indexes and Bloom filters.
You can replace the cache with [`DbBuilder::with_db_cache`](https://docs.rs/slatedb/latest/slatedb/db/struct.DbBuilder.html#method.with_db_cache), or disable it with [`DbBuilder::with_db_cache_disabled`](https://docs.rs/slatedb/latest/slatedb/db/struct.DbBuilder.html#method.with_db_cache_disabled). If you want to plug in your own implementation, use the [`DbCache`](https://docs.rs/slatedb/latest/slatedb/db_cache/trait.DbCache.html) trait.
[`FoyerHybridCache`](https://docs.rs/slatedb/latest/slatedb/db_cache/foyer_hybrid/struct.FoyerHybridCache.html) also belongs in this layer. It is a `DbCache` implementation, so it caches decoded SST entries, not raw object-store bytes. Its memory tier works like any other database cache. Its disk tier can still avoid remote I/O and decode work, but it adds a local disk read on a miss in memory. See [Configuring Foyer Cache](/docs/operations/foyer-cache) for tuning guidance.
## Object-Store Cache
[Section titled “Object-Store Cache”](#object-store-cache)
[`CachedObjectStore`](https://docs.rs/slatedb/latest/slatedb/cached_object_store/struct.CachedObjectStore.html) is a second cache layer for raw object-store bytes. It splits each object into fixed-size parts, stores those parts under a local root folder, and can serve later `GET` and `HEAD` requests from those local files when the needed parts are already present.
There are two ways to enable it. The first is configuration: set [`root_folder`](https://docs.rs/slatedb/latest/slatedb/config/struct.ObjectStoreCacheOptions.html#structfield.root_folder) in [`ObjectStoreCacheOptions`](https://docs.rs/slatedb/latest/slatedb/config/struct.ObjectStoreCacheOptions.html), and SlateDB builds the cache from those options and wraps the object store you passed to the builder in it. This is the only way to enable the cache from a settings file.
The second is to build the cache yourself with [`CachedObjectStore::builder`](https://docs.rs/slatedb/latest/slatedb/cached_object_store/struct.CachedObjectStore.html#method.builder) and pass it to SlateDB as the object store:
```rust
let cache = CachedObjectStore::builder("/var/slatedb-cache", object_store)
.with_cache_on_flush(true)
.with_cache_on_compaction(true)
.with_max_cache_size_bytes(Some(16 * 1024 * 1024 * 1024))
.build()
.await?;
let db = Db::builder(path, cache).build().await?;
```
Building it yourself lets you hold on to the cache instance, share it across several `Db` and `DbReader` instances in the same process, and warm it directly. Leave [`root_folder`](https://docs.rs/slatedb/latest/slatedb/config/struct.ObjectStoreCacheOptions.html#structfield.root_folder) unset when you do this, otherwise SlateDB wraps your cache in a second one.
Either way the cache ends up as the innermost layer, closest to the object store you provided. SlateDB adds its own retry and metrics handling above it, so a read served from the cache is still counted as an object-store request. See [Metrics](/docs/operations/metrics) for what that means for the `slatedb.object_store.*` metrics.
This cache stores object-store bytes, not decoded SST blocks. It helps when the block cache is cold because it can avoid a remote read even though SlateDB still needs to read from local disk and decode the block afterward. On a miss, SlateDB aligns the requested range to the configured part size, fetches that larger range from the object store, and saves the returned parts locally. The default part size is 4 MiB.
The built-in object-store cache is disk-backed today. The root folder is a filesystem path, and the current implementation stores parts under that directory. If you want an in-memory cache, use the block cache layer instead.
## How Caches Get Filled
[Section titled “How Caches Get Filled”](#how-caches-get-filled)
Reads consult the block cache first. On a miss there, SlateDB fetches bytes through the object-store layer, which may itself hit the object-store cache before going remote.
[`ReadOptions::cache_blocks`](https://docs.rs/slatedb/latest/slatedb/config/struct.ReadOptions.html#structfield.cache_blocks) and [`ScanOptions::cache_blocks`](https://docs.rs/slatedb/latest/slatedb/config/struct.ScanOptions.html#structfield.cache_blocks) decide whether SlateDB inserts a decoded data block after fetching it on a miss. They do not disable cache lookups. Reads and scans cache SST metadata, such as indexes, filters, and stats, independently of `cache_blocks`.
The defaults reflect the usual access patterns. Point reads default to `cache_blocks = true` because they are more likely to revisit hot data. Scans default to `cache_blocks = false` so a long sequential read does not fill the cache with data blocks that probably will not be reused soon. Scans can still benefit from entries that are already hot. Internal tasks follow the same idea: WAL replay and compaction read SSTs without populating the foreground cache.
Writes fill the block cache as well. [`BlockCachePolicy`](https://docs.rs/slatedb/latest/slatedb/struct.BlockCachePolicy.html) selects which components of an SST are inserted as that SST is written, and you install it with [`DbBuilder::with_block_cache_policy`](https://docs.rs/slatedb/latest/slatedb/db/struct.DbBuilder.html#method.with_block_cache_policy). The policy applies to the block cache only. Admission into the object-store cache is controlled separately, as described below.
The policy holds one target list per write source. The default inserts data blocks, the index, and the filters after a memtable flush, and inserts only the index and the filters as compaction output is written, so compaction does not evict blocks that reads have made hot. Replace either list with [`BlockCachePolicy::with_flush_targets`](https://docs.rs/slatedb/latest/slatedb/struct.BlockCachePolicy.html#method.with_flush_targets) or [`BlockCachePolicy::with_compaction_output_targets`](https://docs.rs/slatedb/latest/slatedb/struct.BlockCachePolicy.html#method.with_compaction_output_targets). An empty list disables insertion for that source.
Each list holds [`CacheTarget`](https://docs.rs/slatedb/latest/slatedb/db_cache/enum.CacheTarget.html) values:
* `CacheTarget::Filters` inserts the SST filter blocks, if any exist.
* `CacheTarget::Index` inserts the SST index.
* `CacheTarget::Stats` inserts the SST stats block, if one exists.
* `CacheTarget::data(range)` inserts the data blocks whose key span overlaps `range`.
`CacheTarget::data` accepts any key range, so `CacheTarget::data::<&[u8], _>(..)` selects every data block, while `CacheTarget::data(b"user:".as_slice()..b"user;".as_slice())` selects only the blocks covering that key range. This policy caches all four components after a flush and nothing after compaction:
```rust
use slatedb::{BlockCachePolicy, CacheTarget, Db};
let policy = BlockCachePolicy::default()
.with_flush_targets(&[
CacheTarget::data::<&[u8], _>(..),
CacheTarget::Index,
CacheTarget::Filters,
CacheTarget::Stats,
])
.with_compaction_output_targets(&[]);
let db = Db::builder(path, object_store)
.with_block_cache_policy(policy)
.build()
.await?;
```
The disk cache stays disabled unless you enable it, either by setting `object_store_cache_options.root_folder` or by passing your own `CachedObjectStore` to the builder.
By default, writes go straight to the upstream object store and do not populate the object-store cache. Each SST write source has its own admission flag: [`cache_on_flush`](https://docs.rs/slatedb/latest/slatedb/config/struct.ObjectStoreCacheOptions.html#structfield.cache_on_flush) stores SSTs written by memtable flushes locally, and [`cache_on_compaction`](https://docs.rs/slatedb/latest/slatedb/config/struct.ObjectStoreCacheOptions.html#structfield.cache_on_compaction) does the same for compaction output. The builder exposes the same two flags as [`with_cache_on_flush`](https://docs.rs/slatedb/latest/slatedb/cached_object_store/struct.CachedObjectStoreBuilder.html#method.with_cache_on_flush) and [`with_cache_on_compaction`](https://docs.rs/slatedb/latest/slatedb/cached_object_store/struct.CachedObjectStoreBuilder.html#method.with_cache_on_compaction). Enabling them can help if readers are likely to touch freshly written SSTs soon afterward. WAL and manifest writes are never cached.
## Warming the Object-Store Cache
[Section titled “Warming the Object-Store Cache”](#warming-the-object-store-cache)
If you want the disk cache warm before serving traffic, and you configured it through [`ObjectStoreCacheOptions`](https://docs.rs/slatedb/latest/slatedb/config/struct.ObjectStoreCacheOptions.html), set [`preload_disk_cache_on_startup`](https://docs.rs/slatedb/latest/slatedb/config/struct.ObjectStoreCacheOptions.html#structfield.preload_disk_cache_on_startup) to [`PreloadLevel::L0Sst`](https://docs.rs/slatedb/latest/slatedb/config/enum.PreloadLevel.html#variant.L0Sst) or [`PreloadLevel::AllSst`](https://docs.rs/slatedb/latest/slatedb/config/enum.PreloadLevel.html#variant.AllSst). `Db` and `DbReader` load recent SSTs, or all SSTs, into the local cache on open until the cache size limit is reached.
That setting only applies to a cache SlateDB built for you. A cache you built yourself is warmed by you. Read the current SST set from [`DbMetadataOps::manifest`](https://docs.rs/slatedb/latest/slatedb/ops/trait.DbMetadataOps.html#tymethod.manifest), resolve each SST id to an object-store path with [`PathResolver::sst_path`](https://docs.rs/slatedb/latest/slatedb/struct.PathResolver.html#method.sst_path), and pass the paths to [`CachedObjectStore::load_files_to_cache`](https://docs.rs/slatedb/latest/slatedb/cached_object_store/struct.CachedObjectStore.html#method.load_files_to_cache). Construct the resolver with [`PathResolver::new`](https://docs.rs/slatedb/latest/slatedb/struct.PathResolver.html#method.new), which takes the manifest as well as the database path because a manifest can reference SSTs owned by another database after a clone. The `max_bytes` budget is applied in path order, so order paths by priority. Fetches are best-effort, and failures are logged and skipped.
## Sharing Between Instances
[Section titled “Sharing Between Instances”](#sharing-between-instances)
The two cache layers behave differently when you open multiple [`Db`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html) or [`DbReader`](https://docs.rs/slatedb/latest/slatedb/struct.DbReader.html) instances against the same cache.
For the object-store cache, sharing is straightforward. If multiple instances on the same machine use the same root folder, whether they set [`root_folder`](https://docs.rs/slatedb/latest/slatedb/config/struct.ObjectStoreCacheOptions.html#structfield.root_folder) or pass it to `CachedObjectStore::builder`, they reuse the same local cache directory. Instances in the same process can also share one `CachedObjectStore` instance directly. For the same database path, that lets one instance benefit from parts fetched by another. When you use [`Db::resolve_object_store`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html#method.resolve_object_store) and provide different path arguments to builder methods like [`DbBuilder::new`](https://docs.rs/slatedb/latest/slatedb/db/struct.DbBuilder.html#method.new), SlateDB keeps the cached files under different path prefixes, so they do not clobber one another. Using the same part size gives the best reuse. Different part sizes can coexist, but they will not reuse the same cached part files.
If you provide an object store directly with `PrefixStore` or other custom wrapping instead of using `Db::resolve_object_store`, you must configure different root folder values for each instance to prevent cache collisions. SlateDB no longer automatically resolves root prefixes from metadata locations.
The block cache is different. Both [`DbBuilder::with_db_cache`](https://docs.rs/slatedb/latest/slatedb/db/struct.DbBuilder.html#method.with_db_cache) and [`DbReaderBuilder::with_db_cache`](https://docs.rs/slatedb/latest/slatedb/db/struct.DbReaderBuilder.html#method.with_db_cache) let you pass in your own cache object, so you can choose to reuse the same process-local cache implementation across builders. For `Db`, that mainly gives you a shared memory or disk budget, not shared hits: SlateDB scopes each instance’s entries so one `Db` does not read another `Db`’s cached blocks by accident. If your main goal is cross-instance warming, the object-store cache is the better fit.
A cache you pass in stays owned by you: `Db::close()` and `DbReader::close()` never close it, so closing one instance does not affect the others sharing it. Once every instance using the cache is closed, call [`DbCache::close`](https://docs.rs/slatedb/latest/slatedb/db_cache/trait.DbCache.html#method.close) yourself so hybrid implementations can flush their memory tier to disk.
For `DbReader`, treat a caller-managed block cache as an advanced optimization. It can make sense to keep one reader-side cache per database in a single process, but if you want a cache that is naturally shared between writers and readers, or across many short-lived instances, the object-store cache is the simpler mechanism to share.
## Tradeoffs
[Section titled “Tradeoffs”](#tradeoffs)
The block cache saves decode work and can also save remote I/O. With a memory-only implementation, it avoids both. With [`FoyerHybridCache`](https://docs.rs/slatedb/latest/slatedb/db_cache/foyer_hybrid/struct.FoyerHybridCache.html), a hit in the disk tier still avoids remote I/O and re-decoding, but it adds a local disk read.
The object-store cache only saves the remote I/O. SlateDB still has to read from local disk and decode the block. Many deployments use both layers: a block cache for the hot decoded working set, and an object-store cache for colder bytes and range-aligned fetches. Block size matters here too, because it controls cache granularity and therefore affects hit rate and read amplification. [Tuning](/docs/operations/tuning) covers that knob.
If you enable [`FoyerHybridCache`](https://docs.rs/slatedb/latest/slatedb/db_cache/foyer_hybrid/struct.FoyerHybridCache.html) and also enable the object-store cache, SlateDB may store the same SST data twice on local disk. That can still be a reasonable trade if the object-store cache’s range prefetching saves enough remote reads, but the cost is more local storage traffic.
# Change Data Capture
> Stream SlateDB WAL files with a live SlateDbWalReader iterator
Change data capture (CDC) turns writes in SlateDB into a durable change stream that external systems can consume. SlateDB exposes native-WAL CDC through **slatedb::wal::SlateDbWalReader**.
The reader opens a live iterator from the first unconsumed WAL file ID. The iterator owns polling and waits internally when it reaches the current WAL tail; the caller owns durable cursor storage.
## When to use WAL-based CDC
[Section titled “When to use WAL-based CDC”](#when-to-use-wal-based-cdc)
Use WAL-based CDC when you need:
* A low-latency stream of inserts, updates, and deletes.
* A durable append-only feed that can be replayed.
* Integration with downstream systems such as Kafka, Pulsar, or data lakes.
WAL-based CDC is not a bootstrap or backfill API. Combine a database backfill with WAL streaming when an initial full copy is required.
## Streaming model
[Section titled “Streaming model”](#streaming-model)
Keep a durable cursor containing the last fully consumed WAL file ID. Use zero when starting from the beginning unless you have a more appropriate manifest-derived cursor.
To consume the stream:
1. Compute **cursor + 1**, checking for overflow, and create one iterator with an unbounded end range beginning at that WAL file ID.
2. Repeatedly call **next()**. When the iterator reaches the current tail, the call waits and polls internally for the next WAL file.
3. Emit every row in each **WalRows** batch.
4. After the whole batch succeeds, persist its **last\_consumed\_wal\_file\_id** as the new cursor.
5. After a restart, create a new unbounded iterator beginning at the persisted cursor plus one.
The live iterator does not report exhaustion at the current tail; **next()** remains pending until a new WAL file is visible or an error occurs. **last\_wal\_file\_id** remains available when an application needs a point-in-time tail snapshot, but it is not part of the streaming loop.
## Batches and cursor progress
[Section titled “Batches and cursor progress”](#batches-and-cursor-progress)
One **WalRows** batch represents one fully consumed WAL file and contains:
* **rows**: the file’s row entries, in sequence order
* **last\_consumed\_wal\_file\_id**: the durable cursor after that file
Empty fence WAL files produce a batch with no rows. The cursor still advances, so consumers must process the batch rather than flattening batches into rows and losing progress information.
Each row is a **RowEntry** with:
* **key**: key bytes
* **value**: a **ValueDeletable** value, merge operand, or tombstone
* **seq**: the globally increasing sequence number
* **create\_ts** and **expire\_ts**: optional timestamps
Deletes appear as tombstones. Merge operands remain raw merge values; downstream consumers must apply the merge logic themselves when materialized values are required.
## Visibility and durability
[Section titled “Visibility and durability”](#visibility-and-durability)
SlateDbWalReader only sees WAL SSTs flushed to object storage. WAL flushes occur when:
* The WAL buffer reaches its size threshold.
* The configured flush interval elapses.
* The caller explicitly requests a WAL or memtable flush.
A memtable flush first forces pending WAL data to durable storage.
The database must have its WAL enabled. WAL support is enabled by default.
## Basic live stream
[Section titled “Basic live stream”](#basic-live-stream)
This example creates one unbounded iterator, emits both existing and later writes through it, and advances a durable file cursor.
main.rs
```rust
use slatedb::config::{FlushOptions, FlushType};
use slatedb::object_store::{memory::InMemory, path::Path};
use slatedb::wal::{SlateDbWalReaderBuilder, WalReader as _, WalRows};
use slatedb::{Db, RowEntry, ValueDeletable};
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let object_store = Arc::new(InMemory::new());
let path = "/change-data-capture-example";
let db = Db::open(path, object_store.clone()).await?;
db.put(b"user:1", b"alice").await?;
db.put(b"user:2", b"bob").await?;
db.delete(b"user:2").await?;
flush_wal(&db).await?;
let wal_reader = SlateDbWalReaderBuilder::new()
.with_object_store(object_store)
.with_path(Path::from(path))
.build()?;
let mut cursor = 0_u64;
let start_wal_id = cursor
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("WAL cursor cannot advance"))?;
let mut iterator = wal_reader.iterator((start_wal_id..).into()).await?;
// Drain the writes that already exist. Empty fence WALs still advance the
// cursor, so stop based on emitted rows only after persisting every batch.
let mut emitted_rows = 0;
while emitted_rows < 3 {
let batch = iterator
.next()
.await?
.ok_or_else(|| anyhow::anyhow!("live WAL iterator ended unexpectedly"))?;
emitted_rows += emit_batch(&batch, &mut cursor);
}
// Keep the same iterator alive. Its next call observes this later WAL;
// callers do not need to discover a new tail or create another iterator.
db.put(b"user:3", b"carol").await?;
flush_wal(&db).await?;
while emitted_rows < 4 {
let batch = iterator
.next()
.await?
.ok_or_else(|| anyhow::anyhow!("live WAL iterator ended unexpectedly"))?;
emitted_rows += emit_batch(&batch, &mut cursor);
}
db.close().await?;
Ok(())
}
async fn flush_wal(db: &Db) -> Result<(), slatedb::Error> {
db.flush_with_options(FlushOptions {
flush_type: FlushType::Wal,
})
.await
}
fn emit_batch(batch: &WalRows, cursor: &mut u64) -> usize {
for row in &batch.rows {
emit_row(batch.last_consumed_wal_file_id, row);
}
// Persist only after every row in this WAL file has been emitted. This
// also advances across empty fence WALs.
*cursor = batch.last_consumed_wal_file_id;
println!("persist cursor={cursor}");
batch.rows.len()
}
fn emit_row(wal_id: u64, row: &RowEntry) {
let key = String::from_utf8_lossy(row.key.as_ref());
match &row.value {
ValueDeletable::Value(value) => {
let value = String::from_utf8_lossy(value.as_ref());
println!("wal_id={wal_id} seq={} upsert {key}={value}", row.seq);
}
ValueDeletable::Merge(value) => {
let value = String::from_utf8_lossy(value.as_ref());
println!("wal_id={wal_id} seq={} merge {key}+={value}", row.seq);
}
ValueDeletable::Tombstone => {
println!("wal_id={wal_id} seq={} delete {key}", row.seq);
}
}
}
```
Persisting the cursor only after a complete batch gives at-least-once delivery if a process fails while emitting that WAL file. Make the sink idempotent or transactionally couple sink writes with cursor persistence when duplicates are unacceptable.
## Ordering and resumability
[Section titled “Ordering and resumability”](#ordering-and-resumability)
WAL file IDs increase monotonically, and entries within each file are returned in sequence order. The minimal cursor is therefore the last fully consumed WAL file ID.
Do not advance the cursor per row. Doing so can lose progress for empty fence WALs and can incorrectly mark a partially emitted WAL file as complete.
If a requested file has already been garbage collected, iteration reports a truncation/data error. The consumer must not silently skip the gap.
## Backfills plus streaming
[Section titled “Backfills plus streaming”](#backfills-plus-streaming)
For a full backfill:
1. Begin live WAL streaming and buffer changes.
2. Create a detached database checkpoint.
3. Scan the checkpoint or a clone for existing data.
4. Apply buffered WAL changes in sequence order.
5. Delete the checkpoint.
6. Continue streaming from the persisted WAL cursor.
The sink should tolerate overlap between the backfill and WAL stream.
## WAL retention and GC
[Section titled “WAL retention and GC”](#wal-retention-and-gc)
WAL SSTs are garbage collected according to the configured retention policy. Set WAL GC minimum age long enough for the slowest consumer and monitor cursor lag. A consumer that requests a deleted WAL receives a truncation error instead of silently continuing.
## Dedicated WAL object stores
[Section titled “Dedicated WAL object stores”](#dedicated-wal-object-stores)
When a database uses a dedicated WAL object store, construct the reader with both stores: the main object store is used for manifest and GC-cutoff state, and the dedicated WAL object store is used for WAL SSTs. In Rust, use **SlateDbWalReader::new\_for\_db\_with\_wal\_object\_store**.
# Checkpoints
> How SlateDB pins a manifest version for reads and garbage collection
Checkpoints give SlateDB a cheap way to hold onto one stable view of the database while the live database keeps moving. They are useful for long-running reads, read-only clients that need a fixed view, [clones](/docs/design/clones), and administrative workflows that must keep old SSTs and manifests alive while compaction and garbage collection continue in the background.
A checkpoint is a durable reference to one manifest version. SlateDB stores checkpoints in the latest manifest. Each checkpoint records an ID, the pinned `manifest_id`, its creation time, an optional expiration time, and an optional name.
Creating a checkpoint does not copy WAL or SST files. It only adds metadata to the manifest, so it is cheap. The pinned manifest still matters, though, because it defines the L0 SSTs, sorted runs, and other database state that readers see.
Note
A checkpoint is not the same thing as a [`DbSnapshot`](https://docs.rs/slatedb/latest/slatedb/struct.DbSnapshot.html). A checkpoint is durable manifest metadata. It survives restarts and pins manifests and SSTs so garbage collection cannot remove them. A `DbSnapshot` is an in-process read view at a sequence number. It gives one running client a stable view, but it is not stored in the manifest and does not keep old manifests or SSTs alive by itself. See [Consistency](/docs/design/consistency) for more on snapshots.
## What It Protects
[Section titled “What It Protects”](#what-it-protects)
The garbage collector keeps any manifest and SST file that is still reachable from an active checkpoint. That is how SlateDB holds a stable read view open while compaction and garbage collection continue in the background.
When a checkpoint expires or is deleted, SlateDB removes that checkpoint entry from the manifest. After that, GC may reclaim manifests and SSTs that are no longer referenced by the latest database state or by any other checkpoint. [Garbage Collection](/docs/design/gc) covers the cleanup flow.
A new checkpoint can also be created from an existing checkpoint through [`CheckpointOptions::source`](https://docs.rs/slatedb/latest/slatedb/config/struct.CheckpointOptions.html#structfield.source). In that case, the new checkpoint reuses the source checkpoint’s `manifest_id`. This is useful when you want a different lifetime or name without pinning newer database state.
## How SlateDB Creates Checkpoints
[Section titled “How SlateDB Creates Checkpoints”](#how-slatedb-creates-checkpoints)
[`Db::create_checkpoint`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html#method.create_checkpoint) creates a checkpoint from an open writer handle. [`CheckpointScope::All`](https://docs.rs/slatedb/latest/slatedb/config/enum.CheckpointScope.html#variant.All) flushes WAL data and memtables first, so the checkpoint includes writes issued before the call. [`CheckpointScope::Durable`](https://docs.rs/slatedb/latest/slatedb/config/enum.CheckpointScope.html#variant.Durable) skips that extra flushing work and only includes data that was already durable.
[`Admin::create_detached_checkpoint`](https://docs.rs/slatedb/latest/slatedb/admin/struct.Admin.html#method.create_detached_checkpoint) edits the manifest directly. It does not flush a writer’s in-memory state first, so recent unflushed writes are not included.
[`CheckpointOptions`](https://docs.rs/slatedb/latest/slatedb/config/struct.CheckpointOptions.html) lets you set a lifetime, a source checkpoint, and an optional name. Names are labels for listing checkpoints. They are not unique.
## Readers and Background Tasks
[Section titled “Readers and Background Tasks”](#readers-and-background-tasks)
[`DbReader`](https://docs.rs/slatedb/latest/slatedb/struct.DbReader.html) uses checkpoints to separate reader lifetime from writer lifetime. You choose how the reader tracks database state by passing a [`DbReaderMode`](https://docs.rs/slatedb/latest/slatedb/enum.DbReaderMode.html):
* `ManagedCheckpoint` (default): The reader creates and maintains checkpoints while following the latest database state.
* `Checkpoint(id)`: The reader remains pinned to the database state referenced by the supplied checkpoint. It does not follow newer manifests or newer WAL data.
* `FollowLatest`: The reader follows the latest manifest without creating a checkpoint. This mode performs no object-store writes and provides no protection from garbage collection. Reads may fail if referenced objects are deleted. This mode is useful for read-only access to databases not being actively written to, for mirrored databases where manifest changes might not be allowed, or for readers willing to handle missing objects gracefully.
[`DbReaderOptions::checkpoint_lifetime`](https://docs.rs/slatedb/latest/slatedb/config/struct.DbReaderOptions.html#structfield.checkpoint_lifetime) controls how long a `ManagedCheckpoint` reader’s checkpoint lives before it must be refreshed.
SlateDB also uses short-lived internal checkpoints during some background transitions. For example, the compactor writes a temporary checkpoint before publishing a manifest that drops obsolete SST references. That keeps recently replaced SSTs readable until GC can safely remove them.
## Clones
[Section titled “Clones”](#clones)
Clones build on the same mechanism. A clone starts from a checkpoint in the source database, then writes new data into its own path while continuing to reference source SSTs. [RFC 0004](/rfcs/0004-checkpoints) describes the full checkpoint and clone design.
# Clones
> How SlateDB forks a database from a checkpoint without copying SSTs
A clone is a new SlateDB database created from an existing database at a different object-store path. You can think of it as a fork taken at one checkpoint in the source database: the clone starts from that source state, then continues with its own manifest, WAL, and future SST files.
Clone creation is shallow. SlateDB does not copy the SSTs that were already visible at the checkpoint. Instead, the clone manifest records which SST IDs still live in external databases and which checkpoints keep those files alive. The clone reads those SSTs from the source paths while writing new data into its own path.
## Source State
[Section titled “Source State”](#source-state)
[`Admin::create_clone`](https://docs.rs/slatedb/latest/slatedb/admin/struct.Admin.html#method.create_clone) takes a source path and an optional checkpoint ID. If you pass a checkpoint ID, the clone starts from that exact manifest. If you do not pass one, SlateDB creates a short-lived checkpoint against the source database’s latest manifest and uses that as the base.
`Admin::create_clone` works from manifest state only. It does not coordinate with an open writer to flush its in-memory state first. If you need the clone to include recent writes from an active writer, create a checkpoint through [`Db::create_checkpoint`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html#method.create_checkpoint) with [`CheckpointScope::All`](https://docs.rs/slatedb/latest/slatedb/config/enum.CheckpointScope.html#variant.All) and clone from that checkpoint.
## Shared Files
[Section titled “Shared Files”](#shared-files)
The clone reuses any L0 and sorted-run SSTs that were visible at the chosen checkpoint, including SSTs that the source database may already have inherited from an older clone. Reads can therefore span files from the clone’s own path and files from one or more external database paths.
WAL state is handled differently. SlateDB copies any WAL SSTs referenced by the chosen checkpoint into the clone’s `wal/` directory, then marks the clone initialized. Recovery for the cloned database uses those copied WAL files, and all future WAL files are written only under the clone path.
## GC Pins
[Section titled “GC Pins”](#gc-pins)
Clone creation also creates a durable checkpoint in each external database that the clone references. That checkpoint pins the same manifest as the clone’s source checkpoint, so [garbage collection](/docs/design/gc) in the external database cannot delete shared SSTs or older manifests that the clone still needs.
This is why a source checkpoint can be temporary when you omit `parent_checkpoint`. SlateDB first creates a short-lived source checkpoint, then creates a second checkpoint in each external database to keep the referenced state alive for the clone.
## Nested Clones
[Section titled “Nested Clones”](#nested-clones)
A clone can itself be cloned. In that case the new clone inherits the parent clone’s external database references and adds the immediate parent as another external database. The result is still shallow: the newest clone reads old SSTs from the original paths instead of copying them into each intermediate clone.
## Creation Flow
[Section titled “Creation Flow”](#creation-flow)
Clone creation uses two manifest states so retries are safe. SlateDB first writes the clone manifest with `initialized = false`, copies the required WAL SSTs, then updates the manifest to `initialized = true`. If the process stops after the first step, rerunning clone creation validates the same source path and checkpoint before continuing.
The design is described in detail in [RFC 0004](/rfcs/0004-checkpoints).
## Constraints
[Section titled “Constraints”](#constraints)
The clone path must be different from the source path. Clone creation also fails if the source database uses a separate WAL object store. Today, clones only support databases whose WAL and main data share the same object-store root.
# Compaction
The compactor is responsible for taking groups of sorted runs (this doc uses the term sorted run to refer to both sorted runs and l0 ssts) and compacting them together to reduce space amplification (by removing old versions of rows that have been updated/deleted) and read amplification (by reducing the number of sorted runs that need to be searched on a read). It’s made up of a few different components:
* \[`Compactor`]: The main event loop that orchestrates the compaction process.
* \[`CompactorEventHandler`]: The event handler that handles events from the compactor.
* \[`CompactionScheduler`]: The scheduler that discovers compactions that should be performed.
* \[`CompactionExecutor`]: The executor that runs the compaction tasks.
The main event loop listens on the manifest poll ticker to react to manifest poll ticks, the executor worker channel to react to updates about running compactions, and the shutdown channel to discover when it should terminate. It doesn’t actually implement the logic for reacting to these events. This is implemented by \[`CompactorEventHandler`].
The Scheduler is responsible for deciding what sorted runs should be compacted together. It implements the \[`CompactionScheduler`] trait. The implementation is specified by providing an implementation of \[`CompactionSchedulerSupplier`] so different scheduling policies can be plugged into slatedb. Currently, the only implemented policy is the size-tiered scheduler supplied by [`SizeTieredCompactionSchedulerSupplier`](https://github.com/slatedb/slatedb/blob/main/slatedb/src/size_tiered_compaction.rs).
The Executor does the actual work of compacting sorted runs by sort-merging them into a new sorted run. It implements the \[`CompactionExecutor`] trait. Currently, the only implementation is the [`TokioCompactionExecutor`](https://github.com/slatedb/slatedb/blob/main/slatedb/src/compactor_executor.rs), which runs compaction on a local tokio runtime.
## Trivial moves
[Section titled “Trivial moves”](#trivial-moves)
When every input SST has a non-overlapping effective key range, the compactor can reuse the existing SSTs in the destination sorted run. The coordinator completes this [trivial move](https://github.com/facebook/rocksdb/wiki/Compaction-Trivial-Move) itself, without dispatching the job to an executor or reading and rewriting SST data. This reduces compaction I/O while still producing a sorted run of non-overlapping SSTs.
Trivial moves are disabled by default. Set `CompactorOptions::enable_trivial_move` to `true` to enable them.
To split data with different read/write profiles into independent LSM trees — each with its own compaction policy — and to retire whole key ranges cheaply, see [Segmented Compaction](/docs/design/segmented-compaction).
# Compression
> How SlateDB compresses SST data and metadata
SlateDB compresses SST data in two different ways. One is built into the row format and is always on. The other is an optional codec you choose for WAL SSTs and regular SSTs.
* Prefix compression happens inside each SST data block. [`SstRowCodecV2`](https://github.com/slatedb/slatedb/blob/main/slatedb/src/format/row_codec_v2.rs) encodes each key relative to the previous key, with restart points that store full keys so readers can still seek within the block. This reduces repeated key bytes even when block compression is disabled. [Data Modeling](/docs/operations/data-modeling#prefix-compression) covers the effect on key layout.
* Block compression is controlled by [`Settings::compression_codec`](https://docs.rs/slatedb/latest/slatedb/config/struct.Settings.html#structfield.compression_codec) and [`CompressionCodec`](https://docs.rs/slatedb/latest/slatedb/config/enum.CompressionCodec.html). This applies to all blocks (data, indexes, filters, and stats).
## Codecs
[Section titled “Codecs”](#codecs)
The available codecs are a build-time choice. The `slatedb` crate exposes Snappy, Zlib, LZ4, and Zstd behind Cargo features. The `compression` feature enables Snappy. The `zlib`, `lz4`, and `zstd` features enable those codecs directly.
The selected codec is stored in each SST’s metadata. Changing `compression_codec` only affects new WAL and SST files. Existing files keep the codec they were written with, so a database can temporarily contain a mix of uncompressed files and files written with different codecs while flush and compaction rewrite older data.
## Tradeoffs
[Section titled “Tradeoffs”](#tradeoffs)
Compression reduces object-store bytes, network transfer, and cache footprint. That usually helps storage cost and scan-heavy workloads. The cost is CPU on both write and read.
Block size changes the tradeoff. Larger blocks usually compress better. Smaller blocks reduce over-read on point lookups. The [Tuning](/docs/operations/tuning) page covers the operational knobs.
Prefix compression has its own tradeoff. It helps most when adjacent sorted keys share long prefixes, because that reduces repeated key bytes inside a block. The cost is extra decode work within each restart region: after seeking to a restart point, SlateDB might need to reconstruct several keys before it reaches the target entry.
# Consistency
> Learn about SlateDB's consistency model
Snapshots provide consistent read-only views over the entire state of the key-value store.
Once created, it supports all read operations—`get` or `scan`—giving you a stable, consistent view of the data as it existed at the instant the snapshot was taken.
## Creating a snapshot
[Section titled “Creating a snapshot”](#creating-a-snapshot)
Call `snapshot()` on any SlateDB handle.
The call is cheap and non-blocking; it merely pins the current state so it can’t be garbage-collected while the snapshot is alive.
main.rs
```rust
use slatedb::config::Settings;
use slatedb::{Db, Error};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Error> {
// Initialize database
let object_store = Arc::new(object_store::memory::InMemory::new());
let db = Db::builder("my_db", object_store)
.with_settings(Settings::default())
.build()
.await?;
// Write some data to the database
db.put(b"key1", b"original_value").await?;
db.put(b"key2", b"data_before_snapshot").await?;
// Create a snapshot
let snapshot = db.snapshot().await?;
println!("Snapshot created successfully");
// After creating snapshot, make changes to the database
db.put(b"key1", b"modified_value").await?;
// Read from snapshot - should see original data
let snapshot_value1 = snapshot.get(b"key1").await?;
println!(
"Snapshot key1: {:?}",
snapshot_value1.map(|v| String::from_utf8_lossy(&v).to_string())
);
// Read from database - should see updated data
let db_value1 = db.get(b"key1").await?;
println!(
"Database key1: {:?}",
db_value1.map(|v| String::from_utf8_lossy(&v).to_string())
);
Ok(())
}
```
## Reading from a snapshot
[Section titled “Reading from a snapshot”](#reading-from-a-snapshot)
Use the returned snapshot handle exactly like a regular `Db`, but every operation sees the same, immutable state.
main.rs
```rust
use slatedb::config::Settings;
use slatedb::{Db, Error};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Error> {
// Initialize database
let object_store = Arc::new(object_store::memory::InMemory::new());
let db = Db::builder("my_db", object_store)
.with_settings(Settings::default())
.build()
.await?;
// Write some data to the database
db.put(b"apple", b"red").await?;
db.put(b"banana", b"yellow").await?;
db.put(b"cherry", b"red").await?;
// Create a snapshot
let snapshot = db.snapshot().await?;
println!("Snapshot created successfully");
// After creating snapshot, make changes to the database
db.put(b"apple", b"green").await?; // Modify existing
db.put(b"date", b"brown").await?; // Add new key
db.delete(b"banana").await?; // Delete existing
// Scan from snapshot - should see original data
println!("\nScanning snapshot:");
let mut snapshot_iter = snapshot.scan(b"a".to_vec()..=b"z".to_vec()).await?;
while let Some(kv) = snapshot_iter.next().await? {
println!(
" {}: {}",
String::from_utf8_lossy(&kv.key),
String::from_utf8_lossy(&kv.value)
);
}
// Scan from database - should see updated data
println!("\nScanning database:");
let mut db_iter = db.scan(b"a".to_vec()..=b"z".to_vec()).await?;
while let Some(kv) = db_iter.next().await? {
println!(
" {}: {}",
String::from_utf8_lossy(&kv.key),
String::from_utf8_lossy(&kv.value)
);
}
Ok(())
}
```
# Files
A SlateDB object store bucket contains the following directories:
```plaintext
path/to/db/
├─ manifest/
│ ├─ 00000000000000000001.manifest
│ ├─ 00000000000000000002.manifest
│ └─ ...
├─ wal/
│ ├─ 00000000000000000001.sst
│ ├─ 00000000000000000002.sst
│ └─ ...
├─ compactions/
│ ├─ 00000000000000000001.compactions
│ ├─ 00000000000000000002.compactions
│ └─ ...
├─ compacted/
│ ├─ 01K3XYV1W2WR4FDVB7A9S319YS.sst
│ ├─ 01K3XYV9JFPSZ5BW3Y1DVMKDFS.sst
│ └─ ...
└─ gc/
├─ manifest.boundary
├─ compactions.boundary
└─ ...
```
The directory names are mostly self-explanatory. Let’s look at each file type:
## Manifest
[Section titled “Manifest”](#manifest)
The manifest directory contains an ordered list of manifest files in the format `.manifest`. `` is a zero-padded, 20 digit unsigned integer. Each manifest file is a complete snapshot of the database state at the time it was written. A manifest file can be updated by the following processes:
* **Writer**: When a new WAL SSTable is created, the manifest is updated to include the new SSTable.
* **Reader**: When a new checkpoint is created, deleted, or refreshed, the manifest is updated to include the new checkpoint.
* **Compactor**: When a new sorted run is created, the manifest is updated to include the new sorted run.
Each manifest is encoded as a [FlatBuffer](https://flatbuffers.dev). The schema is located in [schemas/manifest.fbs](https://github.com/slatedb/slatedb/blob/main/schemas/manifest.fbs).
See [RFC-0001](/rfcs/0001-manifest) for details on the manifest update protocol.
Note
Users often ask why SlateDB has a WAL. Since SlateDB batches WAL writes, its WAL looks a lot like level 0 in a standard LSM tree. We address this question in the [FAQ](/docs/get-started/faq#why-does-slatedb-have-a-write-ahead-log).
## Compactions
[Section titled “Compactions”](#compactions)
The `compactions` directory contains an ordered list of compaction-state snapshots in the format `.compactions`. `` is a zero-padded, 20 digit unsigned integer. The compactor creates the first file when it initializes, then writes newer versions as it persists submitted, running, and recently completed compactions.
See [RFC-0013](/rfcs/0013-compaction-state-persistence) for details on the compaction update protocol.
## SSTable
[Section titled “SSTable”](#sstable)
`.sst` files in the `wal` and `compacted` directory share the same file format. Files in the `wal` directory are named `.sst`. `` is a zero-padded, 20 digit unsigned integer. Files in the `compacted` directory are named `.sst`, where `` is a [ULID](https://github.com/ulid/spec).
The `compacted` directory contains both L0 (non-partitioned) SSTables and SRs (partitioned SSTables). As the compactor runs, it will drop compacted SSTables from the manifest. Such files will be left in the `compacted` directory until the [garbage collector](/docs/design/gc) runs.
## Garbage Collector Boundaries
[Section titled “Garbage Collector Boundaries”](#garbage-collector-boundaries)
The `gc` directory contains boundary files used for garbage collection coordination. Boundary files are named `*.boundary` (e.g., `manifest.boundary`, `compactions.boundary`). These files track the minimum sequence number of metadata objects (compactions and manifests) that are still younger than `min_age`, which enables safe deletion of expired metadata and compaction artifacts.
Each boundary file stores a single unsigned 64-bit integer representing an inclusive high-watermark. A boundary value `B` means that object IDs `<= B` are eligible for deletion. Before the garbage collector deletes old sequenced metadata files, it advances the namespace boundary. After a writer creates a sequenced metadata file, it checks the boundary before returning success. If the created ID is at or behind the boundary, the write is treated as failed.
Boundary files use conditional updates (ETag-based) to ensure monotonic advancement and prevent concurrent GC processes from interfering with each other. They provide a persistent marker that allows garbage collectors to safely delete objects below the boundary without risking deletion of still-referenced data.
Garbage collectors advance boundary files by default. This can be disabled through `GarbageCollectorOptions`; eligible metadata is still deleted, but the durable boundary is not advanced. Metadata writers always check any existing boundary. All garbage collectors for a database must use a compatible policy, and the metadata `min_age` settings must be long enough to outlive stale processes when boundary advancement is disabled.
# Garbage Collection
> Learn about SlateDB's garbage collection strategy
SlateDB’s garbage collector runs as a background task in the client process, periodically checking for obsolete files in the database storage.
The garbage collector has a configurable minimum age and interval for each file type (WAL SSTs, WAL fence SSTs, compacted SSTs, manifests, and compactions). Garbage collection for a file type can be disabled by setting its options to `None`. The collector runs every `interval` seconds and will delete files older than `min_age` that are not referenced by any active manifest or checkpoint.
Each garbage collection directory type supports a `dry_run` option. When enabled, the collector logs files that would be deleted without actually deleting them. This is useful for testing or verifying garbage collection behavior before enabling actual deletion.
## Boundary files
[Section titled “Boundary files”](#boundary-files)
Before deleting old manifest or compactions metadata, SlateDB normally advances a durable boundary file. Metadata writers check that boundary after creating a new version, which prevents a stale writer from successfully publishing metadata that GC has already passed.
Boundary advancement can be disabled with `GarbageCollectorOptions::boundary_files_enabled`. This supports object stores without conditional overwrite (`If-Match`) while allowing GC to continue deleting eligible metadata. Metadata readers and writers still check any existing boundary; on a new database, no boundary file is created while all garbage collectors use this mode. Without a boundary, a SlateDB client or compactor can begin updating a manifest or compactions file, stop making progress (for example, because its process or host is suspended), then resume after `min_age`. GC may have deleted the ID it intended to create, so create-if-absent can reuse that ID and report a stale update as successful even though newer metadata has superseded it. Every garbage collector must use a compatible setting, and the manifest and compactions `min_age` values must exceed the maximum lifetime of a stale process.
## Filtering Deletion Candidates
[Section titled “Filtering Deletion Candidates”](#filtering-deletion-candidates)
SlateDB supports custom filtering of garbage collection candidates through the `GcFilter` trait. This allows users to intercept files before deletion and approve or reject them based on custom logic.
The `GcFilter::filter` method receives a set of files that SlateDB has already determined are eligible for deletion (files older than `min_age` that are not referenced by any active manifest or checkpoint). The filter returns the subset of files that may be physically deleted. Any files not returned by the filter are retained.
A common use case for `GcFilter` is verifying that files have been replicated to another bucket before allowing deletion. For example, in a mirror-maker-like scenario for cross-region or cross-cloud replication, the filter can check that a file exists in the destination bucket before approving it for deletion from the source.
To configure a filter, use the `with_gc_filter()` method on `GarbageCollectorBuilder`:
```rust
let gc_filter = Arc::new(MyGcFilter::new());
let gc = GarbageCollectorBuilder::new(path, object_store)
.with_gc_filter(gc_filter)
.build()
.await?;
```
The filter is optional. If no filter is configured, garbage collection proceeds normally based on age and reference checks alone.
Below is a diagram illustrating the high-level flow of garbage collection in SlateDB:
```
flowchart TD
A["Start GC Cycle (interval timer)"] --> B[Remove Expired Checkpoints from Manifests]
B --> C[Run WAL SST GC Task]
C --> C1[List WAL SSTs older than last compacted ID]
C1 --> C2[Filter by min_age and active references]
C2 --> C2a[Apply GcFilter if configured]
C2a --> C3[Delete eligible WAL SSTs]
B --> D[Run Compacted SST GC Task]
D --> D1[List all compacted and L0 SSTs]
D1 --> D2[Gather active SST IDs from manifests]
D2 --> D2a[Apply GcFilter if configured]
D2a --> D3[Delete SSTs not referenced and older than min_age]
B --> E[Run Manifest GC Task]
E --> E1["List all manifests (exclude latest)"]
E1 --> E2[Gather active manifest IDs from checkpoints]
E2 --> E2a[Apply GcFilter if configured]
E2a --> E3[Delete manifests not referenced and older than min_age]
C3 & D3 & E3 --> G[Wait for Next Interval or Shutdown]
```
## Default Garbage Collection Behavior
[Section titled “Default Garbage Collection Behavior”](#default-garbage-collection-behavior)
By default, garbage collection is enabled for all managed directories (manifest, WAL, WAL fence, compacted SSTs, and compactions) using standard interval and minimum age settings.
WAL fence garbage collection runs in dry-run mode by default. This means it logs files that would be deleted without actually deleting them. This conservative default prevents accidental data loss while still providing visibility into what would be cleaned up.
To enable actual deletion for WAL fence GC, set `dry_run: false` with a high `min_age` to safely clean up old fences. Alternatively, to silence the dry-run logging entirely, set `wal_fence_options: None`.
# Merge Operators
> How SlateDB stores merge operands and resolves them on reads and rewrites
[`MergeOperator`](https://docs.rs/slatedb/latest/slatedb/trait.MergeOperator.html) lets writers record partial updates with [`Db::merge`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html#method.merge), [`Db::merge_with_options`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html#method.merge_with_options), and [`WriteBatch::merge`](https://docs.rs/slatedb/latest/slatedb/struct.WriteBatch.html#method.merge). Those APIs append merge operands to the row history instead of forcing every writer to read the current value first. SlateDB resolves that history later with the operator you install.
## Example
[Section titled “Example”](#example)
This example uses a merge operator that concatenates string fragments.
```rust
use bytes::Bytes;
use slatedb::{Db, Error, MergeOperator, MergeOperatorError};
use slatedb::object_store::{memory::InMemory, ObjectStore};
use std::sync::Arc;
struct StringConcatMergeOperator;
impl MergeOperator for StringConcatMergeOperator {
fn merge(
&self,
_key: &Bytes,
existing_value: Option,
operand: Bytes,
) -> Result {
let mut result = existing_value.unwrap_or_default().as_ref().to_vec();
result.extend_from_slice(&operand);
Ok(Bytes::from(result))
}
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let object_store: Arc = Arc::new(InMemory::new());
let db = Db::builder("example", object_store)
.with_merge_operator(Arc::new(StringConcatMergeOperator))
.build()
.await?;
db.merge(b"greeting", b"hello, ").await?;
db.merge(b"greeting", b"world").await?;
let value = db.get(b"greeting").await?.unwrap();
assert_eq!(value.as_ref(), b"hello, world");
Ok(())
}
```
## Resolution
[Section titled “Resolution”](#resolution)
For one key, SlateDB reads the newest visible row first and walks backward until it reaches a plain value, a tombstone, or the end of history. It then applies the newer operands from oldest to newest on top of that base.
* `Value -> Merge -> Merge` reads as the base value plus both operands.
* `Tombstone -> Merge -> Merge` ignores everything older than the delete and applies only the newer operands.
* `Merge -> Merge` returns the merged operand bytes even though no base value exists yet.
When SlateDB flushes or compacts data, that last case may remain a single `Merge` row instead of turning into a plain value, because SlateDB still has merge operands but no base value for the key. If older SST levels contain an older value or tombstone for that key, a later read or compaction can still combine that older state with the newer merged operand.
## Contract
[Section titled “Contract”](#contract)
The `MergeOperator` trait has two methods:
* [`merge`](https://docs.rs/slatedb/latest/slatedb/trait.MergeOperator.html#tymethod.merge) combines one operand with an optional accumulated value.
* [`merge_batch`](https://docs.rs/slatedb/latest/slatedb/trait.MergeOperator.html#method.merge_batch) combines an optional accumulated value with a slice of operands ordered from oldest to newest.
The default `merge_batch` implementation calls `merge` pairwise. Override it if you can process a batch more efficiently.
The operator must be associative. SlateDB may regroup operands during reads, memtable flush, and compaction, so the result has to stay stable when those boundaries move. SlateDB installs one merge operator per database, but your implementation can dispatch by key prefix or value format if you need different merge rules.
## Where Merges Happen
[Section titled “Where Merges Happen”](#where-merges-happen)
### Reads
[Section titled “Reads”](#reads)
Normal reads wrap the iterator stack with [`MergeOperatorIterator`](https://github.com/slatedb/slatedb/blob/main/slatedb/src/merge_operator.rs). Point lookups and scans can merge operands across the write batch, memtable, immutable memtables, L0 SSTs, and sorted runs.
The read path merges operands even when their `expire_ts` values differ. The returned row uses the minimum `expire_ts` across the merged operands and any base value. [Time](/docs/design/time) covers the TTL rules in more detail.
If no merge operator is configured and a read encounters a `Merge` row, SlateDB returns `MergeOperatorMissing`.
### Rewrites
[Section titled “Rewrites”](#rewrites)
Before a [`WriteBatch`](https://docs.rs/slatedb/latest/slatedb/struct.WriteBatch.html) is committed, SlateDB reduces consecutive merge operations for the same key when the writer has a merge operator configured. If the batch also contains a value or tombstone base for that key, the reduced row becomes a plain value. If it has only operands, the reduced row stays a `Merge`.
Caution
`Db::write(...)` and transaction commits reject batch-local merges for the same key when the rows being reduced have different effective `expire_ts` values. This includes multiple `merge_with_options(...)` calls with different TTLs, a `put_with_options(...)` followed by a merge with a different TTL, or a `delete(...)` followed by a merge that expires. Use one effective TTL for each key in a batch, or split the operations across separate batchces or commits.
Memtable flush and compaction use the same merge logic before they write new SSTs. Those rewrite paths are stricter than reads. They keep rows with different `expire_ts` values in separate groups so they can expire independently, and they stop at snapshot and durability retention boundaries so active snapshots or remote readers can still see the version chain they need.
### Raw APIs
[Section titled “Raw APIs”](#raw-apis)
[`WalReader`](https://docs.rs/slatedb/latest/slatedb/struct.WalReader.html) and WAL-based [Change Data Capture](/docs/design/change-data-capture) do not apply the merge operator. They expose raw `ValueDeletable::Merge` rows. Downstream consumers need to materialize those operands themselves if they need final values.
## Configuration
[Section titled “Configuration”](#configuration)
Every process that reads or rewrites stored merge rows needs compatible merge logic.
* Writers use [`DbBuilder::with_merge_operator`](https://docs.rs/slatedb/latest/slatedb/struct.DbBuilder.html#method.with_merge_operator).
* Standalone compactors use [`CompactorBuilder::with_merge_operator`](https://docs.rs/slatedb/latest/slatedb/struct.CompactorBuilder.html#method.with_merge_operator).
* Read-only handles use [`DbReaderBuilder::with_merge_operator`](https://docs.rs/slatedb/latest/slatedb/struct.DbReaderBuilder.html#method.with_merge_operator).
If one process writes merge operands and another opens the same database without a compatible operator, the raw rows can still exist in storage, but later reads, memtable flushes, or compaction can fail when they reach them.
# Overview
> A high-level overview of LSM trees and SlateDB’s design
SlateDB is a log-structured merge-tree (LSM tree). We must first understand how LSM trees work to understand SlateDB’s design. In this page, we’ll explore LSMs and see how SlateDB uses them.
## What is an LSM tree?
[Section titled “What is an LSM tree?”](#what-is-an-lsm-tree)
LSM trees are an in-memory and on-disk data structure used to store key-value (KV) data. KV storage engines such as [RocksDB](https://github.com/facebook/rocksdb) and [LevelDB](https://github.com/google/leveldb) are based on LSM trees. They are a type of key-value (KV) storage engine. LSMs provide operations common to most KV storage engines:
* `put(key, value)`: Insert a key-value pair.
* `get(key)`: Retrieve a key-value pair.
* `delete(key)`: Delete a key-value pair.
* `scan(range)`: Scan a range of keys.
* `flush()`: Flush the in-memory data to disk.
## LSM tree design
[Section titled “LSM tree design”](#lsm-tree-design)
LSM trees are made up of a *write-ahead log (WAL)*, a mutable in-memory map called a *MemTable*, *sorted strings tables (SSTables)*, and a *manifest*. We will discuss each of these components in detail.
### Write-ahead log (WAL)
[Section titled “Write-ahead log (WAL)”](#write-ahead-log-wal)
A put() call writes its data to a write-ahead log (WAL). A WAL is an append-only persistent log. WALs are used to recover data in the event of a crash. When the database restarts, it can replay the WAL to recover its state. WALs are not great for serving reads, though; they’re not optimized for random access. Every write is simply an appended to the end of the log. A get() operation must scan the WAL from the last write to the first write until it finds the key-value pair (or until it reaches the beginning of the WAL).
### MemTables
[Section titled “MemTables”](#memtables)
LSM trees avoid this linear scan by storing their data in an additional data structure called a MemTable. After a put() call writes its data to the WAL, it inserts its data into a mutable in-memory map called a MemTable. MemTables typically use a sorted map such as a [SkipList](https://en.wikipedia.org/wiki/Skip_list) map so that `scan()` operations work as well.
```
sequenceDiagram
participant C as Client
participant WAL as Write-Ahead Log (WAL)
participant MT as MemTable (mutable)
C->>WAL: (1) put()/delete()
WAL-->>C: append OK
C->>MT: (2) put()/delete()
MT-->>C: update OK
```
When a get() occurs, the database will search the MemTable for the key-value pair.
```
sequenceDiagram
participant C as Client
participant MT as MemTable (mutable)
C->>MT: get()
MT-->>C: key-value pair
```
### SSTables
[Section titled “SSTables”](#sstables)
But an in-memory MemTable will only allow us to have a database that fits in memory. We must persist our data on disk. We’ve already seen that the WAL is an inappropriate format for serving reads. LSM trees introduce a third data structure here: a sorted strings table (SSTable). SSTables are files that contain a sequence of key-value pairs sorted by key:
```plaintext
key1:value1
key2:value2
key3:value3
...
```
We’ve established that MemTables are sorted by key, so LSM trees need only write their MemTable to disk in the same order.
```
sequenceDiagram
box Memory
participant C as Client
participant MT as MemTable (mutable)
end
box Disk
participant SST as SSTable
end
C->>MT: put()
MT-->>C: update OK
MT->>SST: write
SST-->>C: write OK
```
There’s a problem with this design, though. The MemTable must be locked while we write to disk. `put()` calls will block until the write is complete. This can cause performance issues if the MemTable is large. To get around this, LSM trees have both a mutable MemTable and one or more immutable MemTables that are being written to disk. When the mutable MemTable reaches a certain size, it is *frozen* and written to disk in the background. A new mutable MemTable is then created (or pulled from a pool) to receive new writes while the froze MemTable is written in the background.
```
sequenceDiagram
box Memory
participant C as Client
participant MT as MemTable (mutable)
participant IMT as MemTable (immutable)
end
box Disk
participant SST as SSTable
end
C->>MT: put()
MT-->>C: update OK
MT->>IMT: freeze
IMT->>SST: write
```
When a get() occurs, the database will search all of the MemTables and SSTables for the key-value pair.
```
sequenceDiagram
box Memory
participant C as Client
participant MT as MemTable (mutable)
participant IMT as MemTable (immutable)
end
box Disk
participant SST as SSTable
end
C->>SST: get()
MT-->>C: key-value pair (if found)
IMT-->>C: key-value pair (if found)
SST-->>C: key-value pair (if found)
```
Imagine we have 1000s (or millions) of immutable MemTables and SSTables. A read would need to search them sequentially to find the most recent version of a key-value pair (a user might have updated a key multiple times). This is clearly not efficient. LSM trees solve this problem with *compaction*. Compaction is a process where the database merges multiple SSTables into a new sequence of range partitioned SSTables. Consider the following SSTables:
```plaintext
SSTable 1:
key1:value1a
key56:value56a
key99:value99
SSTable 2:
key43:value43
key56:value56b
key89:value89
SSTable 3:
key1:value1b
key44:value44
key77:value77
```
Each SST in the example above is unpartitioned—it can potentially contain any key-value pair. Compaction will merge these SSTables into a new sequence of range partitioned SSTables. For example, the SSTables above might be compacted into the following SSTables:
```plaintext
SSTable 1:
key1:value1b
key43:value43
key44:value44
key56:value56b
key77:value77
SSTable 2:
key89:value89
key99:value99
```
Now a read for `key56` will only need to search the first SSTable. Readers need only check the key range of each SSTable (stored in memory) to determine if it might contain the key. The unpartitioned tables, are called *level 0 (L0)* SSTables. The partitioned tables are called *sorted runs (SRs)*. L0 SSTables are part of the write path, as we saw above. Sorted runs are generated through a background compaction process.
Note
Sorted runs are a logical construct. The files written to disk are the sorted run’s SSTables. The manifest (which we’ll see in a moment) contains the mapping from a sorted run ID to its SSTables.
A `get()` will traverse the MemTables, L0 SSTables, and SRs to find the key-value pair.
```
sequenceDiagram
box Memory
participant C as Client
participant MT as MemTable (mutable)
participant IMT as MemTable (immutable)
end
box Disk
participant L0 as L0 SSTable
participant SR as SR (sorted run)
end
C->>SR: get()
MT-->>C: key-value pair (if found)
IMT-->>C: key-value pair (if found)
L0-->>C: key-value pair (if found)
SR-->>C: key-value pair (if found)
```
### Manifests
[Section titled “Manifests”](#manifests)
Another issue remains: upon restart, the database has no easy way to know what SSTables it should load. We might try to list all SSTs and read each file’s metadata, but this doesn’t work. RocksDB’s [MANIFEST page](https://github.com/facebook/rocksdb/wiki/MANIFEST) explains why:
> File system operations are not atomic, and are susceptible to inconsistencies in the event of system failure. Even with journaling turned on, file systems do not guarantee consistency on unclean restart. POSIX file system does not support atomic batching of operations either. Hence, it is not possible to rely on metadata embedded in RocksDB datastore files to reconstruct the last consistent state of the RocksDB on restart.
LSM tree implementations store their state in a manifest. The manifest is a file (or log of files) that stores the database’s state; this includes the location of all SSTables, the WAL recovery starting point, and many other details. The LSM tree reads the manifest on startup to reconstruct its in-memory state. As state changes, the LSM tree writes manifest changes atomically to ensure consistency.
We’ve now built a basic LSM tree. There are many, many details to be explored (deletion tombstones, consistency, WAL replay, and more). Still, this basic implementation will work. If you’re interested in experimenting more with LSMs, we highly recommend checking out [Mini-LSM](https://skyzh.github.io/mini-lsm/).
## Tradeoffs
[Section titled “Tradeoffs”](#tradeoffs)
LSM trees are elegant, simple data structures. However, they are not without tradeoffs. LSM trees work very well for high write-throughput workloads. It does so by moving as much work as possible out of the write path. Consequently, the design requires more background work (CPU), more files (storage space), and more variable read/latency (I/O) than other data structures. These tradeoffs are commonly framed in the following terms:
* Write amplification: The number of extra bytes written to storage per byte of live user data ingested.
* Read amplification: The number of extra bytes read from storage per byte of result data returned.
* Space amplification: The total on-disk byte size relative to the total live logical byte size.
LSM trees tend to have higher write throughput but worse read locality, more read and space amplification, more background compaction CPU, and higher tail latencies than B-trees. This subject is an active area of research and there are now many strategies to balance these tradeoffs.
## SlateDB’s LSM design
[Section titled “SlateDB’s LSM design”](#slatedbs-lsm-design)
SlateDB’s LSM design diverges very little from the basic LSM tree design we’ve seen above. SlateDB has a WAL, MemTables, SSTables, sorted runs, and a manifest. The only difference is that all of SlateDB’s data is written to object storage instead of a local disk or filesystem:
```
sequenceDiagram
box Memory
participant C as Client
participant MT as MemTable (mutable)
participant IMT as MemTable (immutable)
end
box Object Storage
participant SST as SSTable
participant SR as Sorted Run
end
C->>MT: put()
MT-->>C: update OK
MT-->>IMT: freeze
IMT-->>SST: write
SST-->>SR: compact
```
In the next section, we’ll look at SlateDB’s object store directory structure.
# Readers
> How DbReader serves read-only traffic from a checkpointed manifest
[`DbReader`](https://docs.rs/slatedb/latest/slatedb/struct.DbReader.html) is SlateDB’s read-only handle. It implements the same [`DbReadOps`](https://docs.rs/slatedb/latest/slatedb/trait.DbReadOps.html) operations as [`Db`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html), but it does not own a mutable memtable and it does not write WAL records. It reads from a [checkpointed](/docs/design/checkpoints) manifest, the SSTs referenced by that manifest. When WAL replay is enabled, it also updates reader-local immutable memtables from newer WAL SSTs.
## Opening
[Section titled “Opening”](#opening)
This example opens a database, flushes one key to object storage, then opens a `DbReader` and reads that key back.
```rust
use slatedb::{Db, DbReader, Error};
use slatedb::object_store::{memory::InMemory, ObjectStore};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Error> {
let object_store: Arc = Arc::new(InMemory::new());
let db = Db::open("example", Arc::clone(&object_store)).await?;
db.put(b"key", b"value").await?;
db.flush().await?;
let reader = DbReader::builder("example", object_store).build().await?;
assert_eq!(reader.get(b"key").await?, Some("value".into()));
Ok(())
}
```
Without [`DbReaderBuilder::with_checkpoint_id`](https://docs.rs/slatedb/latest/slatedb/struct.DbReaderBuilder.html#method.with_checkpoint_id), SlateDB creates a checkpoint in the latest manifest and sets its lifetime from [`DbReaderOptions::checkpoint_lifetime`](https://docs.rs/slatedb/latest/slatedb/config/struct.DbReaderOptions.html#structfield.checkpoint_lifetime). That checkpoint pins the initial L0 and sorted-run view. The reader then replays newer WAL SSTs into immutable memtables unless [`DbReaderOptions::skip_wal_replay`](https://docs.rs/slatedb/latest/slatedb/config/struct.DbReaderOptions.html#structfield.skip_wal_replay) is `true`.
With an explicit checkpoint ID, SlateDB opens exactly the manifest pinned by that checkpoint. It does not create a new checkpoint, refresh the user-managed checkpoint, delete it on close, or follow newer manifests or WAL SSTs.
When `skip_wal_replay` is `true`, the reader only sees data already referenced by the manifest it opened. If the reader owns the checkpoint, it can still pick up later memtable flushes and compaction results when the manifest changes, but it ignores WAL-only data between those manifest updates.
## State
[Section titled “State”](#state)
Internally, the reader tracks the pinned checkpoint and manifest, a deque of immutable memtables built from WAL replay, the last WAL file it replayed, and the highest durable sequence number it can expose. Reads go through the same internal [`Reader`](https://github.com/slatedb/slatedb/blob/main/slatedb/src/reader.rs) implementation that backs ordinary [`Db`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html) reads.
A `DbReader` has no mutable memtable of its own. Its precedence is replayed immutable memtables, then L0 SSTs, then sorted runs. When SlateDB replaces the reader’s checkpoint with a newer manifest, it drops WAL-replayed rows whose sequence numbers are now at or below the new manifest’s `last_l0_seq`, because L0 or lower levels already cover them.
## Refresh Loop
[Section titled “Refresh Loop”](#refresh-loop)
When the reader owns the checkpoint, it starts a background poller that wakes up every [`DbReaderOptions::manifest_poll_interval`](https://docs.rs/slatedb/latest/slatedb/config/struct.DbReaderOptions.html#structfield.manifest_poll_interval).
On each tick, SlateDB loads the latest manifest. If the visible SST layout changed, or if the latest L0 sequence number moved past the WAL state already replayed into the reader, it replaces the checkpoint and rebuilds state from the new manifest. Otherwise it keeps the current checkpoint and only looks for newer WAL SSTs.
SlateDB refreshes the checkpoint expiration after half of the configured lifetime has elapsed. [`DbReader::close`](https://docs.rs/slatedb/latest/slatedb/struct.DbReader.html#method.close) stops the poller and deletes the checkpoint the reader created for itself.
## Read Semantics
[Section titled “Read Semantics”](#read-semantics)
`get`, `get_key_value`, `scan`, and `scan_prefix` use the normal SST iterator stack. Options such as [`ReadOptions::cache_blocks`](https://docs.rs/slatedb/latest/slatedb/config/struct.ReadOptions.html#structfield.cache_blocks), [`ScanOptions::cache_blocks`](https://docs.rs/slatedb/latest/slatedb/config/struct.ScanOptions.html#structfield.cache_blocks), [`ScanOptions::read_ahead_bytes`](https://docs.rs/slatedb/latest/slatedb/config/struct.ScanOptions.html#structfield.read_ahead_bytes), and [`ScanOptions::max_fetch_tasks`](https://docs.rs/slatedb/latest/slatedb/config/struct.ScanOptions.html#structfield.max_fetch_tasks) still control caching and scan behavior.
Visibility is narrower than [`Db`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html). A reader does not share a writer’s live mutable memtable, so read options cannot expose unflushed writer state. `DbReader` only returns committed data that is already durable in object storage, either through the pinned manifest or through WAL SSTs it replayed itself.
Reader-side block and object-store caches use the same configuration surface as [`Db`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html). [Caching](/docs/design/caching) covers those layers.
# Reads
1. A get(), scan(), or similar read call is made on the client.
2. The read request is routed to the database engine, which determines the current read snapshot (either the latest state or a pinned snapshot if using the snapshot API).
3. The engine first checks the in-memory MemTable for the requested key or range. If the key is found and not expired, the value is returned immediately.
4. If the key is not found in the MemTable, the engine searches the immutable MemTable (if present, e.g., during a flush).
5. If the key is still not found, the engine concurrently creates iterators over all L0 SSTables (uncompacted, recently flushed files) and all compacted sorted runs (lower levels). These iterators are constructed in parallel to maximize throughput and minimize latency, with the degree of concurrency determined by the number of files and system configuration [\[source\]](https://github.com/slatedb/slatedb/pull/815).
6. Each SSTable is searched using its block index. A binary search locates the block that may contain the key. If multiple blocks start with the same key, the engine ensures it returns the first such block to avoid stale data and to support multiple versions of a key [\[source\]](https://github.com/slatedb/slatedb/issues/517).
7. As iterators are created, SlateDB asynchronously fetches and caches file blocks from object storage.
8. The engine merges the MemTable, immutable MemTable, L0 SSTable iterators, and sorted run iterators into a single merged iterator. The iterator respects sequence numbers and snapshot visibility, ensuring that only the correct version of each key is visible for the current read or snapshot.
9. The merged iterator returns the value for the requested key (or the next key in a scan). If the key is not found in any structure, None is returned.
Below is a diagram illustrating the high-level flow of a read in SlateDB:
```
flowchart TD
A[API Call: get/scan] --> B[Determine Snapshot Sequence Number]
B --> C[Check MemTable]
C --> D{Found?}
D -- Yes --> Z[Return Value]
D -- No --> E[Check Immutable MemTable]
E --> F{Found?}
F -- Yes --> Z
F -- No --> G["Create merge iterator (concurrent)"]
G --> J["Seek to Key"]
J --> K{Found?}
K -- Yes --> Z
K -- No --> L[Return None]
```
**Notes:**
* Iterator creation for L0 SSTables and compacted runs is parallelized for performance.
* Block cache is populated asynchronously during reads.
* Reads from snapshots always reflect the state at the time the snapshot was taken, regardless of later modifications.
* WAL SSTs are not read during normal operations; they are only accessed during recovery [\[source\]](https://github.com/slatedb/slatedb/issues/499).
# 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`](https://docs.rs/slatedb/latest/slatedb/trait.PrefixExtractor.html) 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](/rfcs/0024-segment-oriented-compaction) for the full design.
## Enabling segments
[Section titled “Enabling segments”](#enabling-segments)
Configure an extractor at creation with [`DbBuilder::with_segment_extractor`](https://docs.rs/slatedb/latest/slatedb/struct.DbBuilder.html#method.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.
* Rust
```rust
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 {
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?;
```
* Go
```go
type fixedThreeByteExtractor struct{}
func (fixedThreeByteExtractor) Name() string { return "fixed_three_byte" }
func (fixedThreeByteExtractor) PrefixLen(target slatedb.PrefixTarget) *uint64 {
var key []byte
switch t := target.(type) {
case slatedb.PrefixTargetPoint:
key = t.Key
case slatedb.PrefixTargetPrefix:
key = t.Prefix
}
if len(key) < 3 {
return nil
}
n := uint64(3)
return &n
}
builder := slatedb.NewDbBuilder("example-db", store)
if err := builder.WithSegmentExtractor(fixedThreeByteExtractor{}); err != nil {
panic(err)
}
db, err := builder.Build()
```
* Java
```java
import io.slatedb.uniffi.PrefixExtractor;
import io.slatedb.uniffi.PrefixTarget;
class FixedThreeByteExtractor implements PrefixExtractor {
@Override
public String name() {
return "fixed_three_byte";
}
@Override
public Long prefixLen(PrefixTarget target) {
byte[] key =
switch (target) {
case PrefixTarget.Point point -> point.key();
case PrefixTarget.Prefix prefix -> prefix.prefix();
};
return key.length < 3 ? null : 3L;
}
}
DbBuilder builder = new DbBuilder("demo-db", store);
builder.withSegmentExtractor(new FixedThreeByteExtractor());
Db db = builder.build().get();
```
* Node.js
```js
import { DbBuilder } from "@slatedb/uniffi";
class FixedThreeByteExtractor {
name() {
return "fixed_three_byte";
}
prefix_len(target) {
const key = target.tag === "Point" ? target.key : target.prefix;
return key.length < 3 ? undefined : 3;
}
}
const builder = new DbBuilder("demo-db", store);
builder.with_segment_extractor(new FixedThreeByteExtractor());
const db = await builder.build();
```
* Python
```python
from slatedb.uniffi import DbBuilder, PrefixExtractor, PrefixTarget
class FixedThreeByteExtractor(PrefixExtractor):
def name(self) -> str:
return "fixed_three_byte"
def prefix_len(self, target: PrefixTarget) -> int | None:
key = target.key if target.is_point() else target.prefix
return None if len(key) < 3 else 3
builder = DbBuilder("demo-db", store)
builder.with_segment_extractor(FixedThreeByteExtractor())
db = await builder.build()
```
Caution
Whether segmentation is enabled is fixed for the life of the database. The extractor’s `name()` is persisted in the manifest; opening with a different name, or adding or removing an extractor, fails. This is primarily as a soft check against accidental misuse.
List the segments that currently exist (in the manifest or in memtables) with [`DbStatus::list_segments()`](https://docs.rs/slatedb/latest/slatedb/struct.DbStatus.html#method.list_segments).
## Evolving the key schema
[Section titled “Evolving the key schema”](#evolving-the-key-schema)
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:
```text
# 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.
## How it compacts
[Section titled “How it compacts”](#how-it-compacts)
Each segment is compacted on its own schedule. The default size-tiered [`CompactionScheduler`](/docs/design/compaction) 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.
Note
Writing to N segments at once produces up to N× the L0 objects (smaller each); the WAL is shared, so it is unaffected. Most data models keep one active segment, so this rarely bites in practice.
## Deleting Segments Wholesale
[Section titled “Deleting Segments Wholesale”](#deleting-segments-wholesale)
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`](https://docs.rs/slatedb/latest/slatedb/admin/struct.Admin.html), built from the segment’s L0 SSTs and sorted runs read from `read_compactor_state_view`. Every binding mirrors this API:
* Rust
```rust
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 = 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?;
}
```
* Go
```go
admin, err := slatedb.NewAdminBuilder("example-db", store).Build()
if err != nil {
panic(err)
}
view, err := admin.ReadCompactorStateView()
if err != nil {
panic(err)
}
var sources []slatedb.SourceId
for _, seg := range view.Manifest.Segments {
if !bytes.Equal(seg.Prefix, []byte("hour=10/")) {
continue
}
for _, v := range seg.L0 {
sources = append(sources, slatedb.SourceIdSstView{Field0: v.Id})
}
for _, r := range seg.Compacted {
sources = append(sources, slatedb.SourceIdSortedRun{Field0: r.Id})
}
}
_, err = admin.SubmitCompaction(slatedb.CompactionSpecDrainSegment{
Segment: []byte("hour=10/"),
Sources: sources,
})
```
* Java
```java
import io.slatedb.uniffi.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
Admin admin = new AdminBuilder("demo-db", store).build();
var view = admin.readCompactorStateView().get();
byte[] prefix = "hour=10/".getBytes();
List sources = new ArrayList<>();
for (Segment seg : view.manifest().segments()) {
if (!Arrays.equals(seg.prefix(), prefix)) continue;
seg.l0().forEach(v -> sources.add(new SourceId.SstView(v.id())));
seg.compacted().forEach(r -> sources.add(new SourceId.SortedRun(r.id())));
}
admin.submitCompaction(new CompactionSpec.DrainSegment(prefix, sources)).get();
```
* Node.js
```js
import { AdminBuilder, CompactionSpec, SourceId } from "@slatedb/uniffi";
const admin = new AdminBuilder("demo-db", store).build();
const view = await admin.read_compactor_state_view();
const prefix = Buffer.from("hour=10/");
const seg = view.manifest.segments.find((s) => prefix.equals(Buffer.from(s.prefix)));
const sources = [
...seg.l0.map((v) => SourceId.SstView(v.id)),
...seg.compacted.map((r) => SourceId.SortedRun(r.id)),
];
await admin.submit_compaction(CompactionSpec.DrainSegment(seg.prefix, sources));
```
* Python
```python
from slatedb.uniffi import AdminBuilder, CompactionSpec, SourceId
admin = AdminBuilder("demo-db", store).build()
view = await admin.read_compactor_state_view()
seg = next(s for s in view.manifest.segments if s.prefix == b"hour=10/")
sources = [SourceId.SST_VIEW(v.id) for v in seg.l0]
sources += [SourceId.SORTED_RUN(r.id) for r in seg.compacted]
await admin.submit_compaction(CompactionSpec.DRAIN_SEGMENT(segment=seg.prefix, sources=sources))
```
To retire segments automatically (e.g. by a TTL), implement [`CompactionScheduler`](https://docs.rs/slatedb/latest/slatedb/compactor/trait.CompactionScheduler.html) and return `drain_segment` specs from `propose`, wired in with `CompactorBuilder::with_scheduler_supplier`.
# Sorted String Tables (SSTs)
> How SlateDB stores WAL and compacted data in SST files
SlateDB stores durable rows in SST files under `wal/` and `compacted/`. Both use the same block and footer format, but they serve different jobs. WAL SSTs record writes in sequence number order for recovery and CDC. Compacted SSTs store L0 and sorted-run data in key order and serve normal reads. The same rows can appear in a different order depending on the SST type.
WAL SST (sequence order)
```text
seq=41 key=banana value=yellow
seq=42 key=apple value=green
seq=43 key=carrot value=orange
```
Compacted SST (key order):
```text
key=apple seq=42 value=green
key=banana seq=41 value=yellow
key=carrot seq=43 value=orange
```
## Layout
[Section titled “Layout”](#layout)
An SST stores data blocks first. A compacted SST may then append a Bloom filter block. The Bloom filter block stores a probabilistic membership filter that lets point reads skip the SST on a definite miss. Next comes the index block. The index block stores one entry per data block with the block offset and a search key or sequence bound so readers can jump to candidate blocks. A compacted SST may also append a stats block. The stats block stores file-level and per-block counters and byte totals for puts, deletes, merge operands, keys, and values. Every SST ends with `SsTableInfo`, followed by the 8-byte metadata offset and the 2-byte format version.
```
%%{init: {"packet": {"bitsPerRow": 14, "bitWidth": 50, "showBits": false}}}%%
packet-beta
title SST file layout
+14: "data blocks..."
+14: "filter block (optional)"
+14: "index block"
+14: "stats block (optional)"
+14: "SsTableInfo"
+14: "metadata offset: u64 LE"
+14: "format version: u16 LE"
```
Each stored block ends with a CRC32 checksum. That applies to data blocks and to footer blocks such as the filter, index, stats, and `SsTableInfo` metadata.
## Data Blocks
[Section titled “Data Blocks”](#data-blocks)
SlateDB groups rows into blocks up to the target size configured with [`DbBuilder::with_sst_block_size`](https://docs.rs/slatedb/latest/slatedb/db/struct.DbBuilder.html#method.with_sst_block_size). When the next row would overflow the current block, the writer closes that block and starts a new one. Compacted SSTs write rows in key order. WAL SSTs write rows in sequence number order.
Within a data block, each stored row is a `RowEntry`: the logical fields are the key bytes, a `ValueDeletable` payload (`Value`, `Merge`, or `Tombstone`), the sequence number, and the optional `expire_ts` and `create_ts` timestamps.
In the current SST row codec (`SstRowCodecV2`), keys are prefix-compressed against the previous key in the block. `shared_bytes` records how many leading bytes match the previous key, `unshared_bytes` records the remaining suffix length, and `key_suffix` stores that suffix. At restart points, which current writers insert every 16 entries, `shared_bytes` is `0`, so the entry stores the full key and readers can seek into the block without decoding from the beginning.
The rest of the row stores the value length and payload, followed by the sequence number, a flags byte, and any optional timestamps. Tombstones set the tombstone flag, encode `value_len = 0`, and omit the value bytes. Merge operands use the same payload field as normal values but set the merge flag instead.
```
%%{init: {"packet": {"bitsPerRow": 14, "bitWidth": 50, "showBits": false}}}%%
packet-beta
title RowEntry encoding in a data block
+14: "shared_bytes: varint"
+14: "unshared_bytes: varint"
+14: "value_len: varint"
+14: "key_suffix: bytes"
+14: "value: bytes (omitted for tombstones)"
+14: "seq: u64"
+14: "flags: u8"
+14: "expire_ts: i64 (optional)"
+14: "create_ts: i64 (optional)"
```
## Index Blocks
[Section titled “Index Blocks”](#index-blocks)
The index stores one entry per data block. Each entry says where that block starts in the file and what boundary SlateDB should use to decide whether to search that block.
In compacted SSTs, the boundary is a key: for the first block it is empty, and for later blocks it is the smallest key that should map to that block. That boundary is often a shortened prefix between the previous block’s last key and this block’s first key, so SlateDB does not need to store the full first key for every block.
In WAL SSTs, the index uses sequence numbers instead of keys and stores the first sequence number in each block.
Readers binary-search the index to find the first block that may contain a target key or the block range that overlaps a scan. In a sorted run, SlateDB first uses SST view boundaries and then the per-SST block index inside the selected file.
## Filter Blocks
[Section titled “Filter Blocks”](#filter-blocks)
Compacted SSTs can include a Bloom filter. SlateDB writes one when the file has at least [`Settings::min_filter_keys`](https://docs.rs/slatedb/latest/slatedb/config/struct.Settings.html#structfield.min_filter_keys) rows and sizes it with [`Settings::filter_bits_per_key`](https://docs.rs/slatedb/latest/slatedb/config/struct.Settings.html#structfield.filter_bits_per_key). Point lookups can skip the whole SST on a negative result. WAL SSTs do not include a filter because SlateDB reads them sequentially during replay.
## Stats Block
[Section titled “Stats Block”](#stats-block)
The current compacted SST builder also writes a stats block. It records counts of puts, deletes, and merge operands, plus raw key and value bytes and per-block stats. This file is useful for read optimization. See [RFC-0020](/rfcs/0020-range-metadata) for more information.
## Footer
[Section titled “Footer”](#footer)
`SsTableInfo` stores the high-level metadata needed to reopen and interpret the SST. That includes the file’s key or sequence bounds, where SlateDB can find auxiliary blocks such as the index, Bloom filter, and stats block, and format details such as compression and whether the file is a WAL SST or a compacted SST.
If you want the exact field-by-field layout, see the [`SsTableInfo` FlatBuffers schema](https://github.com/slatedb/slatedb/blob/main/schemas/sst.fbs).
# Time
> How SlateDB records wall-clock timestamps and expiration metadata
SlateDB separates ordering from time. Sequence numbers decide visibility. Wall-clock timestamps are metadata recorded in milliseconds since the Unix epoch and used for TTL, checkpoint lifetime, and background scheduling.
## Ordering
[Section titled “Ordering”](#ordering)
Every committed write batch gets a sequence number and a creation timestamp. The sequence number is the authoritative ordering key. The timestamp is descriptive metadata.
This matters when multiple writes land in the same millisecond. Two versions of a key can share the same timestamp and still be ordered correctly because SlateDB compares sequence numbers first.
SlateDB stamps the committed batch once. Every row produced by that batch inherits the same `create_ts`, while per-row TTL can still produce different `expire_ts` values. The batch timestamp is exposed through [`WriteHandle::create_ts()`](https://docs.rs/slatedb/latest/slatedb/db/struct.WriteHandle.html#method.create_ts).
## Clock
[Section titled “Clock”](#clock)
SlateDB gets wall-clock time from [`SystemClock`](https://docs.rs/slatedb-common/latest/slatedb_common/clock/trait.SystemClock.html). The default implementation uses the process wall clock. [`DbBuilder::with_system_clock()`](https://docs.rs/slatedb/latest/slatedb/db/struct.DbBuilder.html#method.with_system_clock) lets tests and specialized environments replace it.
Internally, SlateDB wraps that clock in a monotonic clock. If the underlying clock moves backwards, SlateDB waits briefly for it to catch up. If it is still behind, the operation fails instead of writing timestamps that move backwards.
When an immutable memtable is flushed to L0, SlateDB persists the newest flushed clock tick in the manifest as `last_l0_clock_tick`. On restart it seeds the monotonic clock from that value so new timestamps stay ahead of data that is already durable in L0 or lower levels.
The same clock is also used for checkpoint expiry and background tasks such as compaction and garbage collection.
## Row Metadata
[Section titled “Row Metadata”](#row-metadata)
Metadata-aware reads expose the stored timestamps. [`Db::get_key_value()`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html#method.get_key_value) and [`DbIterator::next()`](https://docs.rs/slatedb/latest/slatedb/struct.DbIterator.html#method.next) return [`KeyValue`](https://docs.rs/slatedb/latest/slatedb/struct.KeyValue.html), which includes `seq`, `create_ts`, and `expire_ts`.
[`Db::get()`](https://docs.rs/slatedb/latest/slatedb/struct.Db.html#method.get) returns only value bytes. If you need timestamp metadata, use `get_key_value()` or iterate `KeyValue` results from a scan.
SlateDB also preserves the same metadata in the WAL path, which is why [Change Data Capture](/docs/design/change-data-capture) can expose `create_ts` and `expire_ts` for downstream consumers.
## Expiration
[Section titled “Expiration”](#expiration)
TTL is stored as an absolute expiration timestamp in milliseconds since the Unix epoch, not as a relative duration. On commit, SlateDB computes `expire_ts = create_ts + ttl_millis`.
[`Settings::default_ttl_millis`](https://docs.rs/slatedb/latest/slatedb/config/struct.Settings.html#structfield.default_ttl_millis) sets a default TTL in milliseconds for puts and merges. [`PutOptions`](https://docs.rs/slatedb/latest/slatedb/config/struct.PutOptions.html) and [`MergeOptions`](https://docs.rs/slatedb/latest/slatedb/config/struct.MergeOptions.html) can override that per operation:
* [`Ttl::NoExpiry`](https://docs.rs/slatedb/latest/slatedb/config/enum.Ttl.html#variant.NoExpiry) — store the value without expiration
* [`Ttl::ExpireAfterMillis(u64)`](https://docs.rs/slatedb/latest/slatedb/config/enum.Ttl.html#variant.ExpireAfterMillis) — expire after a relative duration in milliseconds
* [`Ttl::ExpireAtMillis(i64)`](https://docs.rs/slatedb/latest/slatedb/config/enum.Ttl.html#variant.ExpireAtMillis) — expire at a fixed Unix timestamp in milliseconds
Deletes write tombstones and do not carry TTL.
Caution
Batch-local merges requires one effective `expire_ts` per key. `Db::write(...)` and transaction commit return `ErrorKind::Invalid` if a single batch tries to merge the same key across different expiration timestamps. Effective timestamps are computed after applying `Settings::default_ttl_millis`, so `Ttl::Default` can still differ from `Ttl::NoExpiry` or from another explicit TTL.
Note
When SlateDB materializes a value from merge operands, the resulting row reports the earliest `expire_ts` across the merged operands and any base value. A later merge with a longer TTL does not extend the lifetime of older merged data.
The current read path does not consult wall-clock time to hide expired rows. Instead, SlateDB returns the recorded `expire_ts` to metadata-aware callers and uses `expire_ts` during compaction. Applications that need strict read-time TTL enforcement should compare `expire_ts` with their current time.
Compaction is what turns expiration into deletion. Ordinary values may be rewritten to tombstones so older versions in lower levels do not become visible again. Expired merge operands are dropped instead of converted to tombstones because a tombstone would also erase older merge history. Physical removal still depends on later compaction and [garbage collection](/docs/design/gc).
## Mapping Between Timestamps and Sequence Numbers
[Section titled “Mapping Between Timestamps and Sequence Numbers”](#mapping-between-timestamps-and-sequence-numbers)
For approximate conversion between sequence numbers and wall-clock time, the manifest stores a bounded sequence tracker. [`Admin::get_timestamp_for_sequence()`](https://docs.rs/slatedb/latest/slatedb/admin/struct.Admin.html#method.get_timestamp_for_sequence) and [`Admin::get_sequence_for_timestamp()`](https://docs.rs/slatedb/latest/slatedb/admin/struct.Admin.html#method.get_sequence_for_timestamp) query that tracker, and `slatedb-cli` exposes the same conversion. The mapping is lossy by design: recent history has finer granularity, and older history is downsampled to keep manifest state bounded. See [RFC-0012](/rfcs/0012-sequence-tracker/) for its design.
# Writes
Writes are moved to a background task as quickly as possible to prevent blocking the client. `put()`, `write()`, and `delete()` return a `WriteHandle` after the write reaches the in-memory WAL and MemTable. Call `handle.await_durable().await` to wait for that write to reach object storage, or call `flush()` explicitly. The synchronous flow is as follows:
1. A `put()`, `write()`, or `delete()` call is made on the client.
2. The key/value pair is written to the mutable, in-memory WAL table.
3. The key/value pair is written to the mutable, in-memory MemTable.
The following asynchronous flows occur:
* The WAL flusher periodically checks if the WAL table is full. If it is, it freezes the mutable WAL table and triggers an asynchronous write to object storage. Durability waiters are notified when the durable sequence number advances.
* The MemTable flusher periodically checks if the MemTable is full. If it is, it freezes the mutable MemTable and triggers an asynchronous write to object storage. With the WAL disabled, this advances the durable sequence number and notifies durability waiters.
Below is a diagram illustrating the high-level flow of a write in SlateDB:
```
flowchart TD
A[API Call: put/delete/write] --> B[Create WriteBatch]
B --> C["Send to Write Task (channel)"]
C --> D[Assign Sequence Number]
D --> E{WAL Enabled?}
E -- Yes --> F[Append to WAL Buffer]
E -- No --> G[Insert into Memtable]
F --> G[Insert into Memtable]
G --> H{Memtable Full?}
H -- Yes --> I[Freeze Memtable]
I --> J[Flush Immutable Memtable to SSTable]
F --> K{WAL Buffer Full or Flush Interval?}
K -- Yes --> L[Flush WAL to SSTable]
J & L --> M[Write SSTable to Object Storage]
M --> N[Send Durability Notification]
```
## User-supplied sequence numbers
[Section titled “User-supplied sequence numbers”](#user-supplied-sequence-numbers)
Every committed write batch is stamped with a `u64` sequence number. By default SlateDB’s internal *oracle* — a monotonic counter shared by all writers — assigns one as the batch is dequeued from the write channel. [`WriteOptions::seqnum`](https://docs.rs/slatedb/latest/slatedb/config/struct.WriteOptions.html#structfield.seqnum) lets the caller override that counter and stamp the batch with a specific value instead.
### Semantics
[Section titled “Semantics”](#semantics)
The default value is `0`, which means *“let the oracle assign the seqnum”*. Sequence numbers issued by the oracle start at `1`, so `0` is unambiguously a sentinel. Any non-zero value is treated as a request to commit at that exact sequence number, subject to one rule:
* The supplied seqnum must be **strictly greater** than the current max sequence number known to the database. Otherwise the write fails with a [`slatedb::Error`](https://docs.rs/slatedb/latest/slatedb/enum.Error.html) of kind `Invalid`, with the message `invalid sequence number, must be greater than the current max. provided=..., current=...`.
When the check passes, the oracle is advanced to the supplied value, so subsequent auto-assigned writes resume above it. User-supplied and oracle-assigned writes can be mixed freely as long as the seqnums monotonically increase across the write stream.
```rust
use slatedb::config::{PutOptions, WriteOptions};
// Stamp this write with seqnum = 42 (e.g., the offset returned by your
// external WAL after appending the record).
let opts = WriteOptions { seqnum: 42, ..Default::default() };
db.put_with_options(b"key", b"value", &PutOptions::default(), &opts).await?;
```
### Ordering responsibility
[Section titled “Ordering responsibility”](#ordering-responsibility)
Sequence numbers are assigned on the single-writer event loop that drains the write channel — but the *caller* picks the value before the batch is enqueued. That means concurrent writers on your side can race:
```text
thread A: pick seqnum 1, send batch to channel
thread B: pick seqnum 2, send batch to channel
```
If the OS schedules thread B’s send before thread A’s, the seqnum `2` batch arrives first, advances the oracle, and the seqnum `1` batch is rejected as no-longer-greater-than-current.
To avoid this, funnel all writes that use `seqnum` through a single ordering agent on your side — typically the same component that assigned the seqnums (e.g., the producer that appends to your external log). One writer, one channel, no races. If you genuinely need multiple writer threads, serialize them behind a mutex or a single-producer queue before calling `put`/`write`.
### Caveats
[Section titled “Caveats”](#caveats)
* **Non-contiguous seqnums.** Auto-assigned seqnums are not guaranteed to be strictly contiguous either (the memtable flusher tolerates gaps), so user-supplied jumps are consistent with existing behavior. Anything that relies on dense seqnums — don’t.
* **Recovery.** On restart, SlateDB recovers the max seqnum from the manifest and any unflushed WAL. If you continue assigning seqnums from your external log, make sure the next value is above whatever SlateDB recovered, or your first post-restart write will be rejected.
* **Transactions.** Conflict detection still works on user-supplied seqnums — the read/write set is tracked against the snapshot’s seqnum and the commit seqnum, regardless of who picked them. Just keep the monotonic-increase invariant.
* **Durability.** The oracle is advanced as soon as the batch is processed by the write loop, before durability is confirmed. The seqno is still consumed if a later durability wait fails. Use the returned `WriteHandle::await_durable()` when confirmation is required.
# FAQ
> Frequently asked questions about SlateDB
## Is SlateDB for OLTP or OLAP?
[Section titled “Is SlateDB for OLTP or OLAP?”](#is-slatedb-for-oltp-or-olap)
SlateDB is designed for key/value (KV) online transaction processing (OLTP) workloads. It is optimized for lowish-latency, high-throughput writes. It is not optimized for analytical queries that scan large amounts of columnar data. For online analytical processing (OLAP) workloads, we recommend checking out [Tonbo](https://github.com/tonbo-io/tonbo).
## Can’t I use S3 as a key-value store?
[Section titled “Can’t I use S3 as a key-value store?”](#cant-i-use-s3-as-a-key-value-store)
You can definitely use S3 as a key-value store. An object path would represent a key and the object its value. But then you pay one PUT per write, which gets expensive.
To make writes cheaper, you will probably want to batch writes (write multiple key-value pairs in a single `PUT` call). Batched key-value pairs need to be encoded/decoded, and a sorted strings table (SST) is a natural fit. Once you have SSTs, a log-structured merge-tree (LSM) is a natural fit.
## Why does SlateDB have a write-ahead log?
[Section titled “Why does SlateDB have a write-ahead log?”](#why-does-slatedb-have-a-write-ahead-log)
Some developers have asked why SlateDB needs a write-ahead log (WAL) if we’re batching writes. Couldn’t we just write the batches directly to level 0 (L0) as an SST?
We opted to have the WAL separate from the L0 SST so that we could frequently write SSTs without increasing the number of L0 SSTs we have. Since reads are served from L0 SSTs, having too many of them would result in a very large amount of metadata that needs to be managed in memory. By contrast, we don’t serve reads from the durable WAL, we only use it for recovery.
A separate WAL lets SlateDB frequently flush writes to object storage (to reduce durable write latency) without having to worry about the number of L0 SSTs we have.
## How is this different from RocksDB-cloud?
[Section titled “How is this different from RocksDB-cloud?”](#how-is-this-different-from-rocksdb-cloud)
RocksDB-cloud does not write its write-ahead log (WAL) to object storage. Instead, it supports either local disk, Kafka, or Kinesis for the WAL. Users must decide whether they want to increase operation complexity by adding a distributed system like Kafka or Kinesis to their stack, whether they want to use local disk for the WAL, or whether they want to use EBS or EFS for the WAL.
SlateDB, by contrast, writes everything (including the WAL) to object storage. This offers a simpler architecture, better durability, and potentially better availability at the cost of increased write latency (compared to local).
RocksDB-cloud also does not cache writes, so recently written data must be read from object storage even if reads occur immediately after writes. SlateDB, by contrast, caches recent writes in memory and also supports optional local-disk caching of object-store data.
Finally, it’s unclear how open RocksDB-cloud is to outside contributors. The code is available, but there is very little documentation or community.
## How is this different from RocksDB on EBS?
[Section titled “How is this different from RocksDB on EBS?”](#how-is-this-different-from-rocksdb-on-ebs)
Amazon Web Services (AWS) elastic block storage (EBS) runs in a single availability zone (AZ). To get SlateDB’s durability and availability (when run on S3 standard buckets), you would need to replicate the across three AZs. This complicates the main write path (you would need synchronous replication) and add to cost.
S3 is inherently much more flexible and elastic. You don’t need to overprovision, you don’t need to manage volume sizes, and you don’t have to worry about transient space amplification from compaction.
S3 also allows for more cost/perf/availability tradeoffs. For example, users can sacrifice some availability by running one node in front of S3 and replacing it on a failure. [The Cloud Storage Triad: Latency, Cost, Durability](https://materializedview.io/p/cloud-storage-triad-latency-cost-durability) talks more about these tradeoffs.
## How is this different from RocksDB on EFS?
[Section titled “How is this different from RocksDB on EFS?”](#how-is-this-different-from-rocksdb-on-efs)
Amazon Web Services (AWS) elastic file system (EFS) is very expensive ($0.30/GB-month for storage, $0.03/GB for reads, and $0.06/GB for writes). It’s also unclear how well RocksDB works with network file system (NFS) mounted filesystems, and whether [close-to-open consistency](https://docs.aws.amazon.com/efs/latest/ug/features.html#consistency) breaks any internal assumptions in RocksDB.
## How is this different from DynamoDB?
[Section titled “How is this different from DynamoDB?”](#how-is-this-different-from-dynamodb)
DynamoDB has a different cost structure and API than SlateDB. In general, SlateDB will be cheaper. DynamoDB charges $0.1/GiB for storage. If you use S3 standard with SlateDB, storage starts at $0.023/GiB (nearly 5 times cheaper).
S3 standard charges $0.005 per-1000 writes (PUT, DELETE, etc.) and $0.0004 per-1000 reads. DynamoDB charges in read and write request units (RRU and WRU, respectively). Writes cost $1.25 per-million write units and $0.25 per-million read units. Depending on consistency and data size, a single request can cost multiple units (see [here](https://aws.amazon.com/dynamodb/pricing/on-demand/) for details). SlateDB batches writes by default, so it’s usually going to have a less expensive API bill. If you batch DyanmoDB writes, you might be able to get similar fees.
DynamoDB offers 99.999% SLA while an S3 standard bucket offers 99.99%, so DynamoDB is more available.
DynamoDB also requires partitioning. SlateDB doesn’t have partitioning. Instead, you must build a partitioning scheme on top of SlateDB if you need it. Though, since SlateDB fences stale writers, partition management should be fairly straightforward.
SlateDB also offers some unique features like the ability to create snapshot clones of a database at a specific point in time.
## What happens if the process goes down before SlateDB flushes data to object storage?
[Section titled “What happens if the process goes down before SlateDB flushes data to object storage?”](#what-happens-if-the-process-goes-down-before-slatedb-flushes-data-to-object-storage)
Any in-flight data that hasn’t yet been flushed to object storage will be lost.
SlateDB’s write APIs return after updating the in-memory WAL and MemTable. Call `handle.await_durable().await` on the returned [`WriteHandle`](https://docs.rs/slatedb/latest/slatedb/struct.WriteHandle.html) to wait for one write to reach object storage, or call `db.flush().await` to flush all pending writes.
## Does SlateDB support column families?
[Section titled “Does SlateDB support column families?”](#does-slatedb-support-column-families)
SlateDB does not support [column families](https://github.com/facebook/rocksdb/wiki/column-families), but it does offer [segments](https://github.com/slatedb/slatedb/blob/main/rfcs/0024-segment-oriented-compaction.md), which partition a single keyspace into independent, prefix-derived key ranges that each get their own compaction and retention lifecycle. If you instead need fully isolated keyspaces with independent configuration, opening multiple SlateDB databases is cheap and remains the recommended approach.
## Are there any limits to key and value sizes?
[Section titled “Are there any limits to key and value sizes?”](#are-there-any-limits-to-key-and-value-sizes)
Keys are limited to a maximum of 65 KiB (65,535 bytes). Values are limited to a maximum of 4 GiB (4,294,967,295 bytes). Larger values require more memory and will take longer to write, so we recommend testing performance at your expected value size to ensure it meets your requirements.
# Introduction
> Learn about SlateDB, an embedded database built on object storage
SlateDB is an embedded storage engine built as a [log-structured merge-tree](https://en.wikipedia.org/wiki/Log-structured_merge-tree). Unlike traditional LSM-tree storage engines, SlateDB writes all data to object storage.
## Vision
[Section titled “Vision”](#vision)
Object storage is an amazing technology. It provides highly-durable, highly-scalable, highly-available storage at a great cost. And recent advancements have made it even more attractive:
* Google Cloud Storage supports multi-region and dual-region buckets for high availability.
* All object stores support compare-and-swap (CAS) operations.
* Amazon Web Service’s S3 Express One Zone has single-digit millisecond latency.
We believe that the future of object storage are multi-region, low latency buckets that support atomic CAS operations. Inspired by [The Cloud Storage Triad: Latency, Cost, Durability](https://materializedview.io/p/cloud-storage-triad-latency-cost-durability), we set out to build a storage engine built for the cloud. SlateDB is that storage engine.
## Features
[Section titled “Features”](#features)
* **Zero-Disk architecture**: SlateDB is easy to operate. It can run as an in-process storage engine with no local state, no control plane, and no replication protocol.
* **Single-writer**: SlateDB is designed for a single writer. Partitioning can easily be built on top of SlateDB since fencing is supported.
* **Multiple-readers**: Multiple readers on different nodes can all read the same SlateDB database.
* **Read caching**: SlateDB supports in-memory and (optional) on-disk read caching to reduce latency and API cost.
* **Snapshot isolation**: SlateDB supports snapshot isolation, which allows readers and writers to see a consistent view of the database.
* **Transactions**: Transactional writes are supported.
* **Object store persistence**: SlateDB writes all data to object storage, which means SlateDB has the same durability, scalability, and availability as your object store.
* **Writer fencing**: SlateDB enforces writer fencing. Zombie writer processes are detected and prevented from writing to the database.
* **Pluggable compaction**: SlateDB supports pluggable compaction, so you can use the compaction strategy that fits your needs.
## Use Cases
[Section titled “Use Cases”](#use-cases)
SlateDB is a great fit for use cases that are tolerant to 50-100ms write latency, are tolerant to data loss during failure, or are willing to pay for frequent API PUT calls. Such use cases include:
* Stream processing
* Serverless functions
* Durable execution
* Workflow orchestration
* Durable caches
* Data lakes
# Quick Start
> Get started with SlateDB in minutes
SlateDB is implemented in Rust and ships official bindings for Go, Java, Node.js, and Python. Pick your language below to install the library and run a minimal example backed by an in-memory object store.
## Installation
[Section titled “Installation”](#installation)
* Rust
```bash
cargo add slatedb tokio --features tokio/macros,tokio/rt-multi-thread
```
* Go
```bash
go get slatedb.io/slatedb-go
```
The Go binding uses cgo and links against the `slatedb_uniffi` shared library. You need Go 1.25+, `CGO_ENABLED=1`, a C toolchain, and the `slatedb_uniffi` library on your loader path.
* Java
Gradle:
```gradle
dependencies {
implementation("io.slatedb:slatedb-uniffi:")
}
```
Maven:
```xml
io.slatedbslatedb-uniffi<version>
```
Requires Java 22 or newer.
* Node.js
```bash
npm install @slatedb/uniffi
```
Requires Node.js 20 or newer.
* Python
```bash
pip install slatedb
```
Requires Python 3.10 or newer.
## Usage
[Section titled “Usage”](#usage)
SlateDB reads and writes through an object store. Every binding exposes `ObjectStore.resolve(...)` which accepts any URL supported by Rust’s [`object_store`](https://docs.rs/object_store/latest/object_store/fn.parse_url_opts.html) crate, including `memory:///`, `file:///...`, `s3://`, `gs://`, and `az://`. Pass the database path separately to `Db::open`, `DbBuilder`, or `NewDbBuilder`, as the examples below do.
* Rust
SlateDB uses [`tokio`](https://crates.io/crates/tokio) as its async runtime and [`object_store`](https://docs.rs/object_store/latest/object_store/) to interact with object storage.
main.rs
```rust
use slatedb::{object_store::memory::InMemory, Db, Error};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Error> {
// Setup
let object_store = Arc::new(InMemory::new());
let kv_store = Db::open("/tmp/slatedb_full_example", object_store).await?;
// Put
let key = b"test_key";
let value = b"test_value";
kv_store.put(key, value).await?;
// Get
assert_eq!(kv_store.get(key).await?, Some("test_value".into()));
// Delete
kv_store.delete(key).await?;
assert!(kv_store.get(key).await?.is_none());
kv_store.put(b"test_key1", b"test_value1").await?;
kv_store.put(b"test_key2", b"test_value2").await?;
kv_store.put(b"test_key3", b"test_value3").await?;
kv_store.put(b"test_key4", b"test_value4").await?;
// Scan over unbound range
let mut iter = kv_store.scan(..).await?;
let mut count = 1;
while let Ok(Some(item)) = iter.next().await {
assert_eq!(item.key, format!("test_key{count}").into_bytes());
assert_eq!(item.value, format!("test_value{count}").into_bytes());
count += 1;
}
// Scan over bound range
let mut iter = kv_store.scan("test_key1"..="test_key2").await?;
let kv1 = iter.next().await?.unwrap();
assert_eq!(kv1.key, b"test_key1".as_slice());
assert_eq!(kv1.value, b"test_value1".as_slice());
let kv2 = iter.next().await?.unwrap();
assert_eq!(kv2.key, b"test_key2".as_slice());
assert_eq!(kv2.value, b"test_value2".as_slice());
// Seek ahead to next key
let mut iter = kv_store.scan(..).await?;
let next_key = b"test_key4";
iter.seek(next_key).await?;
let kv4 = iter.next().await?.unwrap();
assert_eq!(kv4.key, b"test_key4".as_slice());
assert_eq!(kv4.value, b"test_value4".as_slice());
assert_eq!(iter.next().await?, None);
// Close
kv_store.close().await?;
Ok(())
}
```
* Go
```go
package main
import (
"bytes"
"fmt"
slatedb "slatedb.io/slatedb-go/uniffi"
)
func main() {
store, err := slatedb.ObjectStoreResolve("memory:///")
if err != nil {
panic(err)
}
defer store.Destroy()
builder := slatedb.NewDbBuilder("example-db", store)
defer builder.Destroy()
db, err := builder.Build()
if err != nil {
panic(err)
}
defer db.Destroy()
if _, err := db.Put([]byte("hello"), []byte("world")); err != nil {
panic(err)
}
value, err := db.Get([]byte("hello"))
if err != nil {
panic(err)
}
if value == nil || !bytes.Equal(*value, []byte("world")) {
panic("unexpected value")
}
fmt.Println(string(*value))
if err := db.Shutdown(); err != nil {
panic(err)
}
}
```
Handle types own Rust-side resources: call `Shutdown()` on databases and readers, and `Destroy()` on handles when you are done with them.
* Java
Most database operations return `CompletableFuture`. Native-backed handles implement `AutoCloseable` and should be closed.
```java
import io.slatedb.uniffi.Db;
import io.slatedb.uniffi.DbBuilder;
import io.slatedb.uniffi.ObjectStore;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public final class Main {
private static T await(CompletableFuture future) throws Exception {
return future.get(30, TimeUnit.SECONDS);
}
public static void main(String[] args) throws Exception {
byte[] key = "hello".getBytes(StandardCharsets.UTF_8);
byte[] value = "world".getBytes(StandardCharsets.UTF_8);
try (ObjectStore store = ObjectStore.resolve("memory:///");
DbBuilder builder = new DbBuilder("demo-db", store)) {
Db db = await(builder.build());
try (db) {
await(db.put(key, value));
byte[] read = await(db.get(key));
if (read == null || !Arrays.equals(read, value)) {
throw new IllegalStateException("unexpected value");
}
System.out.println(new String(read, StandardCharsets.UTF_8));
await(db.shutdown());
}
}
}
}
```
* Node.js
Keys and values are binary; pass `Buffer` or `Uint8Array`. Most operations are async and should be awaited. Native-backed handles also expose `dispose()` for deterministic cleanup after `shutdown()` or when abandoning a builder.
```js
import assert from "node:assert/strict";
import { DbBuilder, ObjectStore } from "@slatedb/uniffi";
async function main() {
const store = ObjectStore.resolve("memory:///");
let db;
try {
const builder = new DbBuilder("demo-db", store);
try {
db = await builder.build();
} finally {
builder.dispose();
}
const key = Buffer.from("hello");
const value = Buffer.from("world");
await db.put(key, value);
const read = await db.get(key);
assert.deepEqual(read, value);
console.log(Buffer.from(read).toString("utf8"));
} finally {
if (db != null) {
await db.shutdown();
db.dispose();
}
store.dispose();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
* Python
Most database operations are `async` and should be used with `asyncio`. `WriteBatch` is mutated synchronously and then consumed by `db.write(...)`. Call `db.shutdown()` to close native resources.
```python
import asyncio
from slatedb.uniffi import (
DbBuilder,
IsolationLevel,
KeyRange,
ObjectStore,
WriteBatch,
)
async def main() -> None:
store = ObjectStore.resolve("memory:///")
builder = DbBuilder("demo-db", store)
db = await builder.build()
try:
await db.put(b"user:1", b"Alice")
value = await db.get(b"user:1")
assert value == b"Alice"
batch = WriteBatch()
batch.put(b"user:2", b"Bob")
batch.put(b"user:3", b"Carol")
await db.write(batch)
scan = await db.scan_prefix(
b"user:",
KeyRange(
start=None,
start_inclusive=False,
end=None,
end_inclusive=False,
),
)
while (row := await scan.next()) is not None:
print(row.key, row.value)
tx = await db.begin(IsolationLevel.SERIALIZABLE_SNAPSHOT)
await tx.put(b"user:4", b"Dora")
await tx.commit()
finally:
await db.shutdown()
asyncio.run(main())
```
# Benchmarks
> Review SlateDB performance results and learn how to run your own benchmarks
## Release benchmarks
[Section titled “Release benchmarks”](#release-benchmarks)
SlateDB publishes release benchmark results at [benchmark.slatedb.io](https://benchmark.slatedb.io). The [slatedb/slatedb-benchmark](https://github.com/slatedb/slatedb-benchmark) repository contains the runner and workload definitions. It also stores the raw results published on the site.
The release suite starts from a shared database with 300 million records, about 120 GiB of logical data. The runner applies a fixed workload catalog to each SlateDB revision. The catalog combines relevant workloads from [YCSB Core](https://github.com/brianfrankcooper/YCSB/wiki/Core-Workloads) and RocksDB’s [`db_bench`](https://github.com/facebook/rocksdb/wiki/Benchmarking-tools). SlateDB-specific cases exercise idle behavior and transaction contention. Most workloads run 64 closed-loop clients after a five-minute warmup and record 15 minutes of activity.
## Other benchmarking tools
[Section titled “Other benchmarking tools”](#other-benchmarking-tools)
* [slatedb-bencher](https://github.com/slatedb/slatedb/tree/main/slatedb-bencher) runs configurable database, compaction, and transaction benchmarks against an object store. Its [README](https://github.com/slatedb/slatedb/blob/main/slatedb-bencher/README.md) documents the command-line options, and [benchmark-db.sh](https://github.com/slatedb/slatedb/blob/main/slatedb-bencher/benchmark-db.sh) provides an example workload matrix.
* SlateDB uses [Criterion](https://bheisler.github.io/criterion.rs/) for microbenchmarks of internal functions. The benchmark sources live in [slatedb/benches](https://github.com/slatedb/slatedb/tree/main/slatedb/benches).
## Nightly microbenchmarks
[Section titled “Nightly microbenchmarks”](#nightly-microbenchmarks)
The [nightly workflow](https://github.com/slatedb/slatedb/actions/workflows/nightly.yaml) runs the Criterion microbenchmarks on [WarpBuild](https://warpbuild.com)’s [warp-ubuntu-latest-arm64-8x](https://docs.warpbuild.com/cloud-runners) ARM runners. It also records profiles with [pprof-rs](https://github.com/tikv/pprof-rs) and uploads them to [pprof.me](https://pprof.me). The GitHub Actions job summary links to each profile.
## Benchmarking object stores
[Section titled “Benchmarking object stores”](#benchmarking-object-stores)
SlateDB benchmarks measure the database and object store together. Use [MinIO Warp](https://github.com/minio/warp) to measure raw S3-compatible object store performance without SlateDB in the request path. Warp runs concurrent GET, PUT, DELETE, and mixed-request benchmarks with configurable object sizes and concurrency. Run it from the same region and network used by your SlateDB clients, ideally on the same machine, so the network path and client capacity remain comparable.
# CLI
The SlateDB Admin CLI provides an interface for managing and maintaining SlateDB databases. It enables direct interaction with SlateDB manifests, checkpoints, and garbage collector.
## Installation
[Section titled “Installation”](#installation)
You can install the SlateDB CLI using Cargo. Run:
```plaintext
cargo install --locked slatedb-cli
```
Alternatively, build from source by cloning the repository and compiling the CLI:
```plaintext
git clone https://github.com/slatedb/slatedb.git
cd slatedb
cargo build --release -p slatedb-cli
```
The resulting binary will be available at `target/release/slatedb`.
## Setup
[Section titled “Setup”](#setup)
The CLI requires environment variables to configure access to your object store. You can set these variables in your shell environment or provide them via a `.env` file using the `--env-file` option. The required variables depend on your object store provider. See [admin.rs](https://github.com/slatedb/slatedb/blob/main/slatedb/src/admin.rs) for details.
Every command requires the `--path` option, which specifies the root directory in your object store.
## Usage
[Section titled “Usage”](#usage)
Run the CLI with `slatedb --help` to see a list of available commands and options.
## Run the standalone compactor
[Section titled “Run the standalone compactor”](#run-the-standalone-compactor)
Run compaction as a standalone process (instead of inside your `Db` clients):
```bash
slatedb --env-file .env --path run-compactor
```
The compactor runs until interrupted (Ctrl-C), then shuts down gracefully.
Note
The compactor expects the database to already exist (a manifest must be present). If you’re creating a new database, open it once before starting the compactor.
See [Run a Standalone Compactor](/docs/tutorials/standalone-compactor/) for how to disable the embedded compactor in your database clients.
## Run the standalone garbage collector
[Section titled “Run the standalone garbage collector”](#run-the-standalone-garbage-collector)
Run garbage collection as a standalone process (instead of inside your `Db` clients).
For a single foreground pass over one file type, use `run-garbage-collection`:
```bash
slatedb --env-file .env --path run-garbage-collection \
--resource compacted \
--min-age 5m
```
Valid resources are `manifest`, `wal`, `wal-fence`, `compacted`, and `compactions`.
To run garbage collection on a schedule in a standalone process, pass one or more schedules:
```bash
slatedb --env-file .env --path schedule-garbage-collection \
--manifest min_age=5m,period=1m \
--wal min_age=5m,period=1m \
--compacted min_age=5m,period=1m \
--compactions min_age=5m,period=1m
```
The scheduled process runs until interrupted (Ctrl-C), then shuts down gracefully.
Pass `--disable-boundary-files` to `run-garbage-collection` or `schedule-garbage-collection` to delete eligible manifest and compactions metadata without advancing boundary files. Use the same setting for every garbage collector operating on the database.
Note
The garbage collector expects the database to already exist (a manifest must be present). If you’re creating a new database, open it once before starting the garbage collector.
See [Run a Standalone Garbage Collector](/docs/tutorials/standalone-garbage-collector/) for how to disable the embedded garbage collector in your database clients.
# Compatibility
> What SlateDB currently guarantees across releases, storage formats, object stores, and tested targets
SlateDB follows Semantic Versioning and guarantees forward and backward compatibility for storage formats between adjacent releases. **We do not currently guarantee compile-time API compatibility.**
Shared deployments should move one release at a time. If several processes read and write the same database path, keep them within one release step of each other until the rollout is complete.
## Stored Data
[Section titled “Stored Data”](#stored-data)
A single database can contain a mix of older and newer SST files during normal operation. Readers and compactors must understand every format already present in the bucket. SlateDB version-checks its persistent formats and fails fast when it sees a version it does not understand.
Compression is part of compatibility too. A process can only read compressed SSTs for codecs that were compiled into that binary. If one deployment writes Zstd-compressed SSTs, every reader and compactor that touches that database needs the `zstd` feature enabled. [Compression](/docs/design/compression) covers the codec feature flags.
# Configuration
> How SlateDB loads Settings, which knobs live there, and what must be configured through builders
SlateDB configuration shows up in three main places:
1. [`Settings`](https://docs.rs/slatedb/latest/slatedb/config/struct.Settings.html) holds serializable runtime settings.
2. Builders such as [`DbBuilder`](https://docs.rs/slatedb/latest/slatedb/struct.DbBuilder.html) and [`DbReaderBuilder`](https://docs.rs/slatedb/latest/slatedb/struct.DbReaderBuilder.html) have `with_*` functions to handle complex types and dependency injection.
3. Operation-specific option types such as [`ReadOptions`](https://docs.rs/slatedb/latest/slatedb/config/struct.ReadOptions.html) and [`WriteOptions`](https://docs.rs/slatedb/latest/slatedb/config/struct.WriteOptions.html).
For the complete configuration surface and current defaults, read the [`slatedb::config`](https://docs.rs/slatedb/latest/slatedb/config/) rustdoc.
## Example
[Section titled “Example”](#example)
This example shows all three configuration layers in one place:
```rust
use slatedb::{
config::{DbReaderOptions, MetricLevel, ReadOptions, Settings},
db_cache::foyer::FoyerCache,
object_store::{memory::InMemory, ObjectStore},
Db, DbReader, Error,
};
use std::sync::Arc;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Error> {
let object_store: Arc = Arc::new(InMemory::new());
// 1. Serializable runtime settings.
let mut settings = Settings::default();
settings.flush_interval = Some(Duration::from_millis(250));
settings.metric_level = MetricLevel::Debug;
// 2. Builder configuration for process-local components and wiring.
let db = Db::builder("example", Arc::clone(&object_store))
.with_settings(settings)
.with_db_cache(Arc::new(FoyerCache::new()))
.build()
.await?;
// 3. Per-call options for one operation.
let _value = db
.get_with_options(
b"key",
&ReadOptions::default().with_cache_blocks(false),
)
.await?;
// Reader-specific options configure the DbReader instance itself.
let mut reader_options = DbReaderOptions::default();
reader_options.skip_wal_replay = true;
let _reader = DbReader::builder("example", object_store)
.with_options(reader_options)
.build()
.await?;
Ok(())
}
```
`Settings::metric_level` controls which registered metrics are active. The default is `Info`; set it to `Debug` to enable debug-level metrics when using a metrics recorder. See [Metric levels](/docs/operations/metrics/#metric-levels) for more information.
## Garbage-collection boundary files
[Section titled “Garbage-collection boundary files”](#garbage-collection-boundary-files)
`GarbageCollectorOptions::boundary_files_enabled` controls whether manifest and compactions garbage collection advances durable boundary files before deleting eligible metadata. Boundary advancement is enabled by default. Set it to `false` for object stores that do not support conditional overwrite (`If-Match`), and use the same setting for every garbage collector operating on the database.
Metadata readers and writers always honor existing boundary files. Without boundary advancement, configure manifest and compactions garbage collection `min_age` values longer than any stale writer or compactor can remain alive.
## Reconfiguring Object Stores
[Section titled “Reconfiguring Object Stores”](#reconfiguring-object-stores)
Reconfiguring an object store only changes how SlateDB reaches storage. It does not migrate data between stores for you. The new `ObjectStore` must still have the existing database contents for the database path.
For the regular/main object store, pass the replacement store as the primary store to `Db::builder`, `DbReader::builder`, or `AdminBuilder::new`. Changing credentials, endpoints, or client implementations is fine as long as the new store still points at the same underlying bucket and prefix.
Caution
If you point the main store at a different location without first copying the manifest and SST data, SlateDB will treat that location as a different database root rather than migrating the existing database.
If you’re using a separate WAL object store, make the same kind of change through `.with_wal_object_store(...)`. If you need to move the WAL data, copy the existing WAL objects first and then reopen with `.with_wal_object_store(...)` pointing at the replacement store.
To migrate the data manually:
1. Stop writers, readers that replay WAL, compactor processes, garbage collection, and any admin jobs that can update the database state.
2. Copy the main database directories from the old main store and database path to the replacement main store and database path: `manifest/`, `compactions/`, and `compacted/`. If the database does not use a separate WAL store, copy `wal/` there too.
3. If the database uses a separate WAL object store, copy the old WAL store’s `wal/` directory to the replacement WAL store under the same database path. Do not move `manifest/`, `compactions/`, or `compacted/` into the WAL store; those remain in the main store.
4. Copy whole directory contents, not only the files referenced by the latest manifest. Older manifests, WAL SSTs, and compacted SSTs can still be needed until checkpoints and garbage collection release them.
5. Reopen SlateDB with the replacement store configuration.
# Data Modeling
> Best practices for designing keys in SlateDB
SlateDB uses bytewise lexicographic ordering for all keys. This is the same approach used by FoundationDB, DynamoDB, and RocksDB (by default). Custom comparators are intentionally not supported. This is a deliberate design decision that reduces complexity and avoids bugs observed in other implementations.
This means you are responsible for constructing keys that sort correctly under byte comparison. This document provides patterns for encoding common data types and designing effective key schemas.
## Key Constraints
[Section titled “Key Constraints”](#key-constraints)
Before diving into encoding patterns, be aware of SlateDB’s key and value limits:
| Constraint | Limit |
| ------------------ | ----------------------------- |
| Maximum key size | 65,535 bytes |
| Maximum value size | 4,294,967,295 bytes |
| Minimum key size | 1 byte (keys cannot be empty) |
## How Keys Are Used
[Section titled “How Keys Are Used”](#how-keys-are-used)
Understanding how SlateDB uses keys internally helps explain why key design matters for performance.
### Sorted Storage
[Section titled “Sorted Storage”](#sorted-storage)
All data in SlateDB is stored sorted by key. The in-memory MemTable uses a skip list that maintains key order, and on-disk SSTables store keys in sorted order within blocks. This sorted structure enables:
* **Efficient range scans**: Iterating over a key range (e.g., all keys starting with `user:123:`) requires no random access—just sequential reads through sorted data.
* **Binary search**: Point lookups use binary search to find the right SSTable and block, achieving O(log n) complexity instead of scanning all data.
### Prefix Compression
[Section titled “Prefix Compression”](#prefix-compression)
Within each SST block, SlateDB uses prefix compression. Each key is encoded relative to the previous key in the block, and restart points store a full key. For example, if a block contains:
```plaintext
user:123:profile
user:123:settings
user:123:preferences
```
The common prefix `user:123:` is stored once, and subsequent keys store only `settings`, `preferences`, etc. This means keys with shared prefixes compress better and use less storage. It also means that there is less of a storage penalty for repeated key prefixes.
### Block Caching
[Section titled “Block Caching”](#block-caching)
SlateDB caches SST blocks in memory. Each block contains multiple adjacent keys. When you read one key, the entire block is cached—subsequent reads for nearby keys (in sort order) are served from cache without hitting object storage.
**Implication**: Keys that are accessed together should be close together in sort order. This is why hierarchical key designs (like `tenant:user:resource`) work well—related data shares prefixes and lands in the same blocks.
### Forward-Only Iteration
[Section titled “Forward-Only Iteration”](#forward-only-iteration)
SlateDB currently supports only forward (ascending) iteration. If your access pattern requires descending order (e.g., “most recent first”), you must encode keys to sort in reverse order. See the [Descending Order](#descending-order) pattern below.
## Encoding Primitive Types
[Section titled “Encoding Primitive Types”](#encoding-primitive-types)
### Unsigned Integers
[Section titled “Unsigned Integers”](#unsigned-integers)
Use big-endian (network byte order) encoding. The most significant byte comes first, which ensures correct lexicographic ordering.
```rust
// Encoding a u64
let value: u64 = 42;
let encoded = value.to_be_bytes();
// Result: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A]
```
### Signed Integers
[Section titled “Signed Integers”](#signed-integers)
For signed integers, big-endian encoding alone doesn’t work because negative numbers have their sign bit set, making them appear “larger” than positive numbers in byte comparison.
The solution is to flip the sign bit (XOR the most significant byte with `0x80`):
```rust
fn encode_i64(value: i64) -> [u8; 8] {
let mut bytes = value.to_be_bytes();
bytes[0] ^= 0x80; // Flip sign bit
bytes
}
```
This transforms the two’s complement representation into an unsigned-comparable form where negative numbers sort before positive numbers.
### Floating Point Numbers
[Section titled “Floating Point Numbers”](#floating-point-numbers)
IEEE 754 floating point numbers require special handling:
* For **positive** floats: flip the sign bit only (XOR first byte with `0x80`)
* For **negative** floats: flip ALL bits (XOR each byte with `0xFF`)
```rust
fn encode_f64(value: f64) -> [u8; 8] {
let mut bytes = value.to_be_bytes();
if bytes[0] & 0x80 == 0 {
// Positive: flip sign bit
bytes[0] ^= 0x80;
} else {
// Negative: flip all bits
for byte in &mut bytes {
*byte ^= 0xFF;
}
}
bytes
}
```
This ensures the correct ordering: `-inf < -max < ... < -0 < +0 < ... < +max < +inf`
Note
NaN values require special handling. A common approach is to sort them after +infinity by detecting and encoding them with a special prefix.
### Strings
[Section titled “Strings”](#strings)
UTF-8 encoded strings are naturally ordered by Unicode code point, which is usually what you want.
For locale-aware sorting (e.g., case-insensitive, or treating `ä` as equivalent to `a`):
1. Apply a collation transformation using a library like ICU
2. Store the collation key as the sort key
3. Store the original string in the value if you need to retrieve it
## Composite Keys
[Section titled “Composite Keys”](#composite-keys)
Many applications need keys composed of multiple components (e.g., `tenant_id + user_id + timestamp`).
### Choosing a Delimiter
[Section titled “Choosing a Delimiter”](#choosing-a-delimiter)
The recommended delimiter is `0x00` (null byte):
* Sorts before all other byte values
* Natural choice for “end of component” semantics
**Critical requirement**: The delimiter must not appear within any component value. If your components are strings that never contain null bytes, you can use `0x00` directly.
### Escaping for Binary-Safe Keys
[Section titled “Escaping for Binary-Safe Keys”](#escaping-for-binary-safe-keys)
If components can contain arbitrary binary data (including null bytes), use an escape mechanism. Following FoundationDB’s tuple layer pattern:
* `0x00 0x00` = component terminator
* `0x00 0xFF` = literal null byte within a component
When encoding: replace each `0x00` in the data with `0x00 0xFF`, then append `0x00 0x00` to terminate.
### Variable-Length Components
[Section titled “Variable-Length Components”](#variable-length-components)
Two approaches:
1. **Null-terminated with escaping**: Use `0x00` terminator with the escape sequences above
* Preserves sort order within components
* Slightly more complex encoding/decoding
2. **Length-prefixed**: Prepend a fixed-size length before each component
* No escaping needed
* Doesn’t preserve sort order of the content itself (lengths sort first)
**Optimization**: Place variable-length components last when possible—no delimiter is needed for the final component.
### Fixed-Length Components
[Section titled “Fixed-Length Components”](#fixed-length-components)
No delimiter is needed between consecutive fixed-length components. Just concatenate them directly:
```rust
// topic_id (4 bytes) + offset (8 bytes) = 12 byte key
let mut key = Vec::with_capacity(12);
key.extend_from_slice(&topic_id.to_be_bytes());
key.extend_from_slice(&offset.to_be_bytes());
```
This saves space and simplifies encoding.
### Hierarchical Keys
[Section titled “Hierarchical Keys”](#hierarchical-keys)
Structure keys to enable efficient prefix scans:
```plaintext
usa#california#san_francisco#mission
usa#california#san_francisco#soma
usa#california#los_angeles#hollywood
usa#texas#austin#downtown
```
With this structure:
* Scan `usa#` to get all California and Texas data
* Scan `usa#california#` to get all California cities
* Scan `usa#california#san_francisco#` to get all SF neighborhoods
Each level of the hierarchy is independently scannable with a prefix query.
## Common Patterns
[Section titled “Common Patterns”](#common-patterns)
### Descending Order
[Section titled “Descending Order”](#descending-order)
For “most recent first” or “highest value first” access patterns:
```rust
// Option 1: Bit inversion
fn encode_descending(value: u64) -> [u8; 8] {
let mut bytes = value.to_be_bytes();
for byte in &mut bytes {
*byte ^= 0xFF;
}
bytes
}
// Option 2: Subtraction from max
fn encode_descending_alt(value: u64) -> [u8; 8] {
(u64::MAX - value).to_be_bytes()
}
```
### Timestamps
[Section titled “Timestamps”](#timestamps)
For chronological order, use big-endian encoding of Unix timestamps:
```rust
let timestamp_millis: u64 = 1704067200000;
let key = timestamp_millis.to_be_bytes();
```
For reverse chronological order (most recent first):
```rust
let key = (u64::MAX - timestamp_millis).to_be_bytes();
```
### UUIDs
[Section titled “UUIDs”](#uuids)
* **UUIDv7**: Time-ordered and naturally lexicographically sortable (recommended for new applications)
* **UUIDv4**: Random distribution, does not sort meaningfully by time
* **UUIDv1**: Can be made time-ordered with byte reordering, but UUIDv7 is simpler
### Geospatial Data
[Section titled “Geospatial Data”](#geospatial-data)
Z-order (Morton) curves and Hilbert curves can encode 2D or 3D coordinates as fixed-length integers that preserve spatial locality. These space-filling curve encodings are lexicographically sortable and work well as keys or key components.
## Key Design Tips
[Section titled “Key Design Tips”](#key-design-tips)
* **Keep keys short**: Shorter keys mean less memory usage, faster comparisons, and better cache efficiency
* **Design for access patterns**: Structure keys so your most common queries are efficient range scans
* **Co-locate related data**: Items frequently accessed together should share key prefixes
## Libraries and References
[Section titled “Libraries and References”](#libraries-and-references)
Rather than implementing encodings yourself, consider using a library:
* [storekey](https://github.com/surrealdb/storekey) - Rust crate providing a binary encoding format that ensures lexicographic sort order
For more detailed encoding algorithms and patterns:
* [FoundationDB Data Modeling](https://apple.github.io/foundationdb/data-modeling.html) - Comprehensive guide covering tuple encoding, integers, floats, and composite types
* [DynamoDB Sort Key Best Practices](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-sort-keys.html) - Hierarchical key patterns and version control
# Errors
> How SlateDB classifies public errors
All public SlateDB APIs return [`slatedb::Error`](https://docs.rs/slatedb/latest/slatedb/struct.Error.html). The part applications should treat as stable is [`Error::kind()`](https://docs.rs/slatedb/latest/slatedb/struct.Error.html#method.kind), which returns [`ErrorKind`](https://docs.rs/slatedb/latest/slatedb/enum.ErrorKind.html). The formatted message and source chain are for logs and diagnosis; they can change between releases, so they are a poor place to hang recovery logic.
In practice, this means you should branch on the kind of failure, not on the exact text.
* [`ErrorKind::Transaction`](https://docs.rs/slatedb/latest/slatedb/enum.ErrorKind.html#variant.Transaction) means retry the whole transaction.
* [`ErrorKind::Unavailable`](https://docs.rs/slatedb/latest/slatedb/enum.ErrorKind.html#variant.Unavailable) means a dependency failed and the operation may succeed on retry.
* [`ErrorKind::Closed`](https://docs.rs/slatedb/latest/slatedb/enum.ErrorKind.html#variant.Closed) means the handle is done, and [`CloseReason`](https://docs.rs/slatedb/latest/slatedb/enum.CloseReason.html) explains why.
* [`ErrorKind::Invalid`](https://docs.rs/slatedb/latest/slatedb/enum.ErrorKind.html#variant.Invalid), [`ErrorKind::Data`](https://docs.rs/slatedb/latest/slatedb/enum.ErrorKind.html#variant.Data), and [`ErrorKind::Internal`](https://docs.rs/slatedb/latest/slatedb/enum.ErrorKind.html#variant.Internal) usually mean you should stop and investigate instead of retrying blindly.
See the [`slatedb::ErrorKind`](https://docs.rs/slatedb/latest/slatedb/enum.ErrorKind.html) rustdoc for a complete explanation of each kind.
## Sources
[Section titled “Sources”](#sources)
[`Error`](https://docs.rs/slatedb/latest/slatedb/struct.Error.html) implements the standard source chain. This chain is useful for logs and for narrow diagnostics, such as checking whether a `Data` error came from an underlying `object_store::Error::NotFound`.
# Configuring Foyer Cache
> Configuration and tuning guide for SlateDB's FoyerHybridCache block cache
\[`FoyerHybridCache`]\[foyer-hybrid] wraps Foyer’s `HybridCache` to give SlateDB a two-tier block cache: a fast in-memory tier backed by a persistent disk tier. For background on how it fits into SlateDB’s cache layers, see the [Caching](/docs/design/caching) design doc.
## Upgrade note for existing disk caches
[Section titled “Upgrade note for existing disk caches”](#upgrade-note-for-existing-disk-caches)
When upgrading from SlateDB 0.12.x to SlateDB 0.13.0 or later, existing Foyer disk cache directories may need attention. SlateDB 0.8.x through 0.12.x used Foyer 0.18 and wrote the disk tier with Foyer’s older large-engine layout. SlateDB 0.13.0 uses Foyer 0.22’s block engine, and that engine cannot read all entries from the old on-disk layout. After upgrading, startup may log `foyer_storage` `coding error` messages while recovering the old cache directory.
These errors are cache misses from SlateDB’s point of view. SlateDB falls back to the object store, records the cache error in metrics, and reinserts accessed blocks into the new Foyer layout. A warm-up pass over the working set should therefore repopulate the cache, but expect extra object-store reads and recovery noise during that first pass. To avoid the startup noise, clear the Foyer disk cache directory as part of the upgrade and let SlateDB rebuild it.
## Disk write pipeline
[Section titled “Disk write pipeline”](#disk-write-pipeline)
Two mechanisms control whether an in-memory entry reaches disk. Both need configuration for the disk tier to be useful.
### Write policy and admission
[Section titled “Write policy and admission”](#write-policy-and-admission)
The write policy (`HybridCachePolicy`) decides *when* Foyer submits an entry to the disk flusher. `WriteOnEviction` (default) submits entries only when they are evicted from the memory tier. `WriteOnInsertion` submits them on insert.
The admission filter decides *whether* the flusher accepts a submitted entry. The default accepts everything.
These two checks run in series. Under the defaults, entries only reach the flusher when memory pressure forces an eviction. If the memory tier is large enough to hold the working set, nothing is ever evicted, and nothing reaches disk until the db is closed.
### Flusher pipeline tuning
[Section titled “Flusher pipeline tuning”](#flusher-pipeline-tuning)
Even after an entry is submitted, the default `BlockEngineConfig` settings can silently drop it. The defaults allocate a single flusher thread (`with_flushers(1)`) and a 16 MiB buffer pool (`with_buffer_pool_size`). The submit queue threshold defaults to twice the buffer pool size (32 MiB). When inserts outpace the flusher, entries are dropped in two ways: the submit queue discards entries once it exceeds `submit_queue_size_threshold`, and the IO buffer drops entries that don’t fit in the buffer pool.
If this happens, you will notice the `storage_queue_channel_overflow` / `storage_queue_buffer_overflow` counters increment, which are available in foyer’s internal metrics and accessible via `with_metrics_registry()` when you construct your cache.
Ensure that your flusher and buffer pools are configured large enough to avoid the backpressure mechanism (see section on Monitoring below for sizing guidance):
```rust
use foyer::{
BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, HybridCacheBuilder, PsyncIoEngineConfig,
};
use slatedb::db_cache::CachedEntry;
use slatedb::db_cache::foyer_hybrid::FoyerHybridCache;
let cache = HybridCacheBuilder::new()
.with_name("slatedb_block_cache")
.memory(8 * 1024 * 1024 * 1024) // 8 GB memory tier
.with_weighter(|_, v: &CachedEntry| v.size())
.storage()
.with_io_engine_config(PsyncIoEngineConfig::new())
.with_engine_config(
BlockEngineConfig::new(
FsDeviceBuilder::new("/data/slatedb-cache")
.with_capacity(100 * 1024 * 1024 * 1024) // 100 GB disk tier
.build()
.unwrap(),
)
.with_block_size(64 * 1024)
.with_flushers(4)
.with_buffer_pool_size(256 * 1024 * 1024) // large buffer pool
.with_submit_queue_size_threshold(1024 * 1024 * 1024), // large queue size
)
.build()
.await
.unwrap();
let cache = FoyerHybridCache::new_with_cache(cache);
```
### Write policy choice
[Section titled “Write policy choice”](#write-policy-choice)
`WriteOnEviction` (default) only writes entries to disk under memory pressure or at `close()`. This is fine when the memory tier is smaller than the working set (so eviction happens naturally) or when you can guarantee a clean shutdown via `Db::close()` / `DbReader::close()`.
`WriteOnInsertion` writes entries to disk on insert. At `close()` time, in-memory entries already have disk copies, so the shutdown flush only waits for the current queue to purge.
Set the policy via `HybridCacheBuilder::with_policy()` before calling `.storage()`:
```rust
use foyer::{
BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, HybridCacheBuilder,
HybridCachePolicy, PsyncIoEngineConfig,
};
let cache = HybridCacheBuilder::new()
// ...
.with_policy(HybridCachePolicy::WriteOnInsertion)
.storage()
// ...
.build()
.await
.unwrap();
```
## Shutdown
[Section titled “Shutdown”](#shutdown)
If the process exits without calling `close()` (`SIGKILL`, panic, or dropping the `Db` without closing), the in-memory tier is lost. Only entries that Foyer wrote to disk during normal operation survive.
A cache passed to `with_db_cache()` is owned by you, not by SlateDB: `Db::close()` and `DbReader::close()` will *not* close it. This makes it safe to share one cache across multiple databases — closing one database no longer shuts down the disk engine for the others. It also means the shutdown flush is your responsibility: call `DbCache::close()` on the cache yourself, after closing every `Db` and `DbReader` that uses it.
Caution
Always call `Db::close()` / `DbReader::close()` before shutdown, and then close the cache you passed in with `DbCache::close()`. Dropping without closing races with tokio runtime teardown and will lose in-memory cached entries.
## Monitoring
[Section titled “Monitoring”](#monitoring)
Foyer exposes internal metrics through `with_metrics_registry()` on the builder. Two counters are especially important to know whether you are dropping entries without tiering to disk:
| Counter | Meaning |
| -------------------------------- | ------------------------------------------------- |
| `storage_queue_channel_overflow` | Entries dropped because the submit queue was full |
| `storage_queue_buffer_overflow` | Entries dropped because the IO buffer was full |
If either counter is climbing, increase `with_flushers()`, `with_buffer_pool_size()`, or `with_submit_queue_size_threshold()` on your `BlockEngineConfig`.
# Logging
> Learn how to configure logging and tracing in SlateDB
## Tracing in SlateDB
[Section titled “Tracing in SlateDB”](#tracing-in-slatedb)
SlateDB uses the [`tracing`](https://github.com/tokio-rs/tracing) library for logging and diagnostics. This provides structured, contextual logging that helps debug and monitor your database operations.
Here’s a basic example showing how to consume tracing logs with `tracing_subscriber` and SlateDB:
main.rs
```rust
use slatedb::{bytes::Bytes, object_store::memory::InMemory, Db};
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing subscriber to see the logs
tracing_subscriber::fmt::init();
// Setup
let object_store = Arc::new(InMemory::new());
let db = Db::open("/tmp/slatedb_tracing_subscriber", object_store).await?;
// Put
let key = b"test_key";
let value = b"test_value";
db.put(key, value).await?;
// Get
assert_eq!(db.get(key).await?, Some(Bytes::from_static(value)));
// Delete
db.delete(key).await?;
assert!(db.get(key).await?.is_none());
// Close
db.close().await?;
Ok(())
}
```
# Metrics
> How to collect and export SlateDB metrics using the MetricsRecorder trait
SlateDB uses a recorder-based metrics system. You provide an implementation of the `MetricsRecorder` trait and SlateDB pushes metrics to it. If no recorder is configured, all metric operations are silently discarded with nearly no overhead.
## Configuring a recorder
[Section titled “Configuring a recorder”](#configuring-a-recorder)
Pass your recorder to `DbBuilder`:
```rust
use slatedb::{config::MetricLevel, Db, Settings};
use slatedb::object_store::memory::InMemory;
use slatedb_common::metrics::MetricsRecorder;
use std::sync::Arc;
let recorder: Arc = /* your implementation */;
let settings = Settings {
metric_level: MetricLevel::Debug,
..Settings::default()
};
let db = Db::builder("my_db", Arc::new(InMemory::new()))
.with_settings(settings)
.with_metrics_recorder(recorder)
.build()
.await
.unwrap();
```
### Metric levels
[Section titled “Metric levels”](#metric-levels)
`MetricLevel` controls which metrics are registered. Metrics below the configured level are replaced with no-op handles when they are registered.
`MetricLevel::Info` is the default and registers standard operational metrics. `MetricLevel::Debug` also registers debug-level metrics when SlateDB defines them. Configure the level with `Settings::metric_level`.
## The MetricsRecorder trait
[Section titled “The MetricsRecorder trait”](#the-metricsrecorder-trait)
`MetricsRecorder` has four registration methods, one per metric type:
```rust
pub trait MetricsRecorder: Send + Sync {
fn register_counter(&self, name: &str, description: &str, labels: &[(&str, &str)])
-> Arc;
fn register_gauge(&self, name: &str, description: &str, labels: &[(&str, &str)])
-> Arc;
fn register_up_down_counter(&self, name: &str, description: &str, labels: &[(&str, &str)])
-> Arc;
fn register_histogram(&self, name: &str, description: &str, labels: &[(&str, &str)], boundaries: &[f64])
-> Arc;
}
```
Each method returns a handle that SlateDB calls on the hot path:
| Trait | Method | Value type | Use case |
| ----------------- | ---------------- | ------------------------------- | ----------------------------- |
| `CounterFn` | `increment(u64)` | Monotonic | Request counts, bytes written |
| `GaugeFn` | `set(i64)` | Absolute | Memory usage, queue depth |
| `UpDownCounterFn` | `increment(i64)` | Additive (positive or negative) | In-flight compactions |
| `HistogramFn` | `record(f64)` | Distribution | Latency, I/O sizes |
`GaugeFn` and `UpDownCounterFn` are separate following OpenTelemetry semantics: gauges represent point-in-time snapshots (`set`), while up-down counters track additive changes (`increment` with positive or negative values).
Labels are fixed at registration time as `&[(&str, &str)]` key-value pairs. SlateDB uses labels to collapse related metrics into a single name (e.g. `slatedb.db.request_count` with an `op` label instead of separate counters per operation).
## Available metrics
[Section titled “Available metrics”](#available-metrics)
All metric names use dot-separated notation: `slatedb..`.
### Database (`slatedb.db.*`)
[Section titled “Database (slatedb.db.\*)”](#database-slatedbdb)
| Name | Type | Labels | Description |
| -------------------------------------------- | ------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `slatedb.db.request_count` | counter | `op`: get, scan, flush | Number of DB requests |
| `slatedb.db.write_ops` | counter | | Write operations |
| `slatedb.db.write_batch_count` | counter | | Write batches |
| `slatedb.db.backpressure_count` | counter | | Backpressure events |
| `slatedb.db.l0_stall_count` | counter | `type`: num\_ssts, num\_ssts\_per\_key | L0 flush dispatch stalls by cause |
| `slatedb.db.immutable_memtable_flushes` | counter | | Immutable memtable flushes |
| `slatedb.db.total_mem_size_bytes` | gauge | | Total memory usage |
| `slatedb.db.l0_sst_count` | gauge | | L0 SST count summed across all segment trees |
| `slatedb.db.segment_max_l0_sst_count` | gauge | | Maximum L0 SST count across all segments (useful for detecting backpressure on specific segments) |
| `slatedb.db.sorted_run_count` | gauge | | Number of sorted runs across all trees (root tree plus each named segment tree per RFC-0024) |
| `slatedb.db.sst_view_count` | gauge | | Total number of SST views including L0 views and every sorted-run SST view across all trees |
| `slatedb.db.sst_count` | gauge | | Number of distinct physical SSTables (deduplicates sst\_view\_count by SsTableId, so sst\_count is at most sst\_view\_count) |
| `slatedb.db.external_db_count` | gauge | | Number of external databases referenced in the manifest |
| `slatedb.db.sst_filter_false_positive_count` | counter | | Bloom filter false positives |
| `slatedb.db.sst_filter_positive_count` | counter | | Bloom filter positives |
| `slatedb.db.sst_filter_negative_count` | counter | | Bloom filter negatives |
### Write-Ahead Log (WAL) (`slatedb.wal.*`)
[Section titled “Write-Ahead Log (WAL) (slatedb.wal.\*)”](#write-ahead-log-wal-slatedbwal)
| Name | Type | Labels | Description |
| ---------------------------------------- | ------- | ------ | ------------------------- |
| `slatedb.wal.wal_buffer_flushes` | counter | | WAL buffer flushes |
| `slatedb.wal.wal_buffer_flush_requests` | counter | | WAL buffer flush requests |
| `slatedb.wal.wal_buffer_estimated_bytes` | gauge | | Estimated WAL buffer size |
### Block cache (`slatedb.db_cache.*`)
[Section titled “Block cache (slatedb.db\_cache.\*)”](#block-cache-slatedbdb_cache)
| Name | Type | Labels | Description |
| ------------------------------- | ------- | -------------------------------------------------------------------- | -------------- |
| `slatedb.db_cache.access_count` | counter | `entry_kind`: filter, index, data\_block, stats; `result`: hit, miss | Cache accesses |
| `slatedb.db_cache.error_count` | counter | | Cache errors |
### Compactor (`slatedb.compactor.*`)
[Section titled “Compactor (slatedb.compactor.\*)”](#compactor-slatedbcompactor)
| Name | Type | Labels | Description |
| -------------------------------------------------- | ----------------- | ------ | ------------------------------------ |
| `slatedb.compactor.bytes_compacted` | counter | | Total bytes compacted |
| `slatedb.compactor.epoch` | gauge | | Current compactor epoch |
| `slatedb.compactor.last_compaction_timestamp_sec` | gauge | | Last compaction time (epoch seconds) |
| `slatedb.compactor.running_compactions` | up\_down\_counter | | Currently running compactions |
| `slatedb.compactor.total_bytes_being_compacted` | gauge | | Bytes in active compactions |
| `slatedb.compactor.total_throughput_bytes_per_sec` | gauge | | Compaction throughput |
### Garbage collector (`slatedb.gc.*`)
[Section titled “Garbage collector (slatedb.gc.\*)”](#garbage-collector-slatedbgc)
| Name | Type | Labels | Description |
| -------------------------- | ------- | ------------------------------------------------- | ----------------- |
| `slatedb.gc.deleted_count` | counter | `resource`: manifest, wal, compacted, compactions | Deleted resources |
| `slatedb.gc.count` | counter | | GC runs |
### Memtable flush (`slatedb.memtable_flush.*`)
[Section titled “Memtable flush (slatedb.memtable\_flush.\*)”](#memtable-flush-slatedbmemtable_flush)
| Name | Type | Labels | Description |
| ------------------------------------------------- | ------- | ------ | -------------------- |
| `slatedb.memtable_flush.memtable_freeze_count` | counter | | Frozen memtables |
| `slatedb.memtable_flush.checkpoint_request_count` | counter | | Checkpoint requests |
| `slatedb.memtable_flush.flush_request_count` | counter | | Flush requests |
| `slatedb.memtable_flush.l0_upload_count` | counter | | Completed L0 uploads |
| `slatedb.memtable_flush.l0_flush_count` | counter | | Completed L0 flushes |
| `slatedb.memtable_flush.manifest_refresh_count` | counter | | Manifest refreshes |
### Object store (`slatedb.object_store.*`)
[Section titled “Object store (slatedb.object\_store.\*)”](#object-store-slatedbobject_store)
All object store metrics carry four labels:
| Label | Values |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `component` | db, reader, gc, compactor |
| `store_type` | main, wal |
| `op` | get, put, delete |
| `api` | get, get\_range, get\_ranges, head, list, list\_with\_offset, list\_with\_delimiter, put, multipart\_init, multipart\_part, multipart\_complete, delete |
| Name | Type | Description |
| ----------------------------------------------- | --------- | ----------------------------------- |
| `slatedb.object_store.request_count` | counter | Total API calls (success and error) |
| `slatedb.object_store.error_count` | counter | Failed API calls |
| `slatedb.object_store.request_duration_seconds` | histogram | Per-request latency |
The instrumented store sits beneath the retrying layer, so each retry attempt is counted separately. It sits above the optional object-store cache, so these metrics count the calls SlateDB makes into the cache: a cache hit is counted as one request even though it never reaches the remote store, and the requests the cache issues against the remote store to fill a miss are not counted. Use the cache metrics below to tell hits from misses.
### Object store cache (`slatedb.object_store_cache.*`)
[Section titled “Object store cache (slatedb.object\_store\_cache.\*)”](#object-store-cache-slatedbobject_store_cache)
| Name | Type | Labels | Description |
| ---------------------------------------------- | ------- | ------ | ------------------- |
| `slatedb.object_store_cache.part_hit_count` | counter | | Cache part hits |
| `slatedb.object_store_cache.part_access_count` | counter | | Cache part accesses |
| `slatedb.object_store_cache.cache_keys` | gauge | | Cached keys |
| `slatedb.object_store_cache.cache_bytes` | gauge | | Cached bytes |
| `slatedb.object_store_cache.evicted_keys` | counter | | Evicted keys |
| `slatedb.object_store_cache.evicted_bytes` | counter | | Evicted bytes |
When the cache comes from `object_store_cache_options`, it records to the recorder configured on the database builder. When you build a `CachedObjectStore` yourself, it records to a no-op recorder unless you pass one with `with_metrics_recorder`, so these metrics stay empty until you do.
These are passed to `register_histogram` as the `boundaries` parameter.
## Using DefaultMetricsRecorder
[Section titled “Using DefaultMetricsRecorder”](#using-defaultmetricsrecorder)
`slatedb-common` ships a `DefaultMetricsRecorder` backed by atomics. It’s useful in tests and in some production scenarios that don’t have a dedicated metrics backend (e.g. periodic logging):
```rust
use slatedb_common::metrics::DefaultMetricsRecorder;
use std::sync::Arc;
let recorder = Arc::new(DefaultMetricsRecorder::new());
let db = Db::builder("test_db", object_store)
.with_metrics_recorder(recorder.clone())
.build()
.await
.unwrap();
// ... perform operations ...
let snapshot = recorder.snapshot();
for metric in snapshot.all() {
println!("{}: {:?} {:?}", metric.name, metric.labels, metric.value);
}
```
## Using the metrics-rs facade
[Section titled “Using the metrics-rs facade”](#using-the-metrics-rs-facade)
The [`metrics`](https://crates.io/crates/metrics) crate is a lightweight facade (similar to `log` for logging). Install any compatible exporter (e.g. `metrics-exporter-prometheus`) and wire it up with a thin adapter:
```rust
use metrics::{counter, describe_counter, describe_gauge, describe_histogram, gauge, histogram};
struct MetricsRsCounter(metrics::Counter);
impl CounterFn for MetricsRsCounter {
fn increment(&self, value: u64) {
self.0.increment(value);
}
}
// Gauge, UpDownCounter, and Histogram wrappers follow the same pattern.
pub struct MetricsRsRecorder;
impl MetricsRecorder for MetricsRsRecorder {
fn register_counter(
&self, name: &str, desc: &str, labels: &[(&str, &str)]
) -> Arc {
let labels: Vec<(String, String)> = labels.iter()
.map(|(k, v)| (k.to_string(), v.to_string())).collect();
describe_counter!(name.to_string(), desc.to_string());
Arc::new(MetricsRsCounter(counter!(name.to_string(), &labels)))
}
// ... other methods follow the same pattern
}
```
`MetricsRsRecorder` is stateless since the `metrics` facade manages all state globally.
## Implementing a Prometheus recorder
[Section titled “Implementing a Prometheus recorder”](#implementing-a-prometheus-recorder)
A Prometheus recorder maps each `register_*` call to a labeled time series within a `Family`:
```rust
use prometheus_client::metrics::counter::Counter as PromCounter;
use prometheus_client::metrics::family::Family;
use prometheus_client::registry::Registry;
type Labels = Vec<(String, String)>;
// Thin wrapper that bridges prometheus-client's Counter to SlateDB's CounterFn.
struct PromCounterHandle(PromCounter);
impl CounterFn for PromCounterHandle {
fn increment(&self, value: u64) {
self.0.inc_by(value);
}
}
struct PrometheusRecorder {
registry: Mutex,
counters: Mutex>>,
// ... gauges, histograms
}
impl MetricsRecorder for PrometheusRecorder {
fn register_counter(
&self, name: &str, desc: &str, labels: &[(&str, &str)]
) -> Arc {
let mut families = self.counters.lock().unwrap();
let family = families.entry(name.to_string()).or_insert_with(|| {
let f = Family::::default();
self.registry.lock().unwrap().register(name, desc, f.clone());
f
});
let labels: Labels = labels.iter()
.map(|(k, v)| (k.to_string(), v.to_string())).collect();
let counter = family.get_or_create(&labels).clone();
Arc::new(PromCounterHandle(counter))
}
// ... other methods follow the same pattern
}
```
## Implementing an OpenTelemetry recorder
[Section titled “Implementing an OpenTelemetry recorder”](#implementing-an-opentelemetry-recorder)
A recorder that maps directly to the OpenTelemetry SDK instruments:
```rust
use opentelemetry::metrics::MeterProvider;
use opentelemetry::KeyValue;
use opentelemetry_sdk::metrics::SdkMeterProvider;
struct OtelRecorder {
meter: opentelemetry::metrics::Meter,
}
impl OtelRecorder {
fn new(provider: &SdkMeterProvider) -> Self {
Self { meter: provider.meter("slatedb") }
}
}
impl MetricsRecorder for OtelRecorder {
fn register_counter(
&self, name: &str, desc: &str, labels: &[(&str, &str)]
) -> Arc {
let attrs: Vec = labels.iter()
.map(|(k, v)| KeyValue::new(k.to_string(), v.to_string()))
.collect();
let counter = self.meter.u64_counter(name.to_string())
.with_description(desc.to_string()).build();
Arc::new(OtelCounter { counter, attrs })
}
// ... other methods follow the same pattern
}
```
# Tuning
> Learn about SlateDB's performance characteristics and tuning
SlateDB’s performance characteristics are primarily determined by the object store being used. This page provides guidance on expected performance and how to tune SlateDB for your use case.
## Latency
[Section titled “Latency”](#latency)
SlateDB’s write latency is dominated by object store PUT operations. The following table shows expected latencies for different object stores:
| Object Store | Expected Write Latency | Notes |
| -------------------- | ---------------------- | -------------------------------- |
| S3 Standard | 50-100ms | Network latency dominates |
| S3 Express One Zone | 5-10ms | Single-digit millisecond latency |
| Google Cloud Storage | 50-100ms | Network latency dominates |
| Azure Blob Storage | 50-100ms | Network latency dominates |
| MinIO | 5-20ms | Depends on network and disk |
Read latency dominated by the database’s working set size and read pattern:
1. Working sets that fit in local (memorya and disk) caches will respond very quickly (< 1ms).
2. Read patterns that do sequential reads will respond very quickly (<1ms) since SST blocks are pre-fetched sequentially and cached locally.
3. Read patterns that access keys that are (lexicographically) close to each other will respond very quickly (< 1ms) since blocks are cached locally after the first read.
4. Reads that access data across the keyspace, and whose dataset is larger than a single machine’s memory or disk will, are more likely to see latency spikes similar to object storage latency levels (50-100ms for S3 standard). Such workloads can still work well, but require more tuning, partitioning, and so on.
## Throughput
[Section titled “Throughput”](#throughput)
SlateDB’s write throughput is limited by:
1. **Object store PUT rate limits**: Most object stores limit PUT operations to 3,500 requests per second per prefix.
2. **Network bandwidth**: The time it takes to upload SSTs to object storage.
Read throughput is limited by:
1. **Object store GET rate limits**: Most object stores limit GET operations to 5,500 requests per second per prefix.
2. **Network bandwidth**: The time it takes to download SSTs from object storage.
3. **Disk I/O**: The time it takes to read SSTs from disk when object store caching or [Foyer hybrid caching](https://foyer.rs/docs/tutorial/hybrid-cache) are enabled.
## Tuning
[Section titled “Tuning”](#tuning)
SlateDB provides several configuration options to tune performance. See [slatedb::config](https://docs.rs/slatedb/latest/slatedb/config/) for the current tuning surface.
### Write Performance
[Section titled “Write Performance”](#write-performance)
* **`flush_interval`**: How long SlateDB waits before flushing the mutable WAL to object storage. Lower values reduce durable write latency but increase object-store PUT frequency.
* **`l0_sst_size_bytes`**: The size of L0 SSTs. Larger SSTs provide better compression but take longer to upload.
* **`max_unflushed_bytes`**: The total amount of unflushed WAL and memtable data allowed in memory before SlateDB applies backpressure to writers.
* **`l0_max_ssts`**: The maximum number of L0 SSTs SlateDB allows before it stops flushing memtables and waits for compaction to catch up.
### Read Performance
[Section titled “Read Performance”](#read-performance)
* **`min_filter_keys`**: SlateDB only builds filters for SSTs with at least this many keys. Raising it avoids filter overhead on small SSTs.
* **`DbBuilder::with_filter_policies(...)`**: Configures the filter policies used for SST construction and evaluation. Defaults to a single bloom filter with 10 bits per key. Pass `BloomFilterPolicy::new(bits_per_key)` to tune bloom filter density (higher values reduce false positives but increase filter size), or supply a custom `FilterPolicy` (e.g., prefix bloom). See the `with_filter_policies` docs on `DbBuilder`, `CompactorBuilder`, and `DbReaderBuilder`.
* **`object_store_cache_options.root_folder`**: Enables the local disk cache for object-store data when set.
* **`object_store_cache_options.part_size_bytes`** and **`object_store_cache_options.preload_disk_cache_on_startup`**: Tune how the disk cache is chunked and optionally warmed on startup.
* **`DbBuilder::with_sst_block_size(...)`**: Sets SST block size. Smaller blocks can help point lookups; larger blocks usually favor scans and compression.
### Memory Usage
[Section titled “Memory Usage”](#memory-usage)
* **`max_unflushed_bytes`**: The primary writer-side memory bound for buffered but not yet flushed data.
# Connect SlateDB to Azure Blob Storage
> Learn how to connect SlateDB to Azure Blob Storage
This tutorial shows you how to use SlateDB on Azure Blob Storage (ABS). You would need an ABS account to complete the tutorial.
## Setup
[Section titled “Setup”](#setup)
[Install](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) the Azure CLI.
## Create Storage account
[Section titled “Create Storage account”](#create-storage-account)
The following steps creates a storage account and list the keys. This section can be skipped if you already have a storage account created.
```bash
# Set storage account names
StorageAccountName=
ContainerName=
ResourceGroupName=
# Login
az login
# Create Resource Group in the default subscription.
az group create --name $ResourceGroupName --location westus
# Create Azure Storage account.
az storage account create --name $StorageAccountName --resource-group $ResourceGroupName --location westus --sku Standard_LRS
# Create a storage container
az storage container create --name $ContainerName --account-name $StorageAccountName
# Get the keys.
az storage account keys list --resource-group $ResourceGroupName --account-name $StorageAccountName
```
## Create a project
[Section titled “Create a project”](#create-a-project)
Let’s start by creating a new Rust project:
```bash
cargo init slatedb-abs
cd slatedb-abs
```
## Add dependencies
[Section titled “Add dependencies”](#add-dependencies)
Now add SlateDB and the required dependencies to your `Cargo.toml`:
```bash
cargo add slatedb tokio --features tokio/macros,tokio/rt-multi-thread
cargo add object-store --features object-store/azure
cargo add anyhow
```
Note
If you see “`object_store::path::Path` and `object_store::path::Path` have similar names, but are actually distinct types”, you might need to pin the `object_store` version to match `slatedb`’s `object_store` version. SlateDB also exports `slatedb::object_store` for convenience, if you’d rather use that.
## Write some code
[Section titled “Write some code”](#write-some-code)
This code demonstrates puts that wait for results to be durable, and then puts that do not wait.
main.rs
```rust
use object_store::azure::MicrosoftAzureBuilder;
use slatedb::{config::PutOptions, Db};
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// construct azure blob object store.
let blob_store = Arc::new(
MicrosoftAzureBuilder::new()
.with_account("")
.with_access_key("")
.with_container_name("")
.build()?,
);
println!("Opening the db");
let path = "/tmp/slatedb_azure_blob_storage";
let db = Db::open(path, blob_store.clone()).await?;
// Put a value and wait for the flush.
println!("Writing a value and waiting for flush");
db.put(b"k1", b"value1").await?;
println!("{:?}", db.get(b"k1").await?);
// Put 1000 keys, do not wait for it to be durable
println!("Writing 1000 keys without waiting for flush");
let write_options = slatedb::config::WriteOptions {
..Default::default()
};
for i in 0..1000 {
db.put_with_options(
format!("key{}", i).as_bytes(),
format!("value{}", i).as_bytes(),
&PutOptions::default(),
&write_options,
)
.await?;
}
// flush to make the writes durable.
println!("Flushing the writes and closing the db");
db.flush().await?;
db.close().await?;
// reopen the db and read the value.
println!("Reopening the db");
let db_reopened = Db::open(path, blob_store).await?;
println!("Reading the value from the reopened db");
// read 20 keys
for i in 0..20 {
println!(
"{:?}",
db_reopened.get(format!("key{}", i).as_bytes()).await
);
}
db_reopened.close().await?;
Ok(())
}
```
## Check the blob contents
[Section titled “Check the blob contents”](#check-the-blob-contents)
```bash
az storage blob list --container-name $ContainerName --account-name $StorageAccountName --prefix "tmp/slatedb_azure_blob_storage/" --delimiter "/" --output table
compactions/
wal/
manifest/
```
SlateDB uses four top-level directories over time:
* `manifest`: Contains the manifest files. Manifest files define the state of the DB, including the set of SSTs that are part of the DB.
* `wal`: Contains the write-ahead log files.
* `compacted`: Contains the compacted SST files (may not appear in short examples).
* `compactions`: Contains the compaction-state snapshots.
Let’s check the `wal` folder.
```bash
az storage blob list --container-name $ContainerName --account-name $StorageAccountName --prefix "tmp/slatedb_azure_blob_storage/wal/" --delimiter "/" --output table
Name Blob Type Blob Tier Length Content Type Last Modified Snapshot
----------------------------------------------------------- ----------- ----------- -------- ------------------------ ------------------------- ----------
tmp/slatedb_azure_blob_storage/wal/00000000000000000001.sst BlockBlob Hot 64 application/octet-stream 2024-09-07T01:15:49+00:00
tmp/slatedb_azure_blob_storage/wal/00000000000000000002.sst BlockBlob Hot 138 application/octet-stream 2024-09-07T01:15:49+00:00
tmp/slatedb_azure_blob_storage/wal/00000000000000000003.sst BlockBlob Hot 23388 application/octet-stream 2024-09-07T01:15:49+00:00
tmp/slatedb_azure_blob_storage/wal/00000000000000000004.sst BlockBlob Hot 64 application/octet-stream 2024-09-07T01:15:50+00:00
```
Each of these SST files is a write-ahead log (WAL) file, and each WAL file can contain many `RowEntry` values. They get flushed based on the `flush_interval` config or when `flush` is called explicitly.
```plaintext
```
# Checkpoint and Restore
> Learn how to checkpoint and restore SlateDB databases
## Creating a Checkpoint
[Section titled “Creating a Checkpoint”](#creating-a-checkpoint)
To create a checkpoint, use the `create_checkpoint` function provided by SlateDB.\
This operation captures a consistent and durable view of the database state, anchored to a specific manifest version.
main.rs
```rust
use slatedb::admin::AdminBuilder;
use slatedb::config::CheckpointOptions;
use slatedb::object_store::memory::InMemory;
use slatedb::Db;
use std::{sync::Arc, time::Duration};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let path = "/tmp/slatedb-checkpoint";
let object_store = Arc::new(InMemory::new());
let db = Db::open(path, object_store.clone()).await?;
db.close().await?;
let admin = AdminBuilder::new(path, object_store).build();
let result = admin
.create_detached_checkpoint(&CheckpointOptions {
lifetime: Some(Duration::from_secs(3600)), // expires in 1 hour
..Default::default()
})
.await?;
println!(
"Created checkpoint: ID = {}, Manifest = {}",
result.id, result.manifest_id
);
Ok(())
}
```
## Refreshing a Checkpoint
[Section titled “Refreshing a Checkpoint”](#refreshing-a-checkpoint)
To extend the lifetime of an existing checkpoint, use `Admin::refresh_checkpoint`. This updates the checkpoint’s expiration time, ensuring that the referenced SSTs remain protected from garbage collection for the specified duration.
main.rs
```rust
use slatedb::admin::AdminBuilder;
use slatedb::config::CheckpointOptions;
use slatedb::object_store::memory::InMemory;
use slatedb::Db;
use std::{sync::Arc, time::Duration};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let path = "/tmp/slatedb-checkpoint";
let object_store = Arc::new(InMemory::new());
let db = Db::open(path, object_store.clone()).await?;
db.close().await?;
let admin = AdminBuilder::new(path, object_store).build();
let result = admin
.create_detached_checkpoint(&CheckpointOptions {
lifetime: Some(Duration::from_secs(3600)), // expires in 1 hour
..Default::default()
})
.await?;
admin
.refresh_checkpoint(result.id, Some(Duration::from_secs(7200)))
.await?;
println!(
"Checkpoint refreshed with extended 2-hour lifetime. ID: {}",
result.id
);
Ok(())
}
```
## Deleting a Checkpoint
[Section titled “Deleting a Checkpoint”](#deleting-a-checkpoint)
To remove a checkpoint, use `Admin::delete_checkpoint`. This deletes the checkpoint metadata from the manifest, allowing the garbage collector to eventually reclaim any unreferenced SST files associated with that checkpoint.
main.rs
```rust
use slatedb::admin::AdminBuilder;
use slatedb::config::CheckpointOptions;
use slatedb::object_store::memory::InMemory;
use slatedb::Db;
use std::{sync::Arc, time::Duration};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let path = "/tmp/slatedb-checkpoint";
let object_store = Arc::new(InMemory::new());
let db = Db::open(path, object_store.clone()).await?;
db.close().await?;
let admin = AdminBuilder::new(path, object_store).build();
let result = admin
.create_detached_checkpoint(&CheckpointOptions {
lifetime: Some(Duration::from_secs(3600)), // expires in 1 hour
..Default::default()
})
.await?;
admin.delete_checkpoint(result.id).await?;
println!("Deleted checkpoint: ID = {}", result.id);
Ok(())
}
```
# Connect SlateDB to Google Cloud Storage
> Learn how to connect SlateDB to Google Cloud Storage
This tutorial shows you how to use SlateDB on Google Cloud Storage (GCS). You would need a GCS account to complete the tutorial.
## Setup
[Section titled “Setup”](#setup)
[Install](https://cloud.google.com/sdk/docs/install) the Google Cloud SDK (gcloud CLI).
## Create Storage bucket and Service Account
[Section titled “Create Storage bucket and Service Account”](#create-storage-bucket-and-service-account)
The following steps create a storage bucket and generate service account credentials. This section can be skipped if you already have a bucket and service account key.
```bash
# Set bucket name and project ID
BucketName=
ProjectID=
# Login to Google Cloud
gcloud auth login
# Set the default project
gcloud config set project $ProjectID
# Create a storage bucket (if needed)
gcloud storage buckets create gs://$BucketName --location=us-central1
# Create a service account for authentication
ServiceAccountName=slatedb-gcs-test
gcloud iam service-accounts create $ServiceAccountName \
--display-name="SlateDB GCS Test Service Account"
# Grant the service account access to the project (for storage operations)
gcloud projects add-iam-policy-binding $ProjectID \
--member="serviceAccount:$ServiceAccountName@$ProjectID.iam.gserviceaccount.com" \
--role="roles/storage.objectAdmin"
# Create and download a key for the service account
gcloud iam service-accounts keys create ~/slatedb-gcs-key.json \
--iam-account=$ServiceAccountName@$ProjectID.iam.gserviceaccount.com
```
Now you have your service account key file ready to use.
## Create a project
[Section titled “Create a project”](#create-a-project)
Let’s start by creating a new Rust project:
```bash
cargo init slatedb-gcs
cd slatedb-gcs
```
## Add dependencies
[Section titled “Add dependencies”](#add-dependencies)
Now add SlateDB and the required dependencies to your `Cargo.toml`:
```bash
cargo add slatedb tokio --features tokio/macros,tokio/rt-multi-thread
cargo add object-store --features object-store/gcp
cargo add anyhow
```
Note
If you see “`object_store::path::Path` and `object_store::path::Path` have similar names, but are actually distinct types”, you might need to pin the `object_store` version to match `slatedb`’s `object_store` version. SlateDB also exports `slatedb::object_store` for convenience, if you’d rather use that.
## Write some code
[Section titled “Write some code”](#write-some-code)
This code demonstrates puts that wait for results to be durable, and then puts that do not wait.
main.rs
```rust
use object_store::gcp::GoogleCloudStorageBuilder;
use slatedb::{config::PutOptions, Db};
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// construct google cloud storage object store.
let gcs_store = Arc::new(
GoogleCloudStorageBuilder::new()
.with_service_account_path("")
.with_bucket_name("")
.build()?,
);
println!("Opening the db");
let path = "/tmp/slatedb_google_cloud_storage";
let db = Db::open(path, gcs_store.clone()).await?;
// Put a value and wait for the flush.
println!("Writing a value and waiting for flush");
db.put(b"k1", b"value1").await?;
println!("{:?}", db.get(b"k1").await?);
// Put 1000 keys, do not wait for it to be durable
println!("Writing 1000 keys without waiting for flush");
let write_options = slatedb::config::WriteOptions {
..Default::default()
};
for i in 0..1000 {
db.put_with_options(
format!("key{}", i).as_bytes(),
format!("value{}", i).as_bytes(),
&PutOptions::default(),
&write_options,
)
.await?;
}
// flush to make the writes durable.
println!("Flushing the writes and closing the db");
db.flush().await?;
db.close().await?;
// reopen the db and read the value.
println!("Reopening the db");
let db_reopened = Db::open(path, gcs_store).await?;
println!("Reading the value from the reopened db");
// read 20 keys
for i in 0..20 {
println!(
"{:?}",
db_reopened.get(format!("key{}", i).as_bytes()).await
);
}
db_reopened.close().await?;
Ok(())
}
```
## Check the bucket contents
[Section titled “Check the bucket contents”](#check-the-bucket-contents)
```bash
gcloud storage ls gs://$BucketName/tmp/slatedb_google_cloud_storage/
gs://$BucketName/tmp/slatedb_google_cloud_storage/compactions/
gs://$BucketName/tmp/slatedb_google_cloud_storage/manifest/
gs://$BucketName/tmp/slatedb_google_cloud_storage/wal/
```
SlateDB uses four top-level directories over time:
* `manifest`: Contains the manifest files. Manifest files define the state of the DB, including the set of SSTs that are part of the DB.
* `wal`: Contains the write-ahead log files.
* `compacted`: Contains the compacted SST files (may not appear in short examples).
* `compactions`: Contains the compaction-state snapshots.
Let’s check the `wal` folder:
```bash
gcloud storage ls -l gs://$BucketName/tmp/slatedb_google_cloud_storage/wal/
74 2025-10-05T14:26:52Z gs://sibasishtest/tmp/slatedb_google_cloud_storage/wal/00000000000000000001.sst
163 2025-10-05T14:26:56Z gs://sibasishtest/tmp/slatedb_google_cloud_storage/wal/00000000000000000002.sst
1047 2025-10-05T14:26:57Z gs://sibasishtest/tmp/slatedb_google_cloud_storage/wal/00000000000000000003.sst
36483 2025-10-05T14:26:58Z gs://sibasishtest/tmp/slatedb_google_cloud_storage/wal/00000000000000000004.sst
74 2025-10-05T14:27:02Z gs://sibasishtest/tmp/slatedb_google_cloud_storage/wal/00000000000000000005.sst
74 2025-10-05T15:42:19Z gs://sibasishtest/tmp/slatedb_google_cloud_storage/wal/00000000000000000006.sst
```
Each of these SST files is a write-ahead log (WAL) file, and each WAL file can contain many `RowEntry` values. They get flushed based on the `flush_interval` config or when `flush` is called explicitly.
# Range Scans
> Learn how to scan and seek over ranges of keys in SlateDB
SlateDB stores key-value pairs sorted by key in lexicographic byte order. A range scan creates an iterator over that sorted keyspace, so you can read a bounded slice, read every entry, or jump forward within an iterator.
## Scan all entries
[Section titled “Scan all entries”](#scan-all-entries)
Use `Db::scan(..)` to iterate over the full keyspace. Range scans follow bytewise key order, so `test_key10` sorts before `test_key2`. If numeric order matters, use fixed-width encodings such as `test_key02` and `test_key10`.
```rust
let mut entries = db.scan(..).await?;
while let Some(kv) = entries.next().await? {
println!("{:?} = {:?}", kv.key, kv.value);
}
```
## Choose range bounds
[Section titled “Choose range bounds”](#choose-range-bounds)
Pass a Rust range to `scan` when you know the keys you want. A bounded range like `"test_key2".."test_key4"` includes the start bound and excludes the end bound.
```rust
db.scan("test_key2".."test_key4").await?; // excludes test_key4
db.scan("test_key2"..="test_key4").await?; // includes test_key4
db.scan("test_key2"..).await?; // test_key2 and later
db.scan(.."test_key4").await?; // before test_key4
db.scan(..).await?; // all keys
```
## Seek within a scan
[Section titled “Seek within a scan”](#seek-within-a-scan)
Call `seek` on an iterator to fast-forward to the first key equal to or greater than the requested key. The seek target must stay inside the original scan range and must move forward from the iterator’s current position.
```rust
let mut iter = db.scan(..).await?;
iter.seek(b"test_key3").await?;
let kv = iter.next().await?.unwrap();
assert_eq!(kv.key.as_ref(), b"test_key3");
```
## Scan for prefixes
[Section titled “Scan for prefixes”](#scan-for-prefixes)
If your access pattern is “all keys that start with this prefix,” use `scan_prefix` instead of manually building a start and end range. The second argument restricts the scan to a range of key *suffixes* relative to the prefix; pass `..` to scan the prefix’s entire keyspace. You can pass ordinary Rust range syntax directly, including suffix ranges over byte slices or `Vec`.
```rust
let mut iter = db.scan_prefix(b"test_key", ..).await?; // all keys with the prefix
let mut iter = db.scan_prefix(b"test_key", b"2"..).await?; // test_key2 onward
```
## Complete example
[Section titled “Complete example”](#complete-example)
main.rs
```rust
use slatedb::object_store::memory::InMemory;
use slatedb::{Db, Error};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Error> {
let object_store = Arc::new(InMemory::new());
let db = Db::open("/tmp/slatedb_range_scans", object_store).await?;
db.put(b"test_key1", b"test_value1").await?;
db.put(b"test_key2", b"test_value2").await?;
db.put(b"test_key3", b"test_value3").await?;
db.put(b"test_key4", b"test_value4").await?;
db.put(b"test_key10", b"test_value10").await?;
// Scan over unbound range
let mut iter = db.scan(..).await?;
// Scans return bytewise key order
let expected = [
("test_key1", "test_value1"),
("test_key10", "test_value10"),
("test_key2", "test_value2"),
("test_key3", "test_value3"),
("test_key4", "test_value4"),
];
for (expected_key, expected_value) in expected {
let entry = iter.next().await?.unwrap();
assert_eq!(entry.key.as_ref(), expected_key.as_bytes());
assert_eq!(entry.value.as_ref(), expected_value.as_bytes());
}
assert_eq!(iter.next().await?, None);
// Scan over bound range
let mut iter = db.scan("test_key2"..="test_key3").await?;
let kv2 = iter.next().await?.unwrap();
assert_eq!(kv2.key, b"test_key2".as_slice());
assert_eq!(kv2.value, b"test_value2".as_slice());
let kv3 = iter.next().await?.unwrap();
assert_eq!(kv3.key, b"test_key3".as_slice());
assert_eq!(kv3.value, b"test_value3".as_slice());
assert_eq!(iter.next().await?, None);
// Seek ahead to next key
let mut iter = db.scan(..).await?;
let next_key = b"test_key4";
iter.seek(next_key).await?;
let kv4 = iter.next().await?.unwrap();
assert_eq!(kv4.key, b"test_key4".as_slice());
assert_eq!(kv4.value, b"test_value4".as_slice());
assert_eq!(iter.next().await?, None);
// Scan over prefix
let mut prefixed = db.scan_prefix(b"test_key1", ..).await?;
let expected_keys: [&[u8]; 2] = [b"test_key1", b"test_key10"];
for expected_key in expected_keys {
let kv = prefixed.next().await?.unwrap();
assert_eq!(kv.key.as_ref(), expected_key);
}
assert_eq!(prefixed.next().await?, None);
db.close().await?;
Ok(())
}
```
# Connect SlateDB to S3
> Learn how to connect SlateDB to Amazon S3 using LocalStack
This tutorial shows you how to connect SlateDB to S3. We’ll use LocalStack to simulate S3.
## Create a project
[Section titled “Create a project”](#create-a-project)
Let’s start by creating a new Rust project:
```bash
cargo init slatedb-playground
cd slatedb-playground
```
## Add dependencies
[Section titled “Add dependencies”](#add-dependencies)
Now add SlateDB and the required dependencies to your `Cargo.toml`:
```bash
cargo add slatedb tokio --features tokio/macros,tokio/rt-multi-thread
cargo add object-store --features object-store/aws
cargo add anyhow
```
Note
If you see “`object_store::path::Path` and `object_store::path::Path` have similar names, but are actually distinct types”, you might need to pin the `object_store` version to match `slatedb`’s `object_store` version. SlateDB also exports `slatedb::object_store` for convenience, if you’d rather use that.
## Setup
[Section titled “Setup”](#setup)
You will need to have [LocalStack](https://localstack.cloud/) running. You can install it using [Homebrew](https://brew.sh/):
```bash
brew install localstack/tap/localstack-cli
```
```bash
localstack start -d
```
For a more detailed setup, see the [LocalStack documentation](https://docs.localstack.cloud/).
You’ll also need the AWS CLI:
```bash
brew install awscli
```
## Initialize AWS
[Section titled “Initialize AWS”](#initialize-aws)
SlateDB requires a bucket to work with S3.
### Create your S3 bucket:
[Section titled “Create your S3 bucket:”](#create-your-s3-bucket)
```bash
# Create S3 bucket
aws --endpoint-url=http://localhost:4566 s3api create-bucket --bucket slatedb --region us-east-1
```
## Write some code
[Section titled “Write some code”](#write-some-code)
Stick this into your `src/main.rs` file:
main.rs
```rust
use slatedb::Db;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let object_store = Arc::new(
object_store::aws::AmazonS3Builder::new()
// These will be different if you are using real AWS
.with_allow_http(true)
.with_endpoint("http://localhost:4566")
.with_access_key_id("test")
.with_secret_access_key("test")
.with_bucket_name("slatedb")
.with_region("us-east-1")
.build()?,
);
let db = Db::open("/tmp/slatedb_s3_compatible", object_store.clone()).await?;
// Call db.put with a key and a 64 meg value to trigger L0 SST flush
let value: Vec = vec![0; 64 * 1024 * 1024];
db.put(b"k1", value.as_slice()).await?;
db.close().await?;
Ok(())
}
```
Note
Our code example writes a 64 MiB value to object storage to trigger an L0 SST flush. This is just to show the `compacted` directory in your bucket.
## Run the code
[Section titled “Run the code”](#run-the-code)
Now you can run the code:
```bash
cargo run
```
This will write a 64 MiB value to SlateDB.
## Check the results
[Section titled “Check the results”](#check-the-results)
Now’ let’s check the database path in the bucket:
```bash
% aws --endpoint-url=http://localhost:4566 s3 ls s3://slatedb/tmp/slatedb_s3_compatible/
PRE compacted/
PRE compactions/
PRE manifest/
PRE wal/
```
There are four folders:
* `manifest`: Contains the manifest files. Manifest files define the state of the DB, including the set of SSTs that are part of the DB.
* `wal`: Contains the write-ahead log files.
* `compacted`: Contains the compacted SST files (may not appear in short examples).
* `compactions`: Contains the compaction-state snapshots.
Let’s check the `wal` folder:
```bash
% aws --endpoint-url=http://localhost:4566 s3 ls s3://slatedb/tmp/slatedb_s3_compatible/wal/
2024-09-04 18:05:57 64 00000000000000000001.sst
2024-09-04 18:05:58 67108996 00000000000000000002.sst
```
Each of these SST files is a write-ahead log (WAL) file, and each WAL file can contain many `RowEntry` values. They get flushed based on the `flush_interval` config. The last WAL file is 64 MiB because it contains the value we wrote.
Finally, let’s check the `compacted` folder:
```bash
% aws --endpoint-url=http://localhost:4566 s3 ls s3://slatedb/tmp/slatedb_s3_compatible/compacted/
2024-09-04 18:05:59 67108996 01J6ZVEZ394GCJT1PHZYY1NZGP.sst
```
Again, we see the 64 MiB SST file. This is the L0 SST file that was flushed with our value. Over time, the WAL entries will be removed, and the L0 SSTs will be compacted into higher levels.
# Run a Standalone Compactor
> Learn how to run compaction as a separate process from your database clients
By default, SlateDB starts a background compactor inside each `Db` instance. If you want to run compaction as a separate service (for example, on dedicated compute), you can:
1. Open the `Db` with compaction disabled.
2. Start a standalone compactor in a separate process.
Both processes must use the same `ObjectStore` and database path (the path is the prefix inside your object store).
## Create a Db with no compactor
[Section titled “Create a Db with no compactor”](#create-a-db-with-no-compactor)
To prevent `Db` from starting the embedded compactor, set `Settings::compactor_options` to `None` and use `Db::builder`:
main.rs
```rust
use slatedb::config::Settings;
use slatedb::object_store::local::LocalFileSystem;
use slatedb::Db;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// This example uses a local object store so it can be shared with a separate
// compactor process. In production, this could also be an object store like S3.
let local_root = std::env::temp_dir().join("slatedb-standalone-compactor-tutorial");
std::fs::create_dir_all(&local_root)?;
let object_store = Arc::new(LocalFileSystem::new_with_prefix(local_root)?);
// Disable the embedded compactor by clearing compactor options.
let settings = Settings {
compactor_options: None,
..Default::default()
};
let db = Db::builder("db", object_store)
.with_settings(settings)
.build()
.await?;
db.put(b"hello", b"world").await?;
db.flush().await?;
db.close().await?;
Ok(())
}
```
## Run a compactor without a Db
[Section titled “Run a compactor without a Db”](#run-a-compactor-without-a-db)
You can run the standalone compactor using the SlateDB CLI:
```bash
slatedb --env-file .env --path run-compactor
```
The compactor runs until interrupted (Ctrl-C), then shuts down gracefully.
Alternatively, you can embed the compactor in your own process using `CompactorBuilder` and call `run`:
main.rs
```rust
use slatedb::object_store::local::LocalFileSystem;
use slatedb::CompactorBuilder;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let local_root = std::env::temp_dir().join("slatedb-standalone-compactor-tutorial");
std::fs::create_dir_all(&local_root)?;
let object_store = Arc::new(LocalFileSystem::new_with_prefix(local_root)?);
// Build a compactor without opening a Db. The database must already exist (i.e.
// the manifest has been created).
let compactor = Arc::new(CompactorBuilder::new("db", object_store).build());
let compactor_task = {
let compactor = Arc::clone(&compactor);
tokio::spawn(async move { compactor.run().await })
};
println!("Compactor running. Press Ctrl-C to stop.");
tokio::signal::ctrl_c().await?;
compactor.stop().await?;
compactor_task.await??;
Ok(())
}
```
Note
The standalone compactor expects the database to already exist (a manifest must be present). If you’re creating a new database, open it once (like in the previous section) before starting the compactor.
# Run a Standalone Garbage Collector
> Learn how to run garbage collection as a separate process from your database clients
By default, SlateDB starts a background garbage collector inside each `Db` instance. You can also run garbage collection as a separate service:
1. Keep your existing `Db` clients running.
2. Start one or more standalone garbage collectors against the same database path.
All processes must use the same `ObjectStore` and database path (the path is the prefix inside your object store).
Unlike the compactor coordinator, the garbage collector does not require fencing or leader election. Multiple garbage collectors may run in parallel, including the embedded collectors inside writer processes.
If you want a standalone process to own all garbage collection work, set `Settings::garbage_collector_options` to `None` in your `Db` clients.
## Run from the CLI
[Section titled “Run from the CLI”](#run-from-the-cli)
For a single foreground pass over one file type, use `run-garbage-collection`:
```bash
slatedb --env-file .env --path run-garbage-collection \
--resource compacted \
--min-age 5m
```
Valid resources are `manifest`, `wal`, `wal-fence`, `compacted`, and `compactions`.
To run garbage collection on a schedule in a standalone process, pass one or more schedules:
```bash
slatedb --env-file .env --path schedule-garbage-collection \
--manifest min_age=5m,period=1m \
--wal min_age=5m,period=1m \
--compacted min_age=5m,period=1m \
--compactions min_age=5m,period=1m
```
The scheduled process runs until interrupted (Ctrl-C), then shuts down gracefully.
The command above does not collect WAL fence objects. Enable `--wal-fence` only when `min_age` is longer than any writer can run. Default garbage collection keeps WAL fence deletion in dry-run mode because deleting fences too early can allow stale WAL writes to succeed.
## Embed the garbage collector
[Section titled “Embed the garbage collector”](#embed-the-garbage-collector)
You can also embed the garbage collector in your own process using `GarbageCollectorBuilder` and call `run`:
main.rs
```rust
use slatedb::object_store::local::LocalFileSystem;
use slatedb::GarbageCollectorBuilder;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let local_root = std::env::temp_dir().join("slatedb-standalone-gc-tutorial");
std::fs::create_dir_all(&local_root)?;
let object_store = Arc::new(LocalFileSystem::new_with_prefix(local_root)?);
// Build a garbage collector without opening a Db. The database must already
// exist (i.e. the manifest has been created).
let gc = Arc::new(GarbageCollectorBuilder::new("db", object_store).build());
let gc_task = {
let gc = Arc::clone(&gc);
tokio::spawn(async move { gc.run().await })
};
println!("Garbage collector running. Press Ctrl-C to stop.");
tokio::signal::ctrl_c().await?;
gc.stop().await?;
gc_task.await??;
Ok(())
}
```
With `GarbageCollectorOptions::default()`, garbage collection runs every 60 seconds and uses a 5 minute minimum age for managed directories. WAL fence deletion stays in dry-run mode by default.
To delete manifest and compactions metadata without advancing boundary files, set `GarbageCollectorOptions::boundary_files_enabled` to `false` on every garbage collector for the database. Metadata readers and writers continue to honor any existing boundary. Without boundary advancement, a SlateDB client or compactor can begin updating a manifest or compactions file, stop making progress (for example, because its process or host is suspended), then resume after `min_age`. It can then recreate a deleted metadata ID and incorrectly report its stale update as successful. Set the manifest and compactions `min_age` values longer than the maximum lifetime of a stale process before using this mode.
Note
The standalone garbage collector expects the database to already exist (a manifest must be present). If you’re creating a new database, open it once with your client process before starting the garbage collector.
Garbage collection deletes only files that meet the configured `min_age` and are no longer referenced by the latest database state or active checkpoints.
# Manifest Design
Status: Accepted
Authors:
* [Vignesh Chandramohan](https://github.com/vigneshc)
* [Rohan Desai](https://github.com/rodesai)
* [Chris Riccomini](https://github.com/criccomini)
References:
* Add manifest design document. ([#24](https://github.com/slatedb/slatedb/pull/24))
* Add an alternative manifest design document ([#39](https://github.com/slatedb/slatedb/pull/39))
## Current Design
[Section titled “Current Design”](#current-design)
SlateDB currently holds its state in an in-memory structure called `DbState`. `DbState` looks like this:
```rust
pub(crate) struct DbState {
pub(crate) memtable: Arc,
pub(crate) imm_memtables: Vec>,
pub(crate) l0: Vec,
pub(crate) next_sst_id: usize,
}
```
These fields act as follows:
* `memtable`: The currently active mutable MemTable. `put()` calls insert key-value pairs into this field.
* `imm_memtables`: An ordered list of MemTables that are in the process of being written to object storage.
* `l0`: An ordered list of level-0 [sorted string tables](https://www.scylladb.com/glossary/sstable/) (SSTs). These SSTs are not range partitioned; they each contain a full range of key-value pairs.
* `next_sst_id`: The SST ID to use when SlateDB decides to freeze `memtable`, move it to `imm_memtables`, and flush it to object storage. This will become the MemTable’s ID on object storage.
SlateDB doesn’t have compaction implemented at the moment. We don’t have level-1+ SSTs in the database state.
### Writes
[Section titled “Writes”](#writes)
A `put()` is performed by inserting the key-value pair into `memtable`. Once `flush_interval` (a configuration value) has expired, a flusher thread (in `flush.rs`) locks `DbState` and performs the following operations:
1. Replace `memtable` with a new (empty) MemTable.
2. Insert the old `memtable` into index 0 of `imm_memtables`.
3. For every immutable MemTable in `imm_memtables`.
1. Encode the immutable MemTable as an SST.
2. Write the SST to object storage path `sst-{next_sst_id}`.
3. Remove the MemTable from `imm_memtables`.
4. Insert the SST’s metadata (`SsTableInfo`) into `l0`.
5. Increment `next_sst_id`.
6. Notify listeners that are `await`’ing a `put()` in this MemTable.
A `SsTableInfo` structure is returned when the SST is encoded in 3.1. It looks like this:
```rust
pub(crate) struct SsTableInfo {
pub(crate) first_key: Bytes,
// todo: we probably dont want to keep this here, and instead store this in block cache
// and load it from there
pub(crate) block_meta: Vec,
pub(crate) filter_offset: usize,
pub(crate) filter_len: usize,
pub(crate) block_meta_offset: usize,
}
```
The structure contains the `first_key` in the SST, block locations and key information, and the bloom filter location and size.
*NOTE: The block meta information in the `SsTableInfo` struct stores the location and first key for each block in the SST. SSTs are broken into blocks (usually 4096 bytes); each block has a sorted list of key-value pairs. This data can get somewhat large; we’ll probably move it out of the in-memory `SsTableInfo`.*
### Reads
[Section titled “Reads”](#reads)
Reads simply iterate over each MemTable and SST looking for the value for a key. This is done by:
1. Return `get(k)` from `memtable` if it exists.
2. Iterate over each `imm_memtables` (starting from index 0) and return `get(k)` if it exists.
3. Iterate over each `l0` SST (starting from index 0) and return `get(k)` if it exists.
## Problem
[Section titled “Problem”](#problem)
There are a couple of problems with the current design:
1. SlateDB loses the state of the database when the process stops.
2. SlateDB does not fence zombie writers.
If the process SlateDB is running in stops, all data in `DbState` is lost since it’s not persisted anywhere. SlateDB could read over every SST in object storage and reconstruct `l0`, but it does not.
Even if SlateDB were to recover its state by reading all SSTs in object storage, the process would be slow and could be wrong. Inaccurate `l0` state occurs if multiple writers write SSTs in parallel to the same SlateDB object store location. SlateDB assumes it’s the only writer, but it currently does not implement fencing to keep other writers from corrupting its state.
## Solution
[Section titled “Solution”](#solution)
### Goals
[Section titled “Goals”](#goals)
This design should:
* Work with a single writer, multiple readers, and a single compactor
* Allow the writer, compactor, and all readers to run on separate machines
* Allow parallel writes from a single writer
* Allow readers to snapshot state for consistent reads across time
* Work with object stores that support CAS
* Work with object stores that do not support CAS if a transactional store is available (DynamoDB, MySQL, and so on)
* Provide < 100ms-300ms read/write latencies
* Avoid write contention on the manifest
* Work for S3, Google Cloud Storage (GCS), Azure Blob Storage (ABS), MinIO, Radically Reprogrammable (R2) Storage, and Tigris
### Proposal
[Section titled “Proposal”](#proposal)
We propose persisting SlateDB’s `DbState` in a manifest file to solve problem (1). Such a design is quite common in [log-structured merge-trees](https://en.wikipedia.org/wiki/Log-structured_merge-tree) (LSMs). In particular, RocksDB [follows this pattern](https://github.com/facebook/rocksdb/wiki/MANIFEST). In SlateDB, we will persist `DbState` in a manifest file, which all clients will load on startup. Writer, compactor, and readers will all update the manifest in various situations. All updates to the manifest will be done using [compare-and-swap](https://en.wikipedia.org/wiki/Compare-and-swap) (CAS).
To prevent zombie writers, we propose using CAS to ensure each SST is written exactly one time. We introduce the concept of writer epochs to determine when the current writer is a zombie and halt the process. The full fencing protocol is described later in the *Writer Protocol* section.
### CAS on S3
[Section titled “CAS on S3”](#cas-on-s3)
Changes to the manifest file and SST writes both require CAS in this design. Most object stores provide CAS (a.k.a. preconditions, conditionals, `If-None-Match`, `If-Match`, and so on). But S3 does not, and we want to support S3. Fortunately, we can support CAS-like operations using several different patterns.
There are two scenarios to consider when we want to CAS an object:
1. The object we wish to CAS is immutable; it exists or it does not, but never changes once it exists (“create if not exists”).
2. The object we wish to CAS is mutable; it might exist and can change over time.
There are five different patterns to achieve CAS for these scenarios:
1. **Use CAS**: If the object store supports CAS, we can use it. This pattern works for both scenarios described above.
2. **Use a transactional store**: A transactional store such as DynamoDB or MySQL can be used to store the object. The object store is not used in this mode. This approach works for both scenarios described above, but does not work well for large objects.
3. **Use object versioning**: Object stores that support [object versioning](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) can emulate CAS if the bucket has versioning enabled. Versioning allows multiple versions of an object to be stored. AWS stores versions [in insertion order](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectVersions.html#API_ListObjectVersions_Example_3). We can leverage this property as well as AWS’s [read-after-write](https://aws.amazon.com/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/) consistency guarantees to emulate CAS. Many writers can write to the same object path. Writers can then list all versions of the object. The first version (the earliest) is considered the winner. All other writers have failed the CAS operation. This does require a [ListObjectVersions](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectVersions.html) after the write, but if CAS is needed, this is the best we can do. This pattern works for immutable files—scenario (1)—but not mutable files—scenario (2). This pattern does not work for S3 Express One Zone since it does not support object versioning.
4. **Use a proxy**: A proxy-based solution combines an object store and a transactional store. Two values are stored: an object and a pointer to object. Readers first read the pointer (e.g. object path), then read the object that the pointer points to. Writers read the current object (using the reader steps), update the object, and write it back to the object store at a new location (e.g. a new UUID). Writers then update the pointer to point to the new object. The pointer update is done conditionally if the pointer still points to the file the writer read. The pointer may reside in a file on an object store that supports CAS, or a transactional store like DynamoDB or MySQL. This pattern works for both immutable and mutable files. Unlike (2), it also works well for large objects.
5. **Use a two-phase write**: Like the proxy solution (4), a two-phase write consists of a write to both an object storage and a transactional store. Writers first write a new object to a temporary location. The writer then writes a record to a transactional store. The transactional record signals an intent to copy; it contains a source, destination, and a completion flag. “put-if-not-exists” semantics are used on the destination column. The writer then copies the new object from its temporary location to its final destination in object storage. Upon completion, the transactional store’s record is set to complete and the temporary file is deleted. If the writer fails before the transactional record is written, the write is abandoned. If the writer fails after the transactional record is written, a recovery process runs in the future to copy the object and set the completion flag. This pattern works for immutable objects. It also works well for large objects.
*NOTE: These patterns are discussed more [here](https://github.com/slatedb/slatedb/pull/39/files#r1588879655) and [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1597297640).*
Our proposed solution uses the following forms of CAS:
* CAS (1) for manifest and SST writes when the object store supports CAS
* Two-phase write (5) for manifest and SST writes when the object store does not support CAS
*NOTE: Two-phase write (5) was selected because the folder structure will be the same for CAS object stores. A proxy-based solution (4) would store objects at random locations; only the pointer record would contain the actual object name. (5) is in keeping with our philosophy with our belief that S3 will eventually get CAS. Once this happens, S3 clients can be replaced simply by swapping the transactional store write for a CAS write. Also, (4) requires a proxy read for every object read, while (5) requires an extra write. The extra write is preferred since it’s asynchronous (it can happen after an async acknowledgement) and obviates the proxy reads.*
#### Two-Phase CAS Protocol
[Section titled “Two-Phase CAS Protocol”](#two-phase-cas-protocol)
A description of the two-phase CAS protocol is described in this section. These are the operations to consider:
1. **Writes**: A process writes an object to object storage and fails if the object already exists.
2. **Deletes**: A process deletes an object from object storage.
3. **Recovery**: A process recovers a failed write or delete.
##### Writes
[Section titled “Writes”](#writes-1)
Suppose a writer wishes to write to the object location `manifest/00000000000000000000.manifest`. The writer follows these steps:
1. Write the new manifest to a temporary location, say `manifest/00000000000000000000.manifest.[UUID].[checksum]`.
2. Write a record to a transactional store, say DynamoDB, with the following fields:
* `source`: `manifest/00000000000000000000.manifest.[UUID].[checksum]`
* `destination`: `manifest/00000000000000000000.manifest`
* `committed`: `false`
3. Copy the object from `manifest/00000000000000000000.manifest.[UUID].[checksum]` to `manifest/00000000000000000000.manifest`.
4. Update the record in DynamoDB to set `committed` to `true`.
5. Delete the object from `manifest/00000000000000000000.manifest.[UUID].[checksum]`.
*NOTE: The source location is arbitrary. If randomness is used in the name—a UUID, ULID, or KSUID—collisions must be considered. In our case, collisions are catastrophic since they lead to corruption. We use a UUID and checksum to attempt to reduce collisions.*
Once the record is written to DynamoDB in step (2), the entire write is considered durable. No other writers may insert a record with the same `destination` field.
*NOTE: This design is inefficient on S3. A single SST write includes two S3 `PUT`s, two writes to DynamoDB (or equivalent), and one S3 `DELETE`. See [here](https://github.com/slatedb/slatedb/pull/43#issuecomment-2105368258) for napkin math on API cost. Latency should be minimally impacted since the write is considered durable after one S3 write and one DynamoDB write. Nevertheless, we are gambling that S3 will soon support pure-CAS like every other object store. In the long run, we expect to switch S3 clients to use CAS and drop support for two-phase CAS.*
##### Deletes
[Section titled “Deletes”](#deletes)
See the garbage collection section below for details on deletions.
##### Recovery
[Section titled “Recovery”](#recovery)
A writer or deletion process might fail at any point in the steps outlined above. Client processes may run recoveries at any point in the future to complete partial writes or deletes. The recovery process is as follows:
1. List all records in DynamoDB with `committed` set to `false`.
2. For each record:
1. Copy the `source` object to the `destination` object.
2. Set `committed` to `true`.
3. Delete the source object.
### File Structure
[Section titled “File Structure”](#file-structure)
Let’s look at SlateDB’s file structure on object storage:
```plaintext
some-bucket/
├─ manifest/
│ ├─ 00000000000000000000.manifest
│ ├─ 00000000000000000001.manifest
│ ├─ 00000000000000000002.manifest
├─ wal/
│ ├─ 00000000000000000000.sst
│ ├─ 00000000000000000001.sst
│ ├─ ...
├─ levels/
│ ├─ ...
```
*NOTE: Previous iterations of this design referred to the WAL as `l0` or `level_0`, and the first level in `levels` (formerly `compacted`) as `level_1+`. We’ve since shifted the terminology to match traditional LSMs. This shift is discussed in more detail [here](https://github.com/slatedb/slatedb/pull/39/files#r1589855599).*
#### `manifest/00000000000000000000.manifest`
[Section titled “manifest/00000000000000000000.manifest”](#manifest00000000000000000000manifest)
A file containing writer, compaction, and snapshot information—the state of the database at a point in time.
The manifest’s name is formatted as 20 digit zero-padded numbers to fit u64’s maximum integer and to support lexicographical sorting. The name represents the manifest’s ID. Manifest IDs are monotonically increasing and contiguous. The manifest with the highest ID is considered the current manifest. `00000000000000000002.manifest` is the current manifest in the file structure section above.
*NOTE: The current design does not address incremental updates to the manifest. This is something that prior designs had. See [here](https://github.com/slatedb/slatedb/pull/24/files#diff-58d53b55614c8db2dd180ac49237f37991d2b378c75e0245d780356e5d0c8135R67). We’ve opted to eschew incremental manifest updates for the time being, given their size (\~5MiB) and update frequency (on the order of minutes).*
##### Structure
[Section titled “Structure”](#structure)
A `.manifest` file has the following structure.
*NOTE: We describe the structure in a proto3 IDL, but the manifest could be any format we choose. I’m thinking [FlatBuffers](https://github.com/slatedb/slatedb/issues/41). See [#41](https://github.com/slatedb/slatedb/issues/41) for discussion.*
```proto
syntax = "proto3";
message Manifest {
// Manifest format version to allow schema evolution.
uint16 manifest_format_version = 1;
// The current writer's epoch.
uint64 writer_epoch = 2;
// The current compactor's epoch.
uint64 compactor_epoch = 3;
// The most recent SST in the WAL that's been compacted.
uint64 wal_id_last_compacted = 4;
// The most recent SST in the WAL at the time manifest was updated.
uint64 wal_id_last_seen = 5;
// A list of the SST table info that are valid to read in the `levels` folder.
repeated SstInfo leveled_ssts = 6;
// A list of read snapshots that are currently open.
repeated Snapshot snapshots = 7;
}
message SstInfo {
// Manifest format version to allow schema evolution.
uint16 sstinfo_format_version = 1;
// Globally unique ID for an SST in the `compacted` folder.
uint64 id = 1;
// The first key in the SST file.
string first_key = 2;
// Bloom filter offset in the SST file.
uint32 filter_offset = 3;
// Bloom filter length in the SST file.
uint32 filter_len = 4;
// Block metadata offset in the SST file.
uint32 block_meta_offset = 5;
// Block metadata offset in the SST file.
uint32 block_meta_len = 6;
}
message Snapshot {
// 128-bit UUID that must be unique across all open snapshots.
uint128 id = 1;
// The manifest ID that this snapshot is using as its `DbState`.
uint64 manifest_id = 2;
// The UTC unix timestamp seconds that a snapshot expires at. Clients may update this value.
// If `snapshot_expire_time_s` is older than now(), the snapshot is considered expired.
// If `snapshot_expire_time_s` is 0, the snapshot will never expire.
uint32 snapshot_expire_time_s = 3;
}
```
##### Size
[Section titled “Size”](#size)
Manifest size is important because manifest updates must be done transactionally. The longer the read-modify-write takes, the more likely there is to be a conflict. Consequently, we want our manifest to be as small as possible.
The size calculation (in bytes) for the manifest is:
```plaintext
2 // manifest_format_version
+ 8 // writer_epoch
+ 8 // compactor_epoch
+ 8 // wal_id_last_compacted
+ 8 // wal_id_last_seen
+ 4 + ~56 * leveled_ssts // array length + leveled_ssts (~32 byte key + 24 bytes = ~56 bytes)
+ 4 + 28 * snapshots // array length + snapshots (28 bytes each)
```
Conservatively, a manifest with 1000 snapshots and 100,000 compacted SSTs would be:
```plaintext
2 // manifest_format_version
+ 8 // writer_epoch
+ 8 // compactor_epoch
+ 8 // wal_id_last_compacted
+ 8 // wal_id_last_seen
+ 4 + ~56 * 100000 // array length + leveled_ssts
+ 4 + 28 * 1000 // array length + snapshots
= 5,628,042
```
This comes out to 5,628,042 bytes, or \~5.6 MiB. If a client gets 100MB/s to and from EC2 to S3, it would take 56ms to read and 56ms to write (plus serialization, network, and TCP overhead). All in, let’s say 250-500ms.
Whether this is reasonable or not depends on how frequently manifests are updated. As we’ll see below, updates should be infrequent enough that a 5.6 MiB manifest isn’t a problem.
#### `wal/00000000000000000000.sst`
[Section titled “wal/00000000000000000000.sst”](#wal00000000000000000000sst)
SlateDB’s WAL is a sequentially ordered contiguous list of SSTs. SST object names are formatted as 20 digit zero-padded numbers to fit u64’s maximum integer and to support lexicographical sorting. Each SST contains zero or more sorted key-value pairs. The WAL is used to store writes that have not yet been compacted.
Traditionally, LSMs store WAL data in a different format from the SSTs; this is because each `put()` call results in a single key-value write to the WAL. But SlateDB doesn’t write to the WAL on each `put()`. Instead, `put()`’s are batched together based on `flush_interval`, `flush_bytes` ([#30](https://github.com/slatedb/slatedb/issues/30)), or `skip_memtable_bytes` ([#31](https://github.com/slatedb/slatedb/issues/31)). Based on these configurations, multiple key-value pairs are stored in the WAL in a single write. Thus, SlateDB uses SSTs for both the WAL and compacted files.
*NOTE: We discussed using a different format for the WAL [here](https://github.com/slatedb/slatedb/pull/39/files#r1590087062), but decided against it.*
This design does not use [prefixes](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-prefixes.html). AWS allows, “3,500 PUT/COPY/POST/DELETE or 5,500 GET/HEAD requests per second per partitioned Amazon S3 prefix.” This limits a single writer to 3,500 writes and 5,500 reads per-second. With a `flush_interval` set to 1ms, a client would write 1,000 writes per-second (not including compactions). With caching, the client should be far below the 5,500 reads per-second limit.
This design also does not expose writer epochs in WAL SST filenames (e.g. `[writer_epoch].[sequence_number].sst`). We discussed this in detail [here](https://github.com/slatedb/slatedb/pull/39/files#r1588892292) and [here](https://github.com/slatedb/slatedb/pull/24/files#r1585569744). Ultimately, we chose “un-namespaced” filenames (i.e. filenames without epoch prefix) because it’s simpler to reason about.
##### Attributes
[Section titled “Attributes”](#attributes)
Each SST will have the following [user-defined metadata fields](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata):
* `writer_epoch`: This is a `u64`. Writers will set this on each SST written into `wal`.
### Manifest Updates
[Section titled “Manifest Updates”](#manifest-updates)
A full read-modify-write Manifest update contains the following steps:
1. List `manifest` to find the manifest with the largest ID. Say it’s `00000000000000000002.manifest` in this example.
2. Read `manifest/00000000000000000002.manifest` to read the current manifest.
3. Update the manifest in memory.
4. Write the updated manifest to the next manifest slot (`manifest/00000000000000000003.manifest`).
(4) is a CAS operation. Clients will use either real CAS (pattern (1)) or two-phase write (pattern (5)) to update the manifest.
If the CAS write fails in (4), the client must retry the entire process. This is because the client now has an outdated view of the manifest. It must find the (new) latest manifest and re-apply its changes.
Three different processes update the manifest:
* **Readers**: Readers can update `snapshots` to create, remove, or update a snapshot. Readers can also update `wal_id_last_seen` to update the latest `wal` SST ID when a new snapshot is created.
* **Writers**: Writers must update `writer_epoch` once on startup.
* **Compactors**: Compactors must update `compactor_epoch` once on startup. Compactors must also update `wal_id_last_compacted`, `wal_id_last_seen`, and `leveled_ssts` after each compaction pass.
The union of these three processes means the manifest is updated whenever:
1. A reader creates a snapshot on startup
2. A reader updates a snapshot periodically
3. A reader removes a snapshot when finished
4. A writer increments `writer_epoch` and creates a snapshot on startup
5. A compactor increments `compactor_epoch` on startup
6. A compactor finishes a compaction periodically
If these occur too frequently, there might be conflict between the clients. Conflict can lead to starvation, which can slow down startup time and compaction time. Luckily, all events except (6) should occur at the minute granularity or larger. Compaction, (6), warrants some additional thought, which we discuss at the end of this document.
Let’s look at each of these in turn.
### Snapshots
[Section titled “Snapshots”](#snapshots)
This design introduces the concept of snapshots, which allow clients to:
1. Prevent compaction from deleting SSTs that the client is still using
2. Share a consistent view of the database state across multiple clients
A snapshot has three fields: `id`, `manifest_id`, and `snapshot_expire_time_s`.
* `id`: A UUID that must be unique across all open snapshots.
* `manifest_id`: The manifest ID that this snapshot is using as its `DbState`.
* `snapshot_expire_time_s`: The UTC Unix epoch seconds that the snapshot expires at.
Each client will use a different `id` for each snapshot it creates. Similarly, no two clients will share the same `id` for any snapshot. Clients that wish to share the same view of a database will each define different snapshots with the same `manifest_id`.
Clients set the `snapshot_expire_time_s` when the snapshot is created. Clients may update their snapshot’s `snapshot_expire_time_s` at their discretion by writing a new manifest with the updated value. A snapshot is considered expired if `snapshot_expire_time_s` is less than the current time. If `snapshot_expire_time_s` is 0, the snapshot will never expire.
*NOTE: Clients that set `snapshot_expire_time_s` to 0 must guarantee that they will eventually remove their snapshot from the manifest. If they do not, the snapshot’s SSTs will never be removed.*
A client creates a snapshot by creating a new manifest with a new snapshot added to the `snapshots` field.
A client may also update `wal_id_last_seen` in the new manifest to include the most recent SST in the WAL that the client has seen. This allows clients to include the most recent SSTs from the `wal` in a new snapshot. It also allows clients to determine which `wal` SSTs to ignore from the head of the WAL if the client is restarted. See [here](https://github.com/slatedb/slatedb/pull/39/files#r1588780707) and [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1601924366) for more details.
A new snapshot may only reference an active manifest (see [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1594444035) and [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1601981496) for more details). A manifest is considered active if either:
* It’s the current manifest
* It’s referenced by a snapshot in the current manifest and the snapshot has not expired
A compactor may not delete SSTs that are referenced by any active manifest.
The set of SSTs that are referenced by a manifest are:
* All `levels` files referenced in the manifest’s `leveled_ssts`
* All `wal` files with SST ID >= `wal_id_last_compacted`
*NOTE: The inclusive `>=` for `wal_id_last_compacted` is required so the compactor doesn’t delete the most recently compacted file. We need this file to get the `writer_epoch` during the recovery process detailed in the Read Clients section below.*
*NOTE: Clock skew can affect the timing between the compactor and the snapshot clients. We’re assuming we have well behaved clocks, a [Network Time Protocol](https://www.ntp.org/documentation/4.2.8-series/ntpd/) (NTP) daemon, or [PTP Hardware Clocks](https://aws.amazon.com/blogs/compute/its-about-time-microsecond-accurate-clocks-on-amazon-ec2-instances/) (PHCs).*
*NOTE: A [previous design proposal](https://github.com/slatedb/slatedb/pull/39/files#diff-d589c7beb3d163638e94dbc8e086b3efe093852f0cad96f04cb1283c3bd1eb74R105) used a `heartbeat_s` field that clients would update periodically. After some discussion (see [here](https://github.com/slatedb/slatedb/pull/39/files#r1588896947)), we landed on a design that supports both reference counts and snapshot timeouts. Reference counts are useful for long-lived snapshots that exist indefinitely. Heartbeats are useful for short-lived snapshots that exist for the lifespan of a single process.*
*NOTE: This design considers read-only snapshots. Read-write snapshots are discussed [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1596319141) and in \[[#49](https://github.com/slatedb/slatedb/issues/49)].*
### Writers
[Section titled “Writers”](#writers)
#### `writer_epoch`
[Section titled “writer\_epoch”](#writer_epoch)
This design introduces the concept of a `writer_epoch`. The `writer_epoch` is a monotonically increasing `u64` that is transactionally incremented by a writer on startup. The `writer_epoch` is used to prevent split-brain and fence zombie writers. A zombie writer is a writer with an epoch that is less than the `writer_epoch` in the current manifest.
`writer_epoch` exists in two places:
1. As a field in the manifest
2. As a user-defined metadata field in each `wal` SST
#### Writer Protocol
[Section titled “Writer Protocol”](#writer-protocol)
On startup, a writer client must increment `writer_epoch`.
1. List `manifest` to find the manifest with the largest ID.
2. Read the current manifest (e.g. `manifest/00000000000000000002.manifest`).
3. Increment the `writer_epoch` in the current manifest in memory.
4. Create a new snapshot in the current manifest in memory.
5. Write the manifest with the updated `writer_epoch` (e.g. `manifest/00000000000000000003.manifest`).
*NOTE: A snapshot is created in (4) to prevent the compactor from deleting `wal` SSTs while the writer is writing its fencing SST. The timeout for the snapshot will be twice as long as the writer’s snapshot refresh interval. See [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1594460226) and [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1601806626) for details.*
The writer client must then fence all older clients. This is done by writing an empty SST to the next SST ID in the WAL.
2. List the `wal` directory to find the next SST ID.
3. Write an empty SST with the new `writer_epoch` to the next SST ID using CAS or object versioning.
*NOTE: The writer may choose to release its snapshot created in (1) after writing the fencing SST in (3), or it may periodically refresh its snapshot.*
At this point, there are four potential outcomes:
1. The write is successful.
2. The write was unsuccessful. Another writer wrote an SST with the same ID and a lower (older) `writer_epoch`.
3. The write was unsuccessful. Another writer wrote an SST with the same ID and the same `writer_epoch`.
4. The write was unsuccessful. Another writer wrote an SST with the same ID and a higher (newer) `writer_epoch`.
If the write was successful (1), all previous (older) writers have now been fenced.
If an older writer beats the current writer to the SST slot (2), the newer writer increments its SST ID by 1 and tries again. This process repeats until the write is successful.
If another writer has the same `writer_epoch` (3), the client is in an illegal state. This should never happen since clients are transactionally updating `writer_epoch`. If this does happen, the client should panic.
If a newer writer beats the current writer to the SST slot (4), the current writer has been fenced. The current writer should halt.
*NOTE: This protocol implies the invariant that SST IDs in `wal` will be contiguous and monotonically increasing.*
#### Examples
[Section titled “Examples”](#examples)
Let’s consider some examples to illustrate the writer protocol.
Here’s an example where a new writer successfully fences an older writer (protocol scenario (1)):
```plaintext
time 0, 00000000000000000000.sst, writer_epoch=1
time 1, 00000000000000000001.sst, writer_epoch=1
time 2, 00000000000000000002.sst, writer_epoch=2
time 3, 00000000000000000002.sst, writer_epoch=1
time 4, 00000000000000000003.sst, writer_epoch=2
```
In the example above, writer 1 successfully writes SSTs 0 and 1. At time 2, writer 2 successfully fences writer 1, but writer 1 hasn’t yet seen the fence write. When writer 1 attempts to write SST 2, it loses the CAS write and halts because SST 2 has a higher `writer_epoch`. Writer 2 then continues with a successful write to SST 3.
Here’s an example where a new writer has to retry its fence write because an older writer took its SST ID location (protocol scenario (2)):
```plaintext
time 0, 00000000000000000000.sst, writer_epoch=1
time 1, 00000000000000000001.sst, writer_epoch=1
time 2, 00000000000000000002.sst, writer_epoch=1
time 3, 00000000000000000002.sst, writer_epoch=2
time 4, 00000000000000000003.sst, writer_epoch=2
```
In the example above, writer 1 successfully writes SSTs 0, 1, and 2. Writer 2 tries to fence older writers with a write to SST ID 2, but fails because writer 1 has already written an SST at that location. Writer 2 sees that the SST with ID 2 has a lower `writer_epoch` than its own and retries its fence write at the next SST ID location. This write to SST ID 3 at time 4 is successful. Writer 2 has successfully fenced writer 1. If writer 1 tries to write to SST ID 3, it will see a higher `writer_epoch` and halt.
Here’s an example where a new writer gets fenced before it can write its own fencing write (protocol scenario (4)):
```plaintext
time 0, 00000000000000000000.sst, writer_epoch=1
time 1, 00000000000000000001.sst, writer_epoch=1
time 2, 00000000000000000002.sst, writer_epoch=3
time 3, 00000000000000000002.sst, writer_epoch=2
time 4, 00000000000000000003.sst, writer_epoch=3
```
In the example above, writer 1 successfully writes SSTs 0 and 1. Writer 2 tries to fence older writers with a write to SST ID 2, but fails because writer 3 has already written an SST at that location. Writer 2 sees that the SST with ID 2 has a higher `writer_epoch` than its own, and halts.
#### Acknowledgements
[Section titled “Acknowledgements”](#acknowledgements)
SlateDB’s `put()` API is asynchronous. Clients that want to know when their `put()` has been durably persisted to object storage must call `await`. A writer client will not successfully acknowledge a `put()` call until the key-value pair has been successfully written to object storage in a `wal` SST.
#### Parallel Writes
[Section titled “Parallel Writes”](#parallel-writes)
The writer protocol we describe above assumes that writers are writing SSTs in sequence. That is, a writer will never write SST ID N until SST ID N-1 has been successfully written to object storage by the client.
But we know we will want to support parallel writes in the future. Parallel writes allow a single writer client to write multiple SSTs at the same time. This can reduce latency for the writer client. Our design should not preclude parallel writes.
The current writer protocol can be extended to support parallel writes by defining a `max_parallel_writes` configuration parameter. A new writer must then write `max_parallel_writes` sequential fencing SSTs in a row starting from the first open SST position in the `wal`. `max_parallel_writes` would also need to be stored in the manifest so readers, writers, and compactors can agree on it.
*NOTE: This strategy is discussed in more detail [here](https://github.com/slatedb/slatedb/pull/24/files#r1585894110).*
Consider this example with `max_parallel_writes` set to 2:
```plaintext
time 0, 00000000000000000000.sst, writer_epoch=1
time 1, 00000000000000000001.sst, writer_epoch=1
time 2, 00000000000000000002.sst, writer_epoch=2
time 3, 00000000000000000003.sst, writer_epoch=1
time 4, 00000000000000000002.sst, writer_epoch=1
time 5, 00000000000000000003.sst, writer_epoch=2
time 6, 00000000000000000004.sst, writer_epoch=2
time 7, 00000000000000000005.sst, writer_epoch=2
```
Writer 1 successfully writes SSTs 0, 1, and 3. Writer 1’s SST 2 write fails at time 4 because writer 2 has already taken that location at time 2. At this point, writer 1 immediately stops writing new SSTs and halts. Existing parallel writes run to completion (either failure or success).
Writer 2 does not know whether it’s successfully fenced writer 1 yet. It attempts to write SST 3 at time 5, but fails because writer 1 has already written an SST at that location at time 3. At this point, writer 2 begins again at position 4. Writer 2 successfully writes SST 4, then SST 5. Writer 2 has now written two consecutive SSTs to the `wal`. Since `max_parallel_writes` is set to 2, writer 2 has successfully fenced writer 1; it’s guaranteed that writer 1 will see at least one of these fencing writes in its `max_parallel_writes` window. Thus, no writes from writer 1 (or any older writer) will appear after SST 5.
The next question is what to do about writer 1’s SST 3 that was written at time 3.
The writer must decide whether to:
1. Block all ack’s until all previous SST writes have been successfully written. In this case, `put()`s with key-value pairs in SST 3 would be notified of a failure since SST 2 was not successfully written.
2. Successfully acknowledge SST 3 as soon as it’s written.
We propose allowing writers to successfully acknowledge writes as soon as they occur (2). This strategy has slightly looser semantics since it means a put in SST 3 would be durably persisted and readable in the future, while puts in SST 2 would not be durably persisted.
```plaintext
a = put(key1, value1)
b = put(key2, value3)
a.await() -> failed
b.await() -> succeeded
```
This should be fine since the client can always retry the failed `put()` call. Clients that want strong ordering across puts can use transactions.
*NOTE: Transactions are not something we’ve considered in detail. The current idea is to keep all transactional writes within a single SST. Thus, transactions should work with this protocol.*
All readers and the compactor must decide whether to:
1. Ignore SST 3 because an SST with a higher `writer_epoch` has been written to a previous slot.
2. Treat SST 3 as valid because writer 2 has not yet written `max_parallel_writes` SSTs in a row.
We propose allowing readers and the compactor to treat SST 3 as valid.
*NOTE: Astute readers might notice that `max_parallel_writes` is equivalent to a Kafka producer’s [`max.in.flight.requests.per.connection`](https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#max-in-flight-requests-per-connection) setting.*
If more than 2 writers are writing in parallel, the protocol is the same. The only difference is that readers will see writes from multiple epochs before the youngest writer wins the fencing race.
To illustrate, consider an example where we have 3 writers. writer 1 is the old writer, while writer 2 and writer 3 are trying to fence:
```plaintext
time 0, 00000000000000000000.sst, writer_epoch=1 // success
time 1, 00000000000000000001.sst, writer_epoch=1 // success
time 2, 00000000000000000002.sst, writer_epoch=3 // success
time 3, 00000000000000000003.sst, writer_epoch=1 // success
time 4, 00000000000000000002.sst, writer_epoch=1 // failure (writer 3 won at time 2), writer 1 halts
time 5, 00000000000000000003.sst, writer_epoch=2 // failure (writer 1 won at time 3)
time 6, 00000000000000000003.sst, writer_epoch=3 // failure (writer 1 won at time 3)
time 7, 00000000000000000004.sst, writer_epoch=2 // success
time 8, 00000000000000000004.sst, writer_epoch=3 // failure (writer 2 won at time 7)
time 9, 00000000000000000005.sst, writer_epoch=3 // success
time 10, 00000000000000000005.sst, writer_epoch=2 // failure (writer 3 won at time 9), writer 2 halts
time 11, 00000000000000000006.sst, writer_epoch=3 // success
```
At time 11, writer 3 has successfully fenced writer 1 and writer 2. Readers will consider all SSTs valid from 0-6, though only SSTs 0, 1, and 3 will have key-value pairs since fencing SST writes are always empty.
*NOTE: Writers will only detect that they’ve been fenced when they go to write. Low-throughput writers can periodically scan `wal` for SSTs with a higher `writer_epoch` to proactively detect that they’ve been fenced. This would work with the current design, but isn’t in scope.*
### Readers
[Section titled “Readers”](#readers)
#### Reader Protocol
[Section titled “Reader Protocol”](#reader-protocol)
Readers must establish a snapshot on startup and load DbState:
1. Read the current manifest
2. List all `wal` to find the maximum contiguous SST
3. Update the current manifest in-memory
1. Set `wal_id_last_seen` to the ID of the maximum contiguous SST in (2)
2. Create a new snapshot that references the new manifest
4. Write the updated manifest to the next manifest slot, go to (1) if CAS fails
5. Load all `leveled_sst`s into `DbState.leveled_ssts`
6. Load all `wal` SSTs >= `wal_id_last_compacted` into `DbState.l0`
*NOTE: Readers read the current manifest (1) before listing the `wal` (2) so the reader can detect manifest changes while it’s listing the `wal` directory. This is important because the compactor and garbage collector might have deleted SSTs that the reader sees in (2). Without this protection, the reader might set `wal_id_last_seen` to an SST that no longer exists. See [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1603626303) for more details.*
*NOTE: We’ll probably want to rename `DbState.l0` to `DbState.wal`.*
*NOTE: `DbState.leveled_ssts` doesn’t currently exist because SlateDB does not have compaction. When we add compaction, we’ll need to introduce this variable.*
At this point, a reader has a consistent view of all SSTs in the database.
The reader will then periodically refresh its state by:
1. Polling the `wal` directory to detect new `wal` SSTs
2. Reloading the current manifest and creating a new snapshot to detect new `leveled_ssts`
*NOTE: Readers can also skip snapshot creation if they wish. If they do so, they must deal with SSTs that are no longer in object storage. They may do so by re-reading the manifest and updating their `DbState`, or by simply skipping the SST read. Lazy readers are outside the scope of this document.*
#### Parallel Writes
[Section titled “Parallel Writes”](#parallel-writes-1)
The reader protocol above assumes that all SSTs are contiguous and monotonically increasing. If parallel writes are allowed, a reader might see a `wal` with gaps between SSTs:
```plaintext
time 0, 00000000000000000000.sst, writer_epoch=1
time 1, 00000000000000000001.sst, writer_epoch=1
time 2, 00000000000000000003.sst, writer_epoch=1
```
In this example, the reader has two choices:
1. Include SST 3 in its DbState.
2. Wait for SST 2 to be written.
If the reader includes SST 3 in its `DbState`, it will need to periodically check for SST 2 by polling the `wal` or refreshing its `DbState` at some interval.
We propose waiting for SST 2 to be written. This style simplifies polling: the reader simply polls for the next SST (SST 2 in the example above). When SST 2 appears, it’s added to `l0` and the reader begins polling for SST 3.
This does mean that SST 3 will not be served by a reader until SST 2 arrives. For the writer client, this is not a problem, since the writer client is fully consistent between its own reads and writes. But secondary reader clients might not see SST 3 for a significant period of time if the writer dies. This is a tradeoff that we are comfortable with. If it proves problematic, we can implement (1), above, and poll for missing SSTs periodically.
*NOTE: There is some interdependence between when `wal_id_last_seen` and the semantics of a snapshot. If `wal_id_last_seen` were to include SST 3 in our example above, we’d have to decide whether to include SST 2 when it eventually arrives. The current design does not have this issue. This is discussed more [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1594783266).*
### Compactors
[Section titled “Compactors”](#compactors)
On startup, a compactor must increment the `compactor_epoch` in the manifest. This is done in a similar manner to the process described in the *Writer Protocol* section above.
After startup, compactors periodically do two things:
1. Merge SSTs from the `wal` into `leveled_ssts`
2. Merge SSTs from `leveled_ssts` into higher-level SSTs
To merge SSTs from the `wal` into `leveled_ssts` (1), the compactor must periodically:
1. List all SSTs in the `wal` folder > `wal_id_last_compacted`
2. Create a new merged SST in `leveled_ssts`’s level 0 that contains all `wal` SST key-value pairs
3. Update the manifest with the new `leveled_ssts`, `wal_id_last_compacted`, and `wal_id_last_seen`
*NOTE: This design implies that level 0 for `leveled_ssts` will not be range partitioned. Each SST in level 0 would be a full a-z range. This is how traditional LSMs work. If we were to go with range partitioning in level 0, we’d need to do a full level 0 merge every time we compact `wal`; this could be expensive. This design decision is discussed in more detail [here](https://github.com/slatedb/slatedb/pull/39/files#r1589855599).*
Merging leveled SSTs (2) is outside the scope of this design.
### Garbage Collectors
[Section titled “Garbage Collectors”](#garbage-collectors)
A garbage collector (GC) must delete both objects from object storage and records from the transactional store (when two-phase CAS is used). The garbage collector must delete inactive manifests and SSTs.
*NOTE: This design considers the compactor and garbage collector (inactive SST deletion) as two separate activities that run for one database in one process—the compactor process. In the future, we might want to run the compactor and garbage collector in separate processes on separate machines. We might also want the garbage collector to run across multiple databases. This should be doable, but is outside this design’s scope. The topic is discussed [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1596319141) and in \[[#49](https://github.com/slatedb/slatedb/issues/49)].*
#### Object Deletion
[Section titled “Object Deletion”](#object-deletion)
Four classes of objects must be deleted:
* Inactive manifests
* Inactive SSTs
* Orphaned temporary manifests (when two-phase CAS is used)
* Orphaned temporary SSTs (when two-phase CAS is used)
These objects reside in three locations:
* `manifest`
* `wal`
* `levels`
The garbage collector will periodically list all objects in the `manifest`, `wal`, and `levels` directories and delete the following:
1. Any `.manifest` that is not an active manifest.
2. Any other file in `manifest` that has an object storage creation timestamp older than a day.
3. Any `.sst` in `wal` that is < the minimum `wal_id_last_compacted` in the set of all active manifests.
4. Any other file in `wal` that has an object storage creation timestamp older than a day.
5. Any `.sst` in `levels` that is not referenced by any active manifest.
*NOTE: These rules follow the definitions for active manifests and active SSTs in the *Snapshots* section.*
#### Transactional Store Deletion
[Section titled “Transactional Store Deletion”](#transactional-store-deletion)
Two classes of records must be deleted from the transactional store:
* Inactive manifests
* Inactive SSTs
The garbage collector will periodically delete all transactional store records with the following criteria:
1. `destination` references a `.manifest` that is not the latest and is not referenced by any snapshot in the latest manifest.
2. `destination` references an `.sst` in `wal` that is < `wal_id_last_compacted` in the current manifest.
*NOTE: We use `<` not `<=` for (2) because the compactor must not delete the SST at `wal_id_last_compacted` so readers can recover the `writer_epoch` from the SST.*
## Rejected Solutions
[Section titled “Rejected Solutions”](#rejected-solutions)
### Update Manifest on Write
[Section titled “Update Manifest on Write”](#update-manifest-on-write)
A previous [manifest design](https://github.com/slatedb/slatedb/pull/24/) proposed transactional manifest updates on every `wal` SST write (discussed [here](https://github.com/slatedb/slatedb/pull/39#issuecomment-2094356240)). This was rejected due to the high cost of transactional writes when using object storage with CAS for the manifest.
### Epoch in Object Names
[Section titled “Epoch in Object Names”](#epoch-in-object-names)
A previous [manifest design](https://github.com/slatedb/slatedb/pull/24/) proposed namespacing `wal` filenames with a writer epoch prefix (discussed [here](https://github.com/slatedb/slatedb/pull/24/files#diff-58d53b55614c8db2dd180ac49237f37991d2b378c75e0245d780356e5d0c8135R114)). This was rejected because it was deemed complex. We might revisit this decision in the future if we have a need for it.
Another alternative to two-phase CAS was proposed [here](https://github.com/slatedb/slatedb/pull/43#discussion_r1597308492). This alternative reduced the operation to a single object storage PUT and single DynamoDB write. In doing so, [it required `writer_epoch` in object names](https://github.com/slatedb/slatedb/pull/43#discussion_r1597317062). This scheme prevented *real* CAS from working. We rejected this design because we decided to favor a design that worked well with real CAS.
### Object Versioning CAS
[Section titled “Object Versioning CAS”](#object-versioning-cas)
A [previous version](https://github.com/slatedb/slatedb/blob/6beba6949487a519b8bb6c69a3cf80f06778f540/docs/0001-manifest.md) of this design used object versioning for `wal` SST CAS. This design was rejected because S3 Express One Zone does not support object versioning.
### Use a `manifest/current` Proxy Pointer
[Section titled “Use a manifest/current Proxy Pointer”](#use-a-manifestcurrent-proxy-pointer)
A [previous version](https://github.com/slatedb/slatedb/blob/6beba6949487a519b8bb6c69a3cf80f06778f540/docs/0001-manifest.md) of this design contained an additional file: `manifest/current`. Once we decided to use incremental IDs for the manifest, we no longer needed this file.
### Two-Phase Mutable CAS
[Section titled “Two-Phase Mutable CAS”](#two-phase-mutable-cas)
We [briefly discussed](https://github.com/slatedb/slatedb/pull/43#discussion_r1597489292) supporting mutable CAS operations with the two-phase write CAS pattern. There was [some disagreement](https://github.com/slatedb/slatedb/pull/43#discussion_r1597749655) about whether this was possible. It’s conceivable that user-defined attributes might allow the two-phase CAS pattern to handle mutable CAS operations. We did not explore this since we decided to use incremental IDs for the manifest, which require only immutable CAS.
### DeltaStream Protocol
[Section titled “DeltaStream Protocol”](#deltastream-protocol)
We [considered using a protocol](https://github.com/slatedb/slatedb/pull/43#discussion_r1597543706) similar to DeltaStream’s. This protocol is similar to two-phase CAS, but uses a DynamoDB pointer to determine the current manifest rather than a LIST operation. This protocol wasn’t obviously compatible with SST writes, though. We opted to have a single CAS approach for both the manifest and SSTs, so we rejected this design.
### `object_store` Locking
[Section titled “object\_store Locking”](#object_store-locking)
Rust’s `object_store` crate has a [locking mechanism](https://docs.rs/object_store/latest/object_store/aws/enum.S3CopyIfNotExists.html). We briefly looked at this, but [rejected it](https://github.com/slatedb/slatedb/pull/43#discussion_r1597551287) because it uses time to live (TTL) timeouts for locks. We wanted a CAS operation that would not time out.
### Atomic Deletes
[Section titled “Atomic Deletes”](#atomic-deletes)
A [previous version](https://github.com/slatedb/slatedb/pull/43#discussion_r1600912132) of this design proposed using atomic deletes. This turned out to be unnecessary.
## Addendum
[Section titled “Addendum”](#addendum)
We found that Deltalake [has a locking library](https://github.com/delta-incubator/dynamodb-lock-rs). We thought it might be used for its LSM manifest updates, but it’s not. We discuss our findings more [here](https://github.com/slatedb/slatedb/pull/24/files#r1582427235).
While exploring CAS with object store versioning, we found that Terraform had explored this idea in their [S3 backend](https://github.com/hashicorp/terraform/issues/27070). Ultimately, they’re trying to create file locks, which is different from what we’re doing with our manifest.
# Compaction
Status: Accepted
Authors:
* [Rohan Desai](https://github.com/rodesai)
References:
*
*
*
*
*
*
*
*
*
## Current Implementation
[Section titled “Current Implementation”](#current-implementation)
SlateDB maintains a live memtable, a set of immutable memtables, and metadata about SSTs in the WAL. `put()` writes the key-value into the memtable. At some interval, the flushing thread closes the memtable and flushes the contents to a new WAL SST. Immediately before the flush, the memtable is converted to an immutable memtable.
`get()` operations search the current memtable, immutable memtables, and WAL SSTs in reverse-write order (most recent SSTs first). The search is terminated when the key specified in `get` is found.
## Problem
[Section titled “Problem”](#problem)
There are a few problems with the current implementation that we address with this design:
1. As the DB grows, `get()` becomes very expensive because it has to search an ever-growing list of SSTs.
2. Similarly, as the DB grows, start becomes very slow because SlateDB has to reload the state of all the SSTs
3. The storage space used grows without bound even if the key space is finite. This is less of a problem for SlateDB as compared to disk-based databases because space in s3 is practically unlimited, and is relatively cheap (e.g. .023$/GB vs .08$/GB for EBS (.16$/GB for HA)). Still it is not ideal - storage is cheaper but not free. More troubling is that SlateDB relies on caching metadata effectively for good read performance. This gets harder and harder to do as the number of SSTs grows.
## Goals
[Section titled “Goals”](#goals)
* Compact WAL SSTs into larger SSTs so that space from overwrites and deletes can be reclaimed, and reduce read amplification.
* Further compact compacted SSTs to further reduce read and space amplification.
* Define a simple initial compaction algorithm that balances the various types of amplification.
* Allow for flexibility in the specific compaction algorithm. This is likely going to be an area that we can uniquely innovate, and the design should support easily iterating on compaction scheduler.
* Split some compaction work between the writer and compactor processes to allow for better network utilization.
* Support manual major compaction that compacts the whole database.
## Non-Goals
[Section titled “Non-Goals”](#non-goals)
The following are out-of-scope for this design. That said, the design should not preclude them. For some items, we will describe how the design can be adapted to achieve these goals in follow-on work.
* Optimize for specific workloads. For example, some databases optimize for in-order bulk inserts by avoiding compaction and simply writing out the database in its fully compacted form as inserts arrive.
* Similarly, specialized compaction policies optimized for specific workloads are out-of-scope. For example, some databases support specialized compaction for time series data with a finite lifetime.
* Resumable compaction. This proposal will not define how to resume a long-running compaction after a compactor restart. This is important to support, as compactions could take 10s of minutes, and the compactor should not have to restart them after a failure. I’ll include a section at the end on how we can extend the design to support this.
* GC of unused SSTs is excluded from this design
## Proposal
[Section titled “Proposal”](#proposal)
### Amplification
[Section titled “Amplification”](#amplification)
The design for compaction depends on the database and workload sensitivity to write, read, and space amplification. We will likely ultimately need to support a variety of compaction strategies to accommodate different types of workloads. However there are some common considerations given SlateDB’s object-store based architecture:
1. write amplification: write amplification refers to the added work done by compaction for every write. This is usually measured as some multiplier. For example, if a write is written to the database 5 times (once for the initial write and compacted 4 times) - write amplification is 5x. Write amplification is not much of a concern from a cost perspective. The major cloud providers don’t charge for data transfers to/from the “standard” tier of object storage (provided you set up networking correctly). There are charges to/from zonal tiers like s3express, but I expect that most of the data will reside in the regional tier. Write amplification is a concern from a bandwidth usage perspective. If write amplification is too high, writes will become bottlenecked on the compactor node’s network. This is usually a higher limit than local or attached disk, but not an order of magnitude higher (\~1.5-2x nw-out baseline:local disk write). So we will need to take care to avoid too much write amplification to support write-heavy workloads.
2. read amplification: read amplification refers to the added work done by the database for reads. This includes added cpu from searching through multiple possible locations for a read, and added i/o from reading multiple locations. We certainly don’t want to be doing multiple, if any, GET operations per SlateDB read, as GETs are expensive and slow. Read amplification can be mitigated by effective caching. Ideally, for optimal performance and cost, reads can be served entirely from cache, which spans memory and local disk. Still, we probably don’t want to be doing multiple disk reads per read or we may saturate the local/attached disks. Therefore it’s desirable that the filter and index blocks used for most reads fit in memory. All of this is to say that it is pretty important for SlateDB to reduce read amplification. We need the set of SSTs to search for a read to be small enough so that indexes/filters can fit in memory, and data being read fits in total cache most of the time.
3. space amplification: SlateDB is not as sensitive to space amplification as disk-based databases are because storage is practically unbound, and is cheap (about 1/5th-1/20th the cost of an on-disk db on s3 depending on replication factor, instance stores vs ebs, reserved vs on-demand, etc). One problem that we can totally sidestep is transient space amplification from compaction. Most dbs suffer from this problem and have to either over-allocate disk space or implement incremental compaction. This won’t be an issue for us. Our main concern with space amplification is that it grows the search space for reads as described above.
### High Level Overview
[Section titled “High Level Overview”](#high-level-overview)
In the rest of this document, we propose mechanisms for compacting together SSTs to purge duplicates and keep the total count of SSTs contained. Compaction will be executed both by the main writer and by a separate compactor process.
The main writer compacts the WAL to the first level of the database. This has a number of benefits: As we describe below, the main writer compacts the WAL directly from memory without reading the data back from S3 first. This saves cost for low-latency writers and more importantly, reduces load on the network. This offloads some of the compaction i/o from the compactor onto the writer. Assuming the main bottleneck for compaction will be network, this should help us achieve more throughput. It should be easier to coordinate between the compactor and writer for lower-frequency compactions to lower levels than WAL->L0 compactions.
The compactor is responsible for compacting L0 and the lower levels of the database. Compacted SSTs are maintained in a series of Sorted Runs (SRs). Each SR spans the full keyspace of the database. A SR is made up of an ordered series of SSTs, each of which contains a distinct subset of the total keyspace. We use Sorted Runs instead of large SSTs because it is a simple way to keep the size of the metadata blocks small enough so that they can be easily paged in and out of cache without polluting the cache. Larger SSTs have larger metadata blocks. It’s expensive to page them in and out of cache, and they are likely to displace other data that should be retained by the cache to achieve a high hit rate.
The SRs are themselves ordered by age. When executing point lookups, SlateDB looks up the value for a key in age-order, and terminates the search at the first SR that contains the key. Range scans read every SR and sort-merge the result.
### Manifest Changes
[Section titled “Manifest Changes”](#manifest-changes)
```plaintext
table SortedRun {
id: uint32;
ssts:[SstId];
}
table SstId {
high: uint64;
low: uint64;
}
table Compacted {
runs: [SortedRun];
}
table Manifest {
…
l0_last_compacted: CompactedSstId // The last compacted l0
l0: [SstId];
compacted: Compacted;
…
}
```
We propose to augment the manifest by adding the following fields:
`L0`: Contains a list of SST IDs in L0
`compacted`: Contains a single instance of `Compacted`. `Compacted` contains a list of `SortedRun` instances. A `SortedRun` instance defines a single sorted run. Each Sorted Run contains a list of SST IDs and has a unique ID. The list of SST IDs defines the SSTs that comprise the sorted run. A given SST belongs to at most 1 SR. The ID describes the SR’s position in the list of sorted runs in `compacted`. That is, an SR S with an S.id must occur after SR S’ with ID S’.id if S.id < S’.id (so the sorted run with ID 0 must be last in the list). The last SR in the list must have ID 0. The semantics of the ID will be important when we describe how to define compactions.
`l0_last_compacted`: Contains the ID of the last l0 SST that was compacted to `compacted`. The compactor uses this value to determine which l0 SSTs it needs to consider for compaction when refreshing its view of state with the manifest stored durably.
#### Naming Compacted SSTs (L0 and L1+)
[Section titled “Naming Compacted SSTs (L0 and L1+)”](#naming-compacted-ssts-l0-and-l1)
We will use ULIDs to name compacted SSTs. The ULID is stored in the manifest in the `SstId` table, with the `high` and `low` fields containing the high and low bits of the ULID, respectively.
In the Object Store, compacted SSTs are stored under the compacted directory. Each SST object is named using its ULID and the suffix `.sst`, e.g:
```plaintext
/compacted/01ARZ3NDEKTSV4RRFFQ69G5FAV.sst
/compacted/01BX5ZZKBKACTAV9WEVGEMMVRZ.sst
…
```
### DB Options
[Section titled “DB Options”](#db-options)
Compaction behavior is governed by the following DB options:
`l0_sst_size_bytes`: defines the target L0 SST size in bytes.
`l0_manifest_commit_interval_ms`: defines the minimum interval between memtable flush-related manifest updates in milliseconds.
`l0_compaction_threshold_ssts`: defines the threshold number of SSTs for compacting L0. (default 8)
`l0_max_ssts`: defines the maximum number of uncompacted L0 SSTs. (default 16)
`max_compactions`: defines the max number of concurrent compactions. (default 4)
`compaction_scheduler`: defines the compaction scheduler (currently only supports the value `tiered`)
`compaction_scheduler_tiered`: An options struct that defines scheduler options for tiered compaction.
`compaction_scheduler_tiered.level_compaction_threshold_runs`: defines the threshold number of sorted runs for compacting a lower level (specific to the tiered compaction scheduler). (default 8)
`compaction_scheduler_tiered.level_max_runs`: defines the maximum number of sorted runs at alower level (specific to the tiered compaction scheduler). (default 16)
### WAL->L0 Compaction
[Section titled “WAL->L0 Compaction”](#wal-l0-compaction)
The writer is responsible for compacting the WAL to the first level of the database. It does this directly from the memtable rather than reading back the WAL first. Instead of freezing the memtable when writing the WAL, it instead retains it until enough data has accumulated to fill an L0 SST. Only then does it freeze the memtable and write out an L0 SST. When it’s been longer than `l0_manifest_commit_interval_ms` since the last manifest update, the writer updates the manifest with the new SSTs. The L0 writes are fenced by virtue of committing L0 SSTs in a manifest update. The newly written L0 SSTs are only considered part of the database when the manifest has been updated.
Let’s look at the write and recovery protocols in more detail:
write:
1. `put` adds the key-value to the current memtable and updates the WAL using the protocol described in [the manifest design](https://github.com/slatedb/slatedb/blob/main/rfcs/0001-manifest.md).
2. If the current memtable is larger than `l0_sst_size`, freeze the memtable by converting it to an immutable memtable, and write the memtable to a new ULID-named SST S in `compacted`.
3. When S and all earlier SSTs S’, S’’, … for unflushed immutable tables are written:
1. update L0 in-memory by prepending S, S’, S’’, … to the list of SSTs in L0.
2. clear the immutable memtables for S, S’, S’’,…
3. if time since last manifest update > `l0_manifest_commit_interval_ms`:
1. if (number l0 SSTs > l0\_max\_uncompacted):
1. pause new writes
2. wait till number of l0 SSTs < l0\_max\_uncompacted
2. unpause writes if paused
3. update the manifest using CAS with the following modifications:
1. update `last_compacted` to the last WAL SST included in S
2. update `l0` by prepending S, S’, S’’, … to the list
4. If CAS from (3) fails:
1. If CAS fails and the manifest has a different writer epoch, exit
2. If CAS fails and the manifest has the same writer epoch, go back to 3.iii.c
5. If CAS from (3) succeeds, remove the immutable memtable.
write-recovery:
1. Fence older writers as described in the [manifest design](https://github.com/slatedb/slatedb/blob/main/rfcs/0001-manifest.md).
2. Create a new mutable memtable
3. For every WAL SST W after `last_compacted`, reapply the writes as described above in the write protocol.
TODO: should we have some way to bound the number of immutable memtables in memory? I left it out since we are free to purge them once their SSTs have been written (even before we update the manifest). But this doesn’t help us if we can’t write the SSTs to S3 for some reason - though in that case we likely can’t commit writes anyway.
### Compacting Lower Levels
[Section titled “Compacting Lower Levels”](#compacting-lower-levels)
The Compactor compacts L0 and the lower levels. It contains two logical processes: a Compaction Executor and a Compaction Scheduler. The Compaction Scheduler observes the current state of the database and schedules Compactions. The Compaction Executor bootstraps the Compactor, executes the Compactions scheduled by the Compaction Scheduler, and notifies the Compaction Scheduler about status. The Compaction Executor is fixed, while The Compaction Scheduler is modular to allow SlateDB to support different compaction styles. The specific scheduler is specified in the `compaction.scheduler` db option. Initially, we will implement a single scheduler that performs tiered compaction.
#### Interfaces
[Section titled “Interfaces”](#interfaces)
Lets start by defining the interface between the CompactionExecutor and CompactionScheduler. I’ll define them as Rust structs/traits. Then, we discuss how each component implements the interfaces.
##### Compactions
[Section titled “Compactions”](#compactions)
The Compaction Scheduler tells the Compaction Executor what compactions to execute. A Compaction is defined using the following parameters:
* Sources: A list of one or more sources of data to compact. A source can either be a single L0 SST, or a single SR. The Sources must be logically consecutive. This means that for any sources S1 and S2 where S2 appears immediately after S1 in the list:
* If S1 is an L0 SST, then S2 must either be the next L0 SST OR if S1 is the last L0 SST then S2 must be the first SR (SR with the highest ID)
* If S1 is an SR, then S2 must be the next SR.
* Destination: A destination SR. This can be a new SR, or it can be the SR from Sources with the lowest ID. If it’s a new SR, The SR must be logically consecutive to the last element of Sources (as described above).
Let’s look at some examples of valid/invalid compactions. I’ll use string IDs for SSTs here instead of ULIDs. Suppose our manifest looks like:
```plaintext
l0: [SST-4, SST-3, SST-2, SST-1]
compacted: [100, 50, 3, 1, 0]
```
Here are examples of valid/invalid compactions (I’m using the notation Sources->Destination)
`[SST-2, SST-1]->101`: This describes compacting the oldest 2 L0 SSTs to a new SR
`[SST-4, SST-3]->101`: This is invalid because it skips SST-2 and SST-1
`[SST-1, 100]->100`: This describes compacting the oldest L0 SST (SST-1) and SR 100 and saving the result as SR 100
`[100, 50]->2`: This is invalid because it writes the result to an SR that is not consecutive to 50 (3 is consecutive to 50)
`[SST-4, SST-3, SST-2, SST-1, 100, 50, 3, 1, 0]->0`: This describes a major compaction that compacts everything and saves it as SR 0
Observe that we can use this basic definition to describe compactions done by different compaction algorithms (this isn’t strictly true in the above proposal - e.g. it doesn’t currently support some-to-all compactions like compacting a single SST from one SR into another SR, but that’s a fairly straightforward extension to the definition of a source) - it’s up to the Compaction Scheduler to decide what compactions to execute. The scheduler can choose to implement leveled compaction by viewing each SR as a level and scheduling Compactions that always merge one SR into the next SR. Or it can implement tiered compaction by grouping SRs into levels and define compactions that merge all the SRs in a level into a new SR at the next level. The levels themselves are a logical construct maintained by the scheduler.
In Rust, this looks like:
```plaintext
union SourceId {
sorted_run: u32,
sst: Ulid,
}
struct Compaction {
id: u32 // a unique identifier for the compaction (it must be unique to the process’s lifetime)
sources: Vec
destination: u32
}
```
##### Compaction Executor
[Section titled “Compaction Executor”](#compaction-executor)
The Compaction Executor provides the following interface to the CompactionScheduler:
```plaintext
trait CompactionExecutor {
/*
* Notifies the compaction executor about a new compaction to execute. The result
* is Ok if the compaction was accepted by the executor. This does not mean that the
* compaction was completed. The executor validates the compaction and returns an
* error if the compaction is invalid.
*/
fn submit(&self, compaction: Compaction) -> Result<(), Error>
}
```
##### Compaction Scheduler
[Section titled “Compaction Scheduler”](#compaction-scheduler)
The Compaction Scheduler provides the following interface to the Compaction Executor:
```plaintext
enum CompactorUpdateKind { DBState, CompactionFinished }
struct DBStateUpdate {
kind: CompactorUpdateKind // always DBState
state: DBState, // we probably don’t want to use DBState here, but rather a subset that contains compactor-relevant state like l0 and compacted. But, you get the idea.
}
struct CompactionFinished {
kind: CompactorUpdateKind // always CompactionFinished
compaction_id: u32,
state: DBState,
}
union CompactorUpdate {
db_state: DBStateUpdate,
compaction_finished: CompactionFinished,
}
trait CompactionScheduler {
/*
* Notifies the scheduler that it should start evaluating the db for compaction. This method
* receives a channel over which the Executor sends the Scheduler updates about changes
* to the database. This includes updates about changes to the database (e.g. arrival of
* new L0 files), and completion of compactions.
*/
fn start(&self, executor: Box, chan: Receiver);
}
```
#### Compaction Executor
[Section titled “Compaction Executor”](#compaction-executor-1)
The Compaction Executor initializes as follows:
1. Update the `compactor_epoch` in the manifest using CAS
2. Initialize the Compaction Scheduler by creating an instance based on `compaction.scheduler`, creating, a channel, and calling `start`
3. Send the initial db state (as returned in the manifest) over the channel
Then, the compactor periodically polls the manifest. On every poll, the compactor:
1. Check if the `compactor_epoch` is different than the compactor’s epoch. If it is, exit.
2. If the db has new l0 SSTs, send a `DBStateUpdate` message over the scheduler channel.
##### Executing a Compaction
[Section titled “Executing a Compaction”](#executing-a-compaction)
The Compaction Executor implements `compact` by:
1. Validate the compaction by running through the following checks. If any fail, return error
1. Make sure the compaction is valid as defined above in the Compaction section
2. Make sure there is no other ongoing compaction that includes the SSTs or SRs referenced by the compaction.
2. At this point, the call to `compact` returns.
3. Schedule the compaction for execution in the background.
The Compaction Executor executes the compaction by reading the SSTs and SRs in `sources` and sort-merging them into a new SR.
The Compaction Executor needs to coalesce updates. If the same key appears in multiple sources, then it takes the value from the logically latest (i.e. most recent) source. The Compaction Executor handles destination SR 0 specially. If the destination SR is 0, and the value for a key is resolved to a tombstone, then the Compaction Executor will not include the key in the resulting SR.
The new SR is made up of ULID-named SSTs in the `compacted` directory (just like L0).
We should implement the sort-merge so that we can make good use of the available network. One good option here is to use `async` Object Store APIs to concurrently read the various sources, and then to write the resulting SSTs while we move on to the next key ranges. I think the details are something we can work out in the implementation, and it doesn’t have to be optimal in this iteration of work.
Note that compacting whole sorted runs can create a lot of temporary space amplification, especially for compactions that read the last level. This is not a major concern for SlateDB as ObjectStore capacity is practically infinite, and the usage is temporary so it should not contribute meaningfully to cost.
When the new SR has been fully written out, the Compaction Executor finishes the compaction by:
1. Read the existing manifest and verify that the `compactor_epoch` matches the compactor’s epoch. If it does not, exit.
2. Generate a new manifest that has the new SR in `compacted` and the SR and SSTs from `sources` removed.
3. Write the new manifest using CAS. If CAS fails, go back to 1
4. Send the Compaction Scheduler a CompactionFinished message with the compaction ID and the updated DB state.
#### Compaction Scheduler
[Section titled “Compaction Scheduler”](#compaction-scheduler-1)
The Compaction Scheduler is responsible for selecting the next Compaction. Our goal here is to implement something simple that works, and then iterate/optimize on it in future cycles. Initially we propose to implement basic tiered compaction, which tries to maintain sorted runs in size-based levels, and constrains the number of sorted runs in a given level by merging the runs together when there are too many of them, usually moving the resulting run to the next level.
We choose tiered compaction because it works well for workloads with a moderate to high volume of writes because it has lower write amplification than leveled compaction (usually by a factor of T where T is the fanout for each level). It still guarantees that the total number of runs is proportional to O(log(N)) where N is the size of the db, but allows multiple runs to accumulate at a level to reduce the amount of merging (at the cost of a multiplier T on the number of runs, where T is the number of runs that can accumulate at a level - so it’s really O(Tlog(N))). So the drawback is that there can be significant space and read amplification. However, I think there are reasonable ways to work around most of these drawbacks for now, and we can trade off some write amplification for better read/space amplification down the line by implementing “lazy-leveling” as described in [dostoevsky](https://scholar.harvard.edu/files/stratos/files/dostoevskykv.pdf):
* Tiered compaction maintains `level_compaction_threshold_ssts` sorted runs even at the lowest level, which means the entire keyspace may be copied `level_compaction_threshold_runs` times. This is somewhat concerning from a cost pov, however as explained at the beginning of the design Object Store costs are much lower than block device costs (by a factor of 5-20x), so this is less of a concern for SlateDB than traditional stores.
* The more concerning consequence of high space amplification is that it will likely directly lead to requiring a proportionally larger cache to achieve high hit rates for good read performance and cost. We can deal with this at first by allocating a larger cache. Eventually, we can do leveled compaction to the final level (meaning, maintain a single run at the final level) to dramatically reduce space amplification as described in the doestoevsky paper. Further, we can estimate space amplification by looking at the relative size of the non-last levels and final level, and execute compactions when amplification crosses some threshold.
* Tiered compaction also yields a db with more Sorted Runs, which adds to the cost of point lookups. Impact to point lookup cost should be dramatically reduced by SlateDB’s usage of bloom filters. However, more Sorted Runs does mean that we will need more SST reads on average (as the expected number of bloom filters that return a fp increases linearly with the number of Sorted Runs). There are a number of approaches to solving this problem:
* Universal compaction in RocksDB takes the approach of compacting when the total number of runs crosses some threshold.
* [Monkey](https://stratos.seas.harvard.edu/files/stratos/files/monkeykeyvaluestore.pdf) describes a simple optimization we can adapt that reallocates bloom filter bits from lower levels to higher levels to dramatically decrease the false-positive-rate (fpr) of higher-level filters at the cost of higher fpr at lower levels, but yielding a much lower average number of lookups (see [here](http://daslab.seas.harvard.edu/monkey/) for a good visualization).
* Short range-scans are fundamentally worse with tiered storage as they need to examine a roughly equal amount of data in every Sorted Run. If it’s problematic we can adjust compaction to more aggressively merge runs together with some goal for the total number of runs (similar to RocksDB’s universal compaction). Long range-scans are not as bad as they derive most of their cost from the final level, and with lazy-leveling there is a single final level.
All of this is to say, Tiered compaction feels like a great starting point for a general-purpose store, and we have reasonable short-term workarounds and long term solutions to most of the problems that we anticipate with tiered compaction.
SlateDB’s tiered Compaction Scheduler will work as follows:
1. Whenever a new CompactorUpdate arrives on the scheduler channel:
1. The Compaction Scheduler groups SRs into levels L1, L2,… A level with a larger index is considered “lower” (ugh - this is confusing). The size of the runs in LN is at most `l0_sst_size X l0_compaction_threshold X compaction.scheduler.tiered.level_compaction_threshold^N`
2. Iterate over the levels from lowest to highest. For each level, maybe schedule a compaction. The Compaction includes all SRs in the level as its source (we could probably include compactions for the higher level as well if it needs to be compacted, and so on - but we can add this later). The destination SR ID is the ID of the last SR in the level. Schedule a compaction for level N if:
1. The number of SRs in N > `compaction.scheduler.tiered.level_compaction_threshold_runs`
2. The number of SRs in N+1 < `compaction.scheduler.tiered.level_max_runs`
3. The number of uncompleted compactions < `max_compactions`
4. No ongoing compaction from level N
3. Maybe schedule a compaction for L0. The Compaction includes all SSTs in L0 as its source. The destination SR ID is the highest unused SR ID. Schedule a compaction for L0 if:
1. The number of SRs in L0 > `l0_compaction_threshold_ssts`
2. The number of SRs in L1 < `compaction.scheduler.tiered.level_max_runs`
3. The number of uncompleted compactions < `max_compactions`
4. No ongoing compaction from L0
### Back-Pressure
[Section titled “Back-Pressure”](#back-pressure)
The design described above applies back-pressure so that we don’t wind up writing faster than we can compact and get unbounded read/space amplification. SlateDB blocks new writes if the number of SSTs in L0 exceeds `l0_max_ssts`. This shouldn’t happen if compaction is keeping up and is able to merge SSTs from L0 to L1. Compactions to L1 are blocked if the number of runs in L1 is greater than `compaction.scheduler.tiered.level_max_runs`. Similarly, Compactions to L2, L3, … LN are blocked if the number of runs in those levels exceeds the threshold.
### Reads
[Section titled “Reads”](#reads)
*Point-Lookups*
The reader looks up keys in the following order, and terminates the search at the first item that contains a given key: memtable immutable memtable L0 SSTs, in order SRs, in order
*Range Scans*
To serve Range Scans, the reader needs to look through every memtable, immutable memtable, SST, and SR and sort-merge the key-values that fall within the range.
### Running the Compactor
[Section titled “Running the Compactor”](#running-the-compactor)
By default, the compactor will run alongside the main writer-reader in the same process. We will also support running the compactor in a separate process by initializing and running just the compactor.
### Looking Ahead
[Section titled “Looking Ahead”](#looking-ahead)
In this section I want to briefly sketch out how we can iterate on the design described above to add some additional features.
#### Resuming Compaction
[Section titled “Resuming Compaction”](#resuming-compaction)
Compactions targeting lower levels can take a long time. If the compactor restarts, we don’t want to lose compaction progress. We can record what compactions were ongoing in the manifest by defining a flatbuffer table schema that describes the Compaction struct defined above. Then, when the compactor restarts it knows what compactions were already scheduled.
It’s not enough to know what compactions were ongoing - a new compactor also needs to be able to reconstruct compaction progress. We can leave breadcrumbs to allow it to piece this information together:
To do this we can allow including uncompleted SRs in the list of SRs in the manifest. We can do this by including a `completed` flag in the SR definition. For a given SR ID there should only be one with `completed` set to `true`, which is what the reader would use to serve reads. Then, the new compactor can inspect the SSTs in the new SR, and see what key ranges have already completed compaction, and finish compacting the uncompacted key ranges.
#### Lazy-Leveling/Tiered+Leveled
[Section titled “Lazy-Leveling/Tiered+Leveled”](#lazy-levelingtieredleveled)
The tiered compaction scheduler proposed in this document will have very high space amplification, as it maintains multiple SRs at the lowest level. It’s likely that each SR at the lowest level contains most of the database, assuming a stable db size. As described above, to fix this (at the cost of added write amplification) we can always maintain a single run at the lowest level. In particular, our tiered compaction policy can always maintain SR 0 as its own level. When the earlier level is full, it can compact (at least) all SRs from that level into SR 0.
#### Time-Series
[Section titled “Time-Series”](#time-series)
Some databases (e.g. ScyllaDB) handle time-series data with a fixed lifetime specially. They maintain data in fixed time buckets that are themselves compacted according to some schedule (e.g. tiered or leveled), where each bucket corresponds to a distinct non-overlapping key range. Then, when a given bucket is no longer accessible (as defined by the fixed lifetime), the entire bucket is removed from the database.
We can implement such a Scheduler fairly easily. The main challenge is that we need to either:
1. relax the compaction constraints so that we allow compacting non-contiguous SRs. Then, the scheduler can map each SR to some bucket, and only compact SRs that are in the same bucket. Reads would still work because SR order is preserved for a time bucket.
2. Alternatively, we can extend the model in the manifest to allow a collection of `Compacted`. Then, the scheduler can maintain each bucket in its own `Compacted` instance that’s associated with some key range. Reads would need to be aware of this mapping. When deleting a bucket the whole `Compacted` instance is dropped from the mapping.
#### Distributed Compaction (e.g. scheduling larger compactions on temporary large instances)
[Section titled “Distributed Compaction (e.g. scheduling larger compactions on temporary large instances)”](#distributed-compaction-eg-scheduling-larger-compactions-on-temporary-large-instances)
One of the main advantages of running in cloud is that applications can dynamically provision resources for a short time to burst capacity, since compute is easily provisioned and billed only for the time its provisioned. This means that for really expensive compactions, we should be able to quickly spin up a short-lived but beefy compactor node to execute the compaction. There’s nothing in the design above that precludes us doing this. The compactor could in the future with some (probably pluggable depending on whether a compactor runs on k8s, or directly on a compute service like ec2) API for provisioning, provision a short-lived node, notify it about compaction work, and then commit the manifest when the compaction work is complete.
It’s worth calling out that we already get some of this benefit as most instances have burstable network (e.g. an instance with a 5Gbps baseline network can burst up to 25Gbps for a short duration).
## Appendix
[Section titled “Appendix”](#appendix)
### Network Bandwidth vs Disk Bandwidth on AWS
[Section titled “Network Bandwidth vs Disk Bandwidth on AWS”](#network-bandwidth-vs-disk-bandwidth-on-aws)
| Instance Type | Network Bandwidth MBps (full-duplex) (Baseline/Burst) | Instance Disk Bandwidth MBps (assumes 4K max io size - experimentally verified for xlarges) (Read/Write) | EBS Bandwidth MBps (full-duplex) (Baseline/Burst) |
| ------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| m5d.xlarge | 160/1280 | 230/113 | 143.75/593.75 |
| m5d.metal | 3200/3200 | 5468/2656 | 2375/2375 |
| i3en.xlarge | 537/3200 | 332/253 | 144.2/593.75 |
| i3en.metal | 12800/12800 | 7812/6250 | 2375/2375 |
| c5d.xlarge | 160/1280 | 156/70 | 143.75/593.75 |
| c5d.metal | 12800/12800 | 5468/2656 | 2375/2375 |
# Timestamps & Time-To-Live
Status: Accepted
Authors:
* [Almog Gavra](https://github.com/agavra)
References:
*
*
*
*
*
*
*
*
## Motivation
[Section titled “Motivation”](#motivation)
SlateDB does not have a native understanding of time in data operations (it does in regard to compaction/garbage collection, discussion on this later). This introduces additional complexity to operators that want to implement semantics that depend on the insertion/modification time of rows. The most common usages of timestamps are:
1. **Time-To-Live**: many applications, either for compliance or space-saving purposes require purging data a certain amount of time after its insertion. Use cases with time series data and privacy requirements such as GDPR come to mind here, though ensuring that deleted data is collected within a time bound is left for a future exercise.
2. **Resolving Out-of-Order Operations (out of scope)**: some applications may process incoming requests in a manner that results in out-of-order insertions to SlateDB (stream processing applications are frequently subject to this type of pattern). Using insertion timestamps to resolve which version of a key is more recent may be preferable to using the clock at the time of insertion.
3. **Historical Reads (out of scope)**: while SlateDB provides snapshot capabilities to ensure reads are consistent and repeatable, it requires a snapshot to have been taken at the desired time. Increasing the number of snapshots adds significant storage overhead. Instead, it is possible to trade read-latency for space amplification by maintaining multiple versions of keys at different timestamps to allow for similar “historical” read operations.
## Goals
[Section titled “Goals”](#goals)
* Define the API for specifying TTL on writes
* Define the changes to the on-disk format of stored rows
* Outline mechanism to support Time-To-Live (TTL) via Compaction Filters
## Out of Scope
[Section titled “Out of Scope”](#out-of-scope)
While the following are out of scope for the initial implementation, the design should not preclude:
* Handling out-of-order insertions is not in scope for this RFC, though we make sure the design accounts for the future possibility (see rejected alternatives section)
* Outlining compaction strategies that are optimized for TTL (such as Cassandra’s [TWCS](https://cassandra.apache.org/doc/stable/cassandra/operating/compaction/twcs.html)). This will require additional metadata per-SST and a compaction strategy to match, but is otherwise possible with what’s outlined in the scope of this design.
* Outlining compaction strategies that target efficient deletes (such as outlined in [Lethe](https://disc-projects.bu.edu/lethe/))
* Ensuring deletions are resolved within a time bound (a TTL does not ensure the data is deleted from the object store within the specified time)
* A public API for compaction filters (see ) - this RFC will implement TTL using a compaction filter but will not yet expose a public API for plugging in custom compaction filters. Instead, the implementation should ensure that such an extension is easy.
## Public API
[Section titled “Public API”](#public-api)
We will make the following changes to `DbOptions`:
* introduce a configuration for a default TTL, which defaults to `None`
SlateDB uses the system clock internally for TTL expiration. The clock returns the system time in milliseconds since the Unix epoch, and SlateDB enforces monotonicity by tracking the last tick and sleeping briefly if clock skew is detected.
```rust
pub struct DbOptions {
// ...
/// The default time-to-live (TTL), in milliseconds, for insertions (note that
/// re-inserting a key with any value will update the TTL to use default_ttl_millis)
///
/// Default: no TTL (insertions will remain until deleted)
default_ttl_millis: Option
}
```
A custom `SystemClock` implementation can be provided via the `Db::builder` API using `with_system_clock()`. This is primarily useful for testing or for environments with non-standard time sources:
```rust
use slatedb::Db;
use slatedb_common::clock::DefaultSystemClock;
use std::sync::Arc;
let db = Db::builder("my_db", object_store)
.with_system_clock(Arc::new(DefaultSystemClock::new()))
.build()
.await?;
```
`Db#put_with_options` can optionally specify a row-level TTL at insertion time by leveraging the `PutOptions` struct:
```rust
pub enum Ttl {
/// Use the default TTL configured in DbOptions
Default,
/// No expiration for this entry
NoExpiry,
/// Expire after the specified duration (in milliseconds)
ExpireAfterMillis(u64),
}
pub struct PutOptions {
/// The time-to-live (ttl) for this insertion. If this insert overwrites an existing
/// database entry, the TTL for the most recent entry will be canonical.
///
/// Default: the TTL configured in DbOptions when opening a SlateDB session
pub ttl: Ttl,
}
```
For this RFC we will not introduce the ability to disable filtering out TTL-expired records on retrieval. The default implementation will read the metadata for every retrieved row and filter out records where `ts + tll < now`. We can, as a future optimization, enable lazy deserialization of the metadata so that this doesn’t need to happen on each row retrieved if the user requests the ability to retrieve TTL’d rows that have not yet been compacted. This can be part of the API design for compaction filters (see )
## Implementation
[Section titled “Implementation”](#implementation)
### Data Format
[Section titled “Data Format”](#data-format)
Currently, SlateDB does not have a mechanism for backwards-compatibility of the SST data format.
We will first introduce the following fields to the flatbuffer definitions:
* `format_version`: field that specifies the codec version that encoded this SST
* `row_features`: an enumeration of metadata fields that are available with each encoded row. This makes it possible in the future to save space per row by disabling row attributes, but that is out of scope for this RFC
* `creation_timestamp`: the time at which this SST file was written. For this RFC this timestamp is used to schedule follow-up compactions (see [periodic compaction](#periodic-compaction)).
* `min_ts`/`max_ts`: metadata that indicates the earliest and latest timestamp values in the SST
```fbs
/// the metadata encoded with each row, note that the ordering of this enum is
/// sensitive and must be maintained
enum RowFeature: byte {
RowFlags,
Timestamp,
TimeToLive,
}
table SsTableInfo {
// ...
// The current encoding version of the data block
format_version: uint;
// The metadata attributes that are encoded with each row. These attributes will be encoded
// in order that they are declared in the RowAttributes enum
row_features: [RowFeature];
// The time at which this SST was created (milliseconds since Unix epoch)
creation_timestamp: long;
// The minimum timestamp of any row in the SST (milliseconds since Unix epoch)
min_ts: long;
// The maximum timestamp of any row in the SST (milliseconds since Unix epoch)
max_ts: long;
}
```
This value will be written when the SST is created. We will read this back using a codec that maps 1:1 with the format version. The version 0 has the following per-value format:
```plaintext
| u16 | var | u32 | var |
|---------|-----|-----------|-------|
| key_len | key | value_len | value |
|---------|-----|-----------|-------|
```
The `format_version` will be bumped to 1 and will be modified to have the following schema:
```plaintext
| u16 | var | var | u32 | var |
|---------|-----|------|-----------|-------|
| key_len | key | attr | value_len | value |
|---------|-----|------|-----------|-------|
```
The newly introduced `attr` field will be decoded using the `row_features` array specified for this SST. Which row attributes are included depend on the data that is being written. If any row in the SST has a `ttl`, the `TimeToLive` feature will be enabled.
As part of this proposal, we will no longer represent tombstones as `INT_MAX` value\_len and instead have an indicator `byte` in the attribute array which specifies the `RowFlags`. `RowFlags` is a bitmask of descriptors on the row. The first bit in `RowFlags` is set to `1` if the row is a tombstone. Other bits are reserved for future flags. Later we can add other types of row attributes as necessary (since flatbuffers does not support single-bit values, a byte is the smallest amount of data we can store here):
```plaintext
|-- attr --|
| u16 | var | byte | u32 | var |
|---------|-----|----------|-----------|-------|
| key_len | key | row_flag | value_len | value |
|---------|-----|----------|-----------|-------|
```
In cases where both `Timestamp` and `TimeToLive` are enabled, the row will look like:
```plaintext
|-------- attr --------------|
| u16 | var | byte | i64 | i64 | u32 | var |
|---------|-----|----------------------------|-----------|-------|
| key_len | key | row_flag | ts | expire_ts | value_len | value |
|---------|-----|----------|-----|-----------|-----------|-------|
```
Both `ts` and `expire_ts` are encoded as `i64` in milliseconds.
The metadata will be returned in the decoded `KeyValueDeletable` which will be modified:
```rust
pub struct KeyValueDeletable {
pub key: Bytes,
pub value: ValueDeletable,
pub attributes: RowAttributes,
}
pub struct RowAttributes {
pub ts: Option,
pub expire_ts: Option,
}
```
### Compaction
[Section titled “Compaction”](#compaction)
#### TTL Compaction Filter
[Section titled “TTL Compaction Filter”](#ttl-compaction-filter)
We will introduce the concept of a compaction filter, which serves as a mechanism for pluggable participation in the compaction process. Specifically, the filter (if configured) will run on each key during the compaction process and optionally remove or modify the value. This is independent of the periodic compaction interval specified in the following section.
For the scope of this RFC, we will be introducing a builtin TS/TTL compaction filter. This filter will add a tombstone to any value where `now > kv.attributes.expire_ts` (where `now` is the current system time in milliseconds). Note that tombstones will still contain a timestamp (see [insertion timestamps](#insertion-timestamps)). If a tombstone is added, the TTL (`expire_ts`) for the row will be cleared, indicating that a future run of this filter should not remove the tombstone.
Since this RFC does not cover the public API for compaction filters, this filter will simply run on any SST that specifies `row_features: [Timestamp && TimeToLive]`.
#### Periodic Compaction
[Section titled “Periodic Compaction”](#periodic-compaction)
If the SST is written with the `TimeToLive` row attribute enabled, the compaction scheduler will compare the SST’s `SsTableInfo#timestamp` with a newly introduced compaction config for periodic compaction time limits:
```rust
use std::time::Duration;
pub struct CompactorOptions {
// ...
// When there are SSTs in L0 (or Sorted Runs with SSTs in other levels) older than
// `periodic_compaction_interval`, SlateDB will include them in the next scheduled
// compaction -- this is a best effort interval and slateDB does not guarantee that
// compaction will happen within this interval (implementations of the compaction scheduler
// are free to implement jitters or other mechanisms to ensure that SSTs are not
// all compacted simultaneously)
periodic_compaction_interval: Duration
}
```
This will ensure that Sorted Runs or SSTs that are not otherwise scheduled by the compaction scheduler will be picked up and TTL / tombstones will be compacted. This is particularly relevant for the lower levels of the LSM tree.
## Rejected Alternatives & Follow-Ups
[Section titled “Rejected Alternatives & Follow-Ups”](#rejected-alternatives--follow-ups)
### Insertion Timestamps
[Section titled “Insertion Timestamps”](#insertion-timestamps)
A previous version of this proposal introduced the concept of row level timestamps — the ability to set a timestamp at each insertion. This introduced the ability to have out-of-order insertions, which in turn caused significant downstream complications.
In order to leave the door open for timestamps on insertion, we encode the clock time with each row in the SST as opposed to only maintaining the time that it should expire. This comes at the cost of an extra 4 bytes when cached in memory (compression will probably mean it is stored as fewer bytes).
Another design consideration in the RFC that leaves the door open for insertion timestamps is the association of a timestamp with tombstones. This is to handle the scenario where a record with a TTL is inserted with a lower timestamp than an existing record. Consider the following:
```plaintext
seq0: Insert K1 with TS=100 and TTL=100000
seq1: Insert K1 with TS=50 and TTL=100
```
If `seq0` and `seq1` result in writes in different levels of the LSM tree, when a compaction kicks in at TS=170 a tombstone is generated for `K1`. This tombstone should not result in a deletion of the original `seq0` insert as it logically happened “after” `seq1`.
## Updates
[Section titled “Updates”](#updates)
### September 25, 2024
[Section titled “September 25, 2024”](#september-25-2024)
* Removed out-of-order insertions
* Removed the functionality to retrieve metadata of a row. Without multiple copies of the row the insertion time is less interesting. we can consider adding this functionality back in upon request.
* Added the requirement for the clock to be monotonically increasing
* Added a timestamp field to `ManifestV1` to seed clock
* Added a creation\_timestamp field to `SsTableInfo` and `periodic_compaction_seconds` to the `CompactorOptions`
* Added a `RowType` metadata feature and used it to replace the current encoding of tombstones
### October 1, 2024
[Section titled “October 1, 2024”](#october-1-2024)
* Changed `RowType` to `RowFlags` and indicated that the first bit is used to identify tombstones
* Added `min_ts`/`max_ts` into `SsTableInfo`
* Updated the seeding of the clock min timestamp to look at the WAL SSTs as well as the manifest
* Modified the stored value in the SST from `ttl` to `expire_ts`
* Marked `periodic_compaction_interval` as “best effort” to allow for implementations to optimize performance characteristics such as adding a jitter or max number of SSTs to compact in one go
* Many misc. improvements to readability and clarifications
### October 6, 2024
[Section titled “October 6, 2024”](#october-6-2024)
* Minor changes and feedback. Most notable is renaming “meta features” to “row attributes”
### January 20, 2026
[Section titled “January 20, 2026”](#january-20-2026)
* **Removed the `LogicalClock` trait and user-defined clock configuration.** The original design allowed users to supply a custom clock implementation for TTL timestamps. This was removed because:
1. The custom clock functionality added complexity that introduced bugs
2. With the introduction of transactions (see RFC 0011), sequence numbers can solve many of the same use cases that user-defined clocks were intended to address (e.g., ordering operations)
* SlateDB now uses the system clock directly for TTL expiration timestamps. The `SystemClock` trait (from `slatedb-common`) returns `DateTime`, and SlateDB extracts milliseconds since epoch internally. A custom `SystemClock` can still be provided via `Db::builder().with_system_clock()` for testing or non-standard time sources.
* Updated `DbOptions` to remove the `clock` configuration option
* Updated `WriteOptions` to `PutOptions` with a `Ttl` enum that supports `Default`, `NoExpiry`, and `ExpireAfterMillis(u64)` variants
# Checkpoints and Clones
Status: Accepted
Authors:
* [Rohan Desai](https://github.com/rodesai)
* [Roman Khachatryan](https://github.com/rkhachatryan)
## Current Implementation
[Section titled “Current Implementation”](#current-implementation)
SlateDB runs a Writer, a Compactor - optionally in a separate process, and a Garbage Collector (GC) - also optionally in a separate process. The writer writes data to L0 SSTs. The Compactor then goes and compacts these L0 SSTs into Sorted Runs (SR) under the “compacted” directory, and then further compacts those SRs as they accumulate. Updates to the current set of L0s and Sorted Runs are synchronized between the Writer and Compactor through updates to the centralized manifest. When reading, the Writer gets a reference to a copy of the metadata for the current set of sorted runs and searches them for the requested data. Meanwhile, the GC scans the SSTs in Object Storage and deletes any SSTs that are not referenced by the current manifest and are older than some threshold.
## Problem
[Section titled “Problem”](#problem)
The current implementation poses a few problems and limitations:
1. There is nothing preventing the GC from deleting SSTs out from the Writer while it’s attempting to read their data. This isn’t a big deal today as we only support key lookups, which are fast. But this could become a more acute problem when we support range scans which can be long.
2. Currently only the Writer process can interact with the database. We’d like to support additional read-only clients, called Readers that can read data.
3. Some use cases can benefit from forking the database and establishing a new db that relies on the original DB’s SSTs but writes new data to a different set of SSTs from the original writer.
## Goals
[Section titled “Goals”](#goals)
In this RFC, we propose adding checkpoints and clones to address this limitation.
* Clients will be able to establish cheap db checkpoints that reference the current version of SlateDB’s manifest.
* Writers will use checkpoints to “pin” a version of the database for reads to avoid the GC from deleting the SSTs.
* Readers will use checkpoints similarly, and many Reader processes can coexist alongside a Writer.
* Finally, we will support creating so-called “clones” from a checkpoint to allow spawning new Writers that write to a “fork” of the original database.
### Out Of Scope
[Section titled “Out Of Scope”](#out-of-scope)
This RFC does not propose:
* Any mechanism to provide isolation guarantees from other concurrent reads/writes to the database.
## Proposal
[Section titled “Proposal”](#proposal)
At a high level, this proposal covers a mechanism for establishing database checkpoints, and reviews how the various processes will interact with this mechanism. Logically, a checkpoint refers to a consistent and durable view of the database at some point in time. It is consistent in the sense that data read from a checkpoint reflects all writes up to some point in time, and durable in the sense that this property holds across process restarts. Physically, a checkpoint refers to a version of SlateDB’s manifest.
### Manifest Schema
[Section titled “Manifest Schema”](#manifest-schema)
Checkpoints themselves are also stored in the manifest. We’ll store them using the following schema:
```plaintext
// Reference to an external database.
table ExternalDb {
// Path to root of the external database
path: string (required);
// Checkpoint ID on the external database used to create the initial state of this clone.
// For an entry added for the immediate parent, this is the user-supplied (or ephemeral)
// checkpoint passed to the clone operation. For entries carried over from the parent's
// own `external_dbs` (i.e. nested clones), this is set to the parent's
// `final_checkpoint_id` for that grandparent — a slatedb-generated, slatedb-owned id —
// rather than the original user-supplied checkpoint. This breaks the chain back to a
// user-supplied checkpoint that the user could delete at any time, which would otherwise
// invalidate every future clone in the chain.
source_checkpoint_id: Uuid (required);
// Checkpoint ID this database has placed on the external database that prevents referenced
// data files from being GC'd. Both final_checkpoint_id and source_checkpoint_id should resolve
// to the same manifest_id as long as they both still exist.
final_checkpoint_id: Uuid (required);
// Compacted SST IDs belonging to external DB that are currently being referenced.
sst_ids: [Ulid] (required);
}
// A view referencing a CompactedSsTable by its id. This indirection allows the same
// physical SST to appear multiple times in the manifest with different visible ranges
// (e.g. after projection and union), without duplicating SST metadata.
table CompactedSsTableView {
// Reference to a CompactedSsTable by its id (stored at the top level of ManifestV2).
id: Ulid (required);
visible_range: BytesRange;
}
table SortedRunV2 {
id: uint32;
ssts: [CompactedSsTableView] (required);
}
table ManifestV2 {
// List of external databases referenced by this manifest.
external_dbs: [ExternalDb];
// Flag to indicate whether initialization has finished. When creating the initial manifest for
// a root db (one that is not a clone), this flag will be set to true. When creating the initial
// manifest for a clone db, this flag will be set to false and then updated to true once clone
// initialization has completed.
initialized: boolean;
// Optional epoch time in seconds that this database was destroyed
destroyed_at_s: u64;
...
// All compacted SSTs referenced by l0 and compacted (sorted runs) views.
// This is the single source of truth for SST metadata (id, info, format_version).
ssts: [CompactedSsTable] (required);
// L0 and sorted run entries reference SSTs via CompactedSsTableView, which holds
// only the SST id and a visible_range. This allows the same SST to be referenced
// multiple times with different visible ranges.
l0: [CompactedSsTableView] (required);
compacted: [SortedRunV2] (required);
// A list of current checkpoints that may be active (note: this replaces the existing
// snapshots field)
checkpoint: [Checkpoints] (required);
}
table WriterCheckpoint {
epoch: u64;
}
union CheckpointMetadata { WriterCheckpoint }
table Uuid {
high: uint64;
low: uint64;
}
// Checkpoint reference to be included in manifest to record active checkpoints.
table Checkpoint {
// Id that must be unique across all open checkpoints.
id: Uuid (required);
// The manifest ID that this checkpoint is using as its `CoreDbState`.
manifest_id: ulong;
// The UTC unix timestamp seconds that a checkpoint expires at. Clients may update this value.
// If `checkpoint_expire_time_s` is older than now(), the checkpoint is considered expired.
// If `checkpoint_expire_time_s` is 0, the checkpoint will never expire.
checkpoint_expire_time_s: uint;
// The unix timestamp seconds that a checkpoint was created.
checkpoint_create_time_s: uint;
// Optional metadata associated with the checkpoint. For example, the writer can use this to
// clean up checkpoints from older writers.
metadata: CheckpointMetadata;
// Optional name associated with the checkpoint. The name can be used to list the checkpoints.
// Note that name may not be unique and the list operation can return multiple checkpoints with
// the same name.
name: string;
}
```
### Manifest V1 to V2 Migration
[Section titled “Manifest V1 to V2 Migration”](#manifest-v1-to-v2-migration)
The introduction of ManifestV2 requires a phased rollout to ensure forward compatibility across mixed-version deployments:
1. **Phase 1 (current):** Write V1, read V1+V2. All nodes can read the new V2 format, but manifests are still written in V1 format. This allows older nodes that only understand V1 to continue operating during a rolling upgrade. To support this, a `last_compacted_l0_sst_id` field is maintained alongside `last_compacted_l0_sst_view_id`, since V1 encodes `l0_last_compacted` as an SST ID rather than a view ID.
2. **Phase 2:** Write V2, read V1+V2. Once all nodes are upgraded and can read V2, the writer switches to emitting V2 manifests. V1 reading is retained for backward compatibility with existing manifests on disk.
3. **Phase 3:** Deprecate V1. Once no V1 manifests remain on disk (i.e., all have been superseded), V1 reading support can be removed.
### Public API
[Section titled “Public API”](#public-api)
We’ll make the following changes to the public API to support creating and using checkpoints:
```rust
/// Defines the scope targeted by a given checkpoint. If set to All, then the checkpoint will include
/// all writes that were issued at the time that create_checkpoint is called. SlateDB will flush WALs
/// (if enabled) and do a best-effort flush of memtables to L0 SSTs in order to optimize for
/// readers. The memtable flush will not await if blocked by backpressure. If set to Durable, then
/// the checkpoint includes only writes that were durable at the time of the call. This will be
/// faster, but may not include data from recent writes.
enum CheckpointScope {
All,
Durable
}
/// Specify options to provide when creating a checkpoint.
struct CheckpointOptions {
/// Optionally specifies the lifetime of the checkpoint to create. The expire time will be set to
/// the current wallclock time plus the specified lifetime. If lifetime is None, then the checkpoint
/// is created without an expiry time.
lifetime: Option,
/// Optionally specifies an existing checkpoint to use as the source for this checkpoint. This is
/// useful for users to establish checkpoints from existing checkpoints, but with a different lifecycle
/// and/or metadata.
source: Option
/// Optionally specifies a name for the checkpoint. Can be used to list the checkpoints.
pub name: Option,
}
#[derive(Debug)]
pub struct CheckpointCreateResult {
/// The id of the created checkpoint.
id: uuid::Uuid,
/// The manifest id referenced by the created checkpoint.
manifest_id: u64,
}
impl Db {
/// Creates a checkpoint of an opened db using the provided options. Returns the ID of the created
/// checkpoint and the id of the referenced manifest.
pub async fn create_checkpoint(
&self,
scope: CheckpointScope,
options: &CheckpointOptions,
) -> Result {
…
}
/// Refresh the lifetime of an existing checkpoint. Takes the id of an existing checkpoint
/// and a lifetime, and sets the lifetime of the checkpoint to the specified lifetime. If
/// there is no checkpoint with the specified id, then this fn fails with
/// SlateDBError::InvalidDbState
pub async fn refresh_checkpoint(
path: &Path,
object_store: Arc,
id: Uuid,
lifetime: Option,
) -> Result<(), SlateDBError> {}
/// Deletes the checkpoint with the specified id.
pub async fn delete_checkpoint(
path: &Path,
object_store: Arc,
id: Uuid,
) -> Result<(), SlateDBError> {}
/// Called to destroy the database at the given path. If `soft` is true, This method will
/// set the destroyed_at_s field in the manifest. The GC will clean up the db after some
/// time has passed, and all checkpoints have either been deleted or expired. As part of
/// cleaning up the db, the GC will also remove the database’s checkpoint from the parent
/// database. If `soft` is false, then all cleanup will be performed by the call to this
/// method. If `soft` is false, the destroy will return SlateDbError::InvalidDeletion if
/// there are any remaining non-expired checkpoints.
pub async fn destroy(path: Path, object_store: Arc, soft: bool) -> Result<(), SlateDbError> {
…
}
}
mod admin {
/// Creates a checkpoint of the db stored in the object store at the specified path using the provided options.
/// Note that the scope option does not impact the behaviour of this method. The checkpoint will reference
/// the current active manifest of the db.
pub async fn create_checkpoint>(
path: P,
object_store: Arc,
options: &CheckpointOptions,
) -> Result {}
/// Clone a Db from a checkpoint. If no db already exists at the specified path, then this will create
/// a new db under the path that is a clone of the db at parent_path. A clone is a shallow copy of the
/// parent database - it starts with a manifest that references the same SSTs, but doesn't actually copy
/// those SSTs, except for the WAL. New writes will be written to the newly created db and will not be
/// reflected in the parent database. The clone can optionally be created from an existing checkpoint. If
/// parent_checkpoint is None, then the manifest referenced by parent_checkpoint is used as the base for
/// the clone db's manifest. Otherwise, this method creates a new checkpoint for the current version of
/// the parent db.
pub async fn create_clone>(
clone_path: P,
parent_path: P,
object_store: Arc,
parent_checkpoint: Option,
) -> Result<(), Box> {
…
}
}
/// Configuration options for the database reader. These options are set on client startup.
#[derive(Clone)]
pub struct DbReaderOptions {
/// How frequently to poll for new manifest files. Refreshing the manifest file allows readers
/// to detect newly compacted data.
pub manifest_poll_interval: Duration,
/// For readers that refresh their checkpoint, this specifies the lifetime to use for the
/// created checkpoint. The checkpoint’s expire time will be set to the current time plus
/// this value. If not specified, then the checkpoint will be created with no expiry, and
/// must be manually removed. This lifetime must always be greater than
/// manifest_poll_interval x 2
pub checkpoint_lifetime: Option,
/// The max size of a single in-memory table used to buffer WAL entries
/// Defaults to 64MB
pub max_memtable_bytes: u64
}
pub struct DbReader {
…
}
impl DbReader {
/// Creates a database reader that can read the contents of a database (but cannot write any
/// data). The caller can provide an optional checkpoint. If the checkpoint is provided, the
/// reader will read using the specified checkpoint and will not periodically refresh the
/// checkpoint. Otherwise, the reader creates a new checkpoint pointing to the current manifest
/// and refreshes it periodically as specified in the options. It also removes the previous
/// checkpoint once any ongoing reads have completed.
pub async fn open(
path: Path,
object_store: Arc,
checkpoint: Option,
options: DbReaderOptions
) -> Result { ... }
/// Read a key an return the read value, if any.
pub async fn get(&self, key: &[u8]) -> Result