Skip to content

Commit 70496c3

Browse files
apurvamclaude
andcommitted
perf: use SlateDB multi_get for batch sample loading
Replace individual get() calls and narrow range scans with a new multi_get API that does a single scan from min to max key with a HashSet filter. This amortizes SST index block reads and data block reads across all requested keys. Changes: - Add multi_get to StorageRead trait with default fallback to individual gets - Override in SlateDbStorageReader to use DbReader::multi_get (scan-based) - Update get_time_series_batch and get_forward_index_batch to use multi_get - Update preload_all_samples to batch by bucket using samples_batch - Switch SlateDB dependency to fork with multi_get + tracing instrumentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f404315 commit 70496c3

6 files changed

Lines changed: 120 additions & 120 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ serde_json = "1.0"
4343
serde_with = { version = "3", features = ["base64"] }
4444
serde_yaml = "0.9"
4545
foyer = "0.18"
46-
slatedb = "0.11.1"
47-
slatedb-common = "0.11.1"
46+
slatedb = { git = "https://github.com/apurvam/slatedb", branch = "multi-get-prototype" }
47+
slatedb-common = { git = "https://github.com/apurvam/slatedb", branch = "multi-get-prototype" }
4848
thiserror = "2.0"
4949
tokio = { version = "1.0", features = ["full"] }
5050
tokio-util = "0.7"

common/src/storage/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod loader;
55
pub mod slate;
66
pub mod util;
77

8+
use std::collections::HashMap;
89
use std::sync::Arc;
910

1011
use async_trait::async_trait;
@@ -253,6 +254,24 @@ pub trait StorageRead: Send + Sync {
253254
tracing::Span::current().record("num_records", records.len());
254255
Ok(records)
255256
}
257+
258+
/// Retrieves multiple records by their keys in a single batch operation.
259+
///
260+
/// Returns a map of key to record for all keys that were found.
261+
/// Keys not present in storage are omitted from the result.
262+
///
263+
/// The default implementation falls back to individual `get()` calls.
264+
/// Implementations may override this for better performance (e.g., using
265+
/// a scan from min to max key with a filter).
266+
async fn multi_get(&self, keys: &[Bytes]) -> StorageResult<HashMap<Bytes, Record>> {
267+
let mut results = HashMap::with_capacity(keys.len());
268+
for key in keys {
269+
if let Some(record) = self.get(key.clone()).await? {
270+
results.insert(key.clone(), record);
271+
}
272+
}
273+
Ok(results)
274+
}
256275
}
257276

258277
/// A point-in-time snapshot of the storage layer.

common/src/storage/slate.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,25 @@ impl StorageRead for SlateDbStorageReader {
450450
.map_err(StorageError::from_storage)?;
451451
Ok(Box::new(SlateDbIterator { iter }))
452452
}
453+
454+
#[tracing::instrument(level = "debug", skip_all, fields(num_keys = keys.len(), num_found, num_scanned))]
455+
async fn multi_get(
456+
&self,
457+
keys: &[Bytes],
458+
) -> StorageResult<std::collections::HashMap<Bytes, Record>> {
459+
let result = self
460+
.reader
461+
.multi_get(keys)
462+
.await
463+
.map_err(StorageError::from_storage)?;
464+
465+
tracing::Span::current().record("num_found", result.len());
466+
467+
Ok(result
468+
.into_iter()
469+
.map(|(k, v)| (k.clone(), Record::new(k, v)))
470+
.collect())
471+
}
453472
}
454473

455474
#[cfg(test)]

