use dashmap::DashMap;
use serde_json::Value;
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;
#[derive(Clone)]
pub struct CursorStore {
cursors: Arc<DashMap<String, StoredCursor>>,
ttl: Duration,
}
struct StoredCursor {
remaining_results: VecDeque<Value>,
created_at: Instant,
batch_size: usize,
db_name: String,
}
const MAX_CURSORS: usize = 10_000;
impl CursorStore {
pub fn new(ttl: Duration) -> Self {
Self {
cursors: Arc::new(DashMap::new()),
ttl,
}
}
fn make_room(&self) {
if self.cursors.len() < MAX_CURSORS {
return;
}
self.cursors
.retain(|_, cursor| cursor.created_at.elapsed() <= self.ttl);
while self.cursors.len() >= MAX_CURSORS {
let oldest = self
.cursors
.iter()
.max_by_key(|entry| entry.created_at.elapsed())
.map(|entry| entry.key().clone());
match oldest {
Some(key) => {
self.cursors.remove(&key);
tracing::warn!("Cursor store full ({}), evicted oldest cursor", MAX_CURSORS);
}
None => break,
}
}
}
pub fn store(
&self,
db_name: impl Into<String>,
results: Vec<Value>,
batch_size: usize,
) -> String {
self.make_room();
let cursor_id = Uuid::new_v7(uuid::Timestamp::now(uuid::NoContext)).to_string();
let cursor = StoredCursor {
remaining_results: VecDeque::from(results),
created_at: Instant::now(),
batch_size,
db_name: db_name.into(),
};
self.cursors.insert(cursor_id.clone(), cursor);
cursor_id
}
pub fn store_and_get_first_batch(
&self,
db_name: impl Into<String>,
results: Vec<Value>,
batch_size: usize,
) -> (Option<String>, Vec<Value>, bool) {
let mut iter = results.into_iter();
let first_batch: Vec<Value> = iter.by_ref().take(batch_size).collect();
let mut remaining_results: VecDeque<Value> = iter.collect();
let has_more = !remaining_results.is_empty();
if !has_more {
return (None, first_batch, false);
}
self.make_room();
let cursor_id = Uuid::new_v7(uuid::Timestamp::now(uuid::NoContext)).to_string();
let cursor = StoredCursor {
remaining_results: std::mem::take(&mut remaining_results),
created_at: Instant::now(),
batch_size,
db_name: db_name.into(),
};
self.cursors.insert(cursor_id.clone(), cursor);
(Some(cursor_id), first_batch, true)
}
pub fn db_name(&self, cursor_id: &str) -> Option<String> {
self.cursors.get(cursor_id).map(|c| c.db_name.clone())
}
pub fn get_next_batch(&self, cursor_id: &str) -> Option<(Vec<Value>, bool)> {
let mut entry = self.cursors.get_mut(cursor_id)?;
let cursor = entry.value_mut();
if cursor.created_at.elapsed() > self.ttl {
drop(entry);
self.cursors.remove(cursor_id);
return None;
}
if cursor.remaining_results.is_empty() {
drop(entry);
self.cursors.remove(cursor_id);
return Some((vec![], false));
}
let take = cursor.batch_size.min(cursor.remaining_results.len());
let batch: Vec<Value> = cursor.remaining_results.drain(0..take).collect();
let has_more = !cursor.remaining_results.is_empty();
if !has_more {
drop(entry);
self.cursors.remove(cursor_id);
}
Some((batch, has_more))
}
pub fn delete(&self, cursor_id: &str) -> bool {
self.cursors.remove(cursor_id).is_some()
}
pub fn spawn_cleanup_task(&self) {
let cursors = self.cursors.clone();
let ttl = self.ttl;
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
cursors.retain(|_, cursor| cursor.created_at.elapsed() <= ttl);
}
});
}
#[allow(dead_code)]
pub fn count(&self) -> usize {
self.cursors.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_store_and_retrieve() {
let store = CursorStore::new(Duration::from_secs(300));
let results = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
let cursor_id = store.store("db1", results, 2);
let (batch, has_more) = store.get_next_batch(&cursor_id).unwrap();
assert_eq!(batch.len(), 2);
assert_eq!(batch[0], json!({"id": 1}));
assert_eq!(batch[1], json!({"id": 2}));
assert!(has_more);
let (batch, has_more) = store.get_next_batch(&cursor_id).unwrap();
assert_eq!(batch.len(), 1);
assert_eq!(batch[0], json!({"id": 3}));
assert!(!has_more);
}
#[test]
fn test_cursor_expiration() {
let store = CursorStore::new(Duration::from_millis(100));
let results = vec![json!({"id": 1})];
let cursor_id = store.store("db1", results, 10);
std::thread::sleep(Duration::from_millis(150));
assert!(store.get_next_batch(&cursor_id).is_none());
}
#[test]
fn test_delete_cursor() {
let store = CursorStore::new(Duration::from_secs(300));
let results = vec![json!({"id": 1})];
let cursor_id = store.store("db1", results, 10);
assert!(store.delete(&cursor_id));
assert!(store.get_next_batch(&cursor_id).is_none());
}
#[test]
fn test_small_result_set() {
let store = CursorStore::new(Duration::from_secs(300));
let results = vec![json!({"id": 1}), json!({"id": 2})];
let cursor_id = store.store("db1", results, 10);
let (batch, has_more) = store.get_next_batch(&cursor_id).unwrap();
assert_eq!(batch.len(), 2);
assert!(!has_more);
}
#[test]
fn test_store_and_get_first_batch() {
let store = CursorStore::new(Duration::from_secs(300));
let results = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
let (cursor_id, first_batch, has_more) = store.store_and_get_first_batch("db1", results, 2);
assert!(has_more);
assert!(cursor_id.is_some());
assert_eq!(first_batch.len(), 2);
assert_eq!(first_batch[0], json!({"id": 1}));
assert_eq!(first_batch[1], json!({"id": 2}));
let (batch, has_more) = store.get_next_batch(cursor_id.as_ref().unwrap()).unwrap();
assert_eq!(batch.len(), 1);
assert_eq!(batch[0], json!({"id": 3}));
assert!(!has_more);
}
#[test]
fn test_store_and_get_first_batch_fits_in_one() {
let store = CursorStore::new(Duration::from_secs(300));
let results = vec![json!({"id": 1}), json!({"id": 2})];
let (cursor_id, first_batch, has_more) =
store.store_and_get_first_batch("db1", results, 10);
assert!(!has_more);
assert!(cursor_id.is_none());
assert_eq!(first_batch.len(), 2);
assert_eq!(store.count(), 0);
}
}