timeseries/src/promql/evaluator.rs

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -709,48 +709,54 @@ impl<'reader, R: QueryReader> CachedQueryReader<'reader, R> {
709709
/// Preload samples for all (bucket, series_ids) pairs concurrently across
710710
/// all buckets in a single parallel batch.
711711
///
712-
/// Uses a sequential scan per bucket (via `samples_batch`) when the number
713-
/// of uncached series is large, which is dramatically faster than thousands
714-
/// of individual point reads.
712+
/// Uses high concurrency (128) for individual gets, flattened across all
713+
/// buckets into a single stream for maximum parallelism.
715714
pub(crate) async fn preload_all_samples(
716715
&mut self,
717716
bucket_candidates: &[(TimeBucket, Vec<SeriesId>)],
718717
) -> Result<()> {
719-
// Group uncached series by bucket.
720-
let mut uncached_by_bucket: Vec<(TimeBucket, Vec<SeriesId>)> = Vec::new();
721-
for (bucket, series_ids) in bucket_candidates {
722-
let uncached_ids: Vec<SeriesId> = series_ids
723-
.iter()
724-
.filter(|id| self.cache.get_samples(bucket, id).is_none())
725-
.copied()
726-
.collect();
727-
if !uncached_ids.is_empty() {
728-
uncached_by_bucket.push((*bucket, uncached_ids));
729-
}
730-
}
718+
// Group uncached series by bucket for batch loading.
719+
let uncached_by_bucket: Vec<(TimeBucket, Vec<SeriesId>)> = bucket_candidates
720+
.iter()
721+
.filter_map(|(bucket, series_ids)| {
722+
let uncached: Vec<SeriesId> = series_ids
723+
.iter()
724+
.filter(|id| self.cache.get_samples(bucket, id).is_none())
725+
.copied()
726+
.collect();
727+
if uncached.is_empty() {
728+
None
729+
} else {
730+
Some((*bucket, uncached))
731+
}
732+
})
733+
.collect();
731734

732735
if uncached_by_bucket.is_empty() {
733736
return Ok(());
734737
}
735738

736-
// Use batch scan per bucket (scan vs individual gets decided by the reader)
739+
let total_uncached: usize = uncached_by_bucket.iter().map(|(_, ids)| ids.len()).sum();
740+
741+
// Load all buckets concurrently using samples_batch (which uses multi_get internally).
737742
let reader = self.reader;
738743
let results: Vec<std::result::Result<_, crate::error::Error>> =
739744
futures::stream::iter(uncached_by_bucket)
740745
.map(|(bucket, series_ids)| async move {
741-
let batch = reader
746+
let samples_map = reader
742747
.samples_batch(&bucket, &series_ids, i64::MIN, i64::MAX)
743748
.await?;
744-
Ok((bucket, batch))
749+
Ok((bucket, samples_map))
745750
})
746-
.buffer_unordered(8)
751+
.buffer_unordered(4) // one per bucket, not per series
747752
.collect()
748753
.await;
749754

755+
self.stats.samples_cache_misses += total_uncached as u64;
756+
750757
for result in results {
751-
let (bucket, batch) = result?;
752-
self.stats.samples_cache_misses += batch.len() as u64;
753-
for (series_id, samples) in batch {
758+
let (bucket, samples_map) = result?;
759+
for (series_id, samples) in samples_map {
754760
self.cache.cache_samples(bucket, series_id, samples);
755761
}
756762
}

timeseries/src/storage/mod.rs

Lines changed: 49 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use common::{Record, Storage, StorageRead};
44
use futures::stream::{self, StreamExt};
55
use roaring::RoaringBitmap;
66

7-
use std::collections::{HashMap, HashSet};
7+
use std::collections::HashMap;
88

99
use crate::index::{InvertedIndex, SeriesSpec};
1010
use crate::model::{Sample, SeriesFingerprint, SeriesId, TimeBucket};
@@ -255,114 +255,73 @@ pub(crate) trait OpenTsdbStorageReadExt: StorageRead {
255255
Ok(max_series_id)
256256
}
257257

258-
/// Load samples for a batch of series using a narrowed sequential scan.
258+
/// Load samples for a batch of series using multi_get.
259259
///
260-
/// Scans from the minimum to maximum series_id key, filtering to only
261-
/// the requested IDs via HashSet lookup. This is faster than N individual
262-
/// `get()` calls because sequential scan has much lower per-record overhead
263-
/// (~0.35ms vs ~6ms for point reads due to LSM tree traversal).
264-
///
265-
/// Stops early once all requested series have been found.
266-
#[tracing::instrument(level = "info", skip(self, bucket, series_ids), fields(bucket_start = bucket.start, wanted = series_ids.len(), scanned, range_size))]
260+
/// Uses the storage layer's multi_get which internally performs a
261+
/// sequential scan from min to max key. This is much faster than
262+
/// individual get() calls because it amortizes SST index reads and
263+
/// data block reads across all requested keys.
264+
#[tracing::instrument(level = "info", skip(self, bucket, series_ids), fields(bucket_start = bucket.start, wanted = series_ids.len(), found))]
267265
async fn get_time_series_batch(
268266
&self,
269267
bucket: &TimeBucket,
270268
series_ids: &[SeriesId],
271269
) -> Result<HashMap<SeriesId, Vec<Sample>>> {
272-
let wanted: HashSet<SeriesId> = series_ids.iter().copied().collect();
273-
let min_id = *series_ids.iter().min().unwrap();
274-
let max_id = *series_ids.iter().max().unwrap();
275-
let range_size = (max_id - min_id + 1) as u64;
276-
tracing::Span::current().record("range_size", range_size);
277-
278-
// Narrow scan from min to max+1 series_id key
279-
let start_key = TimeSeriesKey {
280-
time_bucket: bucket.start,
281-
bucket_size: bucket.size,
282-
series_id: min_id,
283-
}
284-
.encode();
285-
let end_key = TimeSeriesKey {
286-
time_bucket: bucket.start,
287-
bucket_size: bucket.size,
288-
series_id: max_id.wrapping_add(1),
289-
}
290-
.encode();
291-
let range = common::BytesRange::new(
292-
std::ops::Bound::Included(start_key),
293-
std::ops::Bound::Excluded(end_key),
294-
);
295-
296-
let mut iter = self.scan_iter(range).await?;
297-
let mut result = HashMap::with_capacity(series_ids.len());
298-
let mut scanned = 0u64;
299-
let mut found = 0usize;
300-
while let Some(record) = iter.next().await? {
301-
scanned += 1;
302-
let key = TimeSeriesKey::decode(record.key.as_ref())?;
303-
if wanted.contains(&key.series_id) {
304-
let samples: Vec<Sample> = match TimeSeriesIterator::new(record.value.as_ref()) {
305-
Some(iter) => iter.filter_map(|r| r.ok()).collect(),
306-
None => Vec::new(),
307-
};
308-
result.insert(key.series_id, samples);
309-
found += 1;
310-
if found == wanted.len() {
311-
break; // All requested series found
270+
let keys: Vec<bytes::Bytes> = series_ids
271+
.iter()
272+
.map(|&series_id| {
273+
TimeSeriesKey {
274+
time_bucket: bucket.start,
275+
bucket_size: bucket.size,
276+
series_id,
312277
}
313-
}
278+
.encode()
279+
})
280+
.collect();
281+
282+
let records = self.multi_get(&keys).await?;
283+
let mut result = HashMap::with_capacity(records.len());
284+
285+
for (key_bytes, record) in records {
286+
let key = TimeSeriesKey::decode(key_bytes.as_ref())?;
287+
let samples: Vec<Sample> = match TimeSeriesIterator::new(record.value.as_ref()) {
288+
Some(iter) => iter.filter_map(|r| r.ok()).collect(),
289+
None => Vec::new(),
290+
};
291+
result.insert(key.series_id, samples);
314292
}
315-
tracing::Span::current().record("scanned", scanned);
293+
tracing::Span::current().record("found", result.len());
316294
Ok(result)
317295
}
318296

319-
/// Load forward index entries for a batch of series using a narrowed scan.
320-
#[tracing::instrument(level = "info", skip(self, bucket, series_ids), fields(bucket_start = bucket.start, wanted = series_ids.len(), scanned, range_size))]
297+
/// Load forward index entries for a batch of series using multi_get.
298+
#[tracing::instrument(level = "info", skip(self, bucket, series_ids), fields(bucket_start = bucket.start, wanted = series_ids.len(), found))]
321299
async fn get_forward_index_batch(
322300
&self,
323301
bucket: &TimeBucket,
324302
series_ids: &[SeriesId],
325303
) -> Result<ForwardIndex> {
326-
let wanted: HashSet<SeriesId> = series_ids.iter().copied().collect();
327-
let min_id = *series_ids.iter().min().unwrap();
328-
let max_id = *series_ids.iter().max().unwrap();
329-
let range_size = (max_id - min_id + 1) as u64;
330-
tracing::Span::current().record("range_size", range_size);
331-
332-
let start_key = ForwardIndexKey {
333-
time_bucket: bucket.start,
334-
bucket_size: bucket.size,
335-
series_id: min_id,
336-
}
337-
.encode();
338-
let end_key = ForwardIndexKey {
339-
time_bucket: bucket.start,
340-
bucket_size: bucket.size,
341-
series_id: max_id.wrapping_add(1),
342-
}
343-
.encode();
344-
let range = common::BytesRange::new(
345-
std::ops::Bound::Included(start_key),
346-
std::ops::Bound::Excluded(end_key),
347-
);
304+
let keys: Vec<bytes::Bytes> = series_ids
305+
.iter()
306+
.map(|&series_id| {
307+
ForwardIndexKey {
308+
time_bucket: bucket.start,
309+
bucket_size: bucket.size,
310+
series_id,
311+
}
312+
.encode()
313+
})
314+
.collect();
348315

349-
let mut iter = self.scan_iter(range).await?;
316+
let records = self.multi_get(&keys).await?;
350317
let result = ForwardIndex::default();
351-
let mut scanned = 0u64;
352-
let mut found = 0usize;
353-
while let Some(record) = iter.next().await? {
354-
scanned += 1;
355-
let key = ForwardIndexKey::decode(record.key.as_ref())?;
356-
if wanted.contains(&key.series_id) {
357-
let value = ForwardIndexValue::decode(record.value.as_ref())?;
358-
result.series.insert(key.series_id, value.into());
359-
found += 1;
360-
if found == wanted.len() {
361-
break;
362-
}
363-
}
318+
319+
for (key_bytes, record) in records {
320+
let key = ForwardIndexKey::decode(key_bytes.as_ref())?;
321+
let value = ForwardIndexValue::decode(record.value.as_ref())?;
322+
result.series.insert(key.series_id, value.into());
364323
}
365-
tracing::Span::current().record("scanned", scanned);
324+
tracing::Span::current().record("found", result.series.len());
366325
Ok(result)
367326
}
368327

0 commit comments

Comments
 (0)