use dashmap::DashMap;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use spatio_types::config::{SyncMode, SyncPolicy};
use spatio_types::point::Point3d;
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::Path;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::config::PersistenceConfig;
use crate::error::Result;
#[derive(Debug, Clone, Copy)]
pub struct SyncSettings {
pub policy: SyncPolicy,
pub mode: SyncMode,
pub batch_size: usize,
}
impl Default for SyncSettings {
fn default() -> Self {
Self {
policy: SyncPolicy::default(),
mode: SyncMode::default(),
batch_size: 1,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationUpdate {
pub timestamp: SystemTime,
pub position: Point3d,
pub metadata: serde_json::Value,
}
pub struct ColdState {
trajectory_log: Mutex<TrajectoryLog>,
recent_buffer: DashMap<String, VecDeque<LocationUpdate>>,
buffer_capacity: usize,
log_path: Option<std::path::PathBuf>,
}
impl ColdState {
pub fn new(
log_path: &Path,
buffer_capacity: usize,
config: PersistenceConfig,
sync: SyncSettings,
) -> Result<Self> {
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(Self {
trajectory_log: Mutex::new(TrajectoryLog::open_file(
log_path,
config.buffer_size,
sync,
)?),
recent_buffer: DashMap::new(),
buffer_capacity,
log_path: Some(log_path.to_path_buf()),
})
}
pub fn new_memory(buffer_capacity: usize) -> Self {
Self {
trajectory_log: Mutex::new(TrajectoryLog::open_memory()),
recent_buffer: DashMap::new(),
buffer_capacity,
log_path: None,
}
}
#[inline]
fn make_key(namespace: &str, object_id: &str) -> String {
format!("{}::{}", namespace, object_id)
}
pub fn stats(&self) -> (usize, usize) {
let trajectory_count = self.recent_buffer.len();
let buffer_bytes = self
.recent_buffer
.iter()
.map(|entry| entry.value().len() * 100)
.sum();
(trajectory_count, buffer_bytes)
}
pub fn append_update(
&self,
namespace: &str,
object_id: &str,
position: Point3d,
metadata: serde_json::Value,
timestamp: SystemTime,
) -> Result<()> {
let micros = micros_since_epoch(timestamp);
let timestamp_truncated = UNIX_EPOCH + std::time::Duration::from_micros(micros as u64);
let update = LocationUpdate {
timestamp: timestamp_truncated,
position,
metadata,
};
{
let mut log = self.trajectory_log.lock();
log.append(namespace, object_id, &update)?;
}
let full_key = Self::make_key(namespace, object_id);
let mut buffer = self.recent_buffer.entry(full_key).or_default();
buffer.push_back(update);
if buffer.len() > self.buffer_capacity {
buffer.pop_front();
}
Ok(())
}
pub fn append_tombstone(&self, namespace: &str, object_id: &str) -> Result<()> {
let micros = micros_since_epoch(SystemTime::now());
let mut log = self.trajectory_log.lock();
log.append_tombstone(micros, namespace, object_id)
}
pub fn flush(&self) -> Result<()> {
let mut log = self.trajectory_log.lock();
log.flush()
}
pub fn query_trajectory(
&self,
namespace: &str,
object_id: &str,
start_time: SystemTime,
end_time: SystemTime,
limit: usize,
) -> Result<Vec<LocationUpdate>> {
let full_key = Self::make_key(namespace, object_id);
let mut from_buffer = Vec::new();
if let Some(buffer) = self.recent_buffer.get(&full_key) {
let buffer_is_complete = buffer.len() < self.buffer_capacity;
from_buffer = buffer
.iter()
.filter(|u| u.timestamp >= start_time && u.timestamp <= end_time)
.cloned()
.collect();
if buffer_is_complete {
from_buffer.sort_by_key(|u| std::cmp::Reverse(u.timestamp));
from_buffer.truncate(limit);
return Ok(from_buffer);
}
}
let buffer_timestamps: std::collections::HashSet<SystemTime> =
from_buffer.iter().map(|u| u.timestamp).collect();
let from_disk = {
let mut log = self.trajectory_log.lock();
match log.flush_and_file_target()? {
Some(target) => {
drop(log);
scan_file(
&target,
namespace,
object_id,
start_time,
end_time,
&buffer_timestamps,
)?
}
None => log.scan_memory(
namespace,
object_id,
start_time,
end_time,
&buffer_timestamps,
),
}
};
from_buffer.extend(from_disk);
from_buffer.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
from_buffer.truncate(limit);
Ok(from_buffer)
}
pub fn recover_current_locations(
&self,
) -> Result<std::collections::HashMap<String, LocationUpdate>> {
use std::collections::HashMap;
let mut entries: HashMap<String, Option<LocationUpdate>> = HashMap::new();
let mut from_offset = 0u64;
if let Some(log_path) = &self.log_path {
let log_len = std::fs::metadata(log_path).map(|m| m.len()).unwrap_or(0);
if let Some((snapshot, covered_len)) = read_snapshot(&snapshot_path_for(log_path))
&& covered_len <= log_len
{
for (key, update) in snapshot {
entries.insert(key, Some(update));
}
from_offset = covered_len;
}
}
{
let log = self.trajectory_log.lock();
log.replay(from_offset, &mut entries)?;
}
Ok(entries
.into_iter()
.filter_map(|(key, slot)| slot.map(|u| (key, u)))
.collect())
}
pub fn write_checkpoint(
&self,
state: &std::collections::HashMap<String, LocationUpdate>,
) -> Result<()> {
let Some(log_path) = &self.log_path else {
return Ok(());
};
let covered_len = std::fs::metadata(log_path).map(|m| m.len()).unwrap_or(0);
write_snapshot(&snapshot_path_for(log_path), state, covered_len)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LogVersion {
V1,
V2,
}
const LOG_HEADER_V2: &str = "#spatio-log v2";
fn crc32(bytes: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in bytes {
crc ^= byte as u32;
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
fn record_body(line: &str, version: LogVersion) -> Option<&str> {
match version {
LogVersion::V1 => Some(line),
LogVersion::V2 => {
if line.is_empty() || line.starts_with('#') {
return None;
}
let (crc_hex, body) = line.split_once('|')?;
let expected = u32::from_str_radix(crc_hex, 16).ok()?;
if crc32(body.as_bytes()) != expected {
log::warn!("Skipping log record with CRC mismatch (corrupt or torn write)");
return None;
}
Some(body)
}
}
}
fn sync_parent_dir(path: &Path) {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
if let Ok(dir) = File::open(&parent) {
let _ = dir.sync_all();
}
}
fn write_record<W: Write>(w: &mut W, version: LogVersion, body: &str) -> std::io::Result<()> {
match version {
LogVersion::V2 => writeln!(w, "{:08x}|{}", crc32(body.as_bytes()), body),
LogVersion::V1 => writeln!(w, "{}", body),
}
}
fn micros_since_epoch(t: SystemTime) -> u128 {
t.duration_since(UNIX_EPOCH).unwrap_or_default().as_micros()
}
fn format_update_body(
micros: u128,
namespace: &str,
object_id: &str,
position: &Point3d,
metadata: &serde_json::Value,
) -> String {
let json = serde_json::to_string(metadata).unwrap_or_else(|_| "null".to_string());
format!(
"{}|{}|{}|{:.6}|{:.6}|{:.6}|{}|{}",
micros,
namespace,
object_id,
position.y(), position.x(), position.z(), json.len(),
json,
)
}
fn parse_update_body(body: &str) -> Option<(SystemTime, &str, &str, Point3d, serde_json::Value)> {
let parts: Vec<&str> = body.splitn(8, '|').collect();
if parts.len() != 8 {
return None;
}
let micros: u128 = parts[0].parse().ok()?;
let timestamp = UNIX_EPOCH + Duration::from_micros(u64::try_from(micros).unwrap_or(u64::MAX));
let lat: f64 = parts[3].parse().ok()?;
let lon: f64 = parts[4].parse().ok()?;
let alt: f64 = parts[5].parse().ok()?;
let metadata = serde_json::from_str(parts[7]).unwrap_or(serde_json::Value::Null);
Some((
timestamp,
parts[1],
parts[2],
Point3d::new(lon, lat, alt),
metadata,
))
}
const SNAPSHOT_HEADER_PREFIX: &str = "#spatio-snap v1 ";
fn snapshot_path_for(log_path: &Path) -> std::path::PathBuf {
let mut s = log_path.as_os_str().to_os_string();
s.push(".snap");
std::path::PathBuf::from(s)
}
fn read_snapshot(path: &Path) -> Option<(std::collections::HashMap<String, LocationUpdate>, u64)> {
use std::collections::HashMap;
let content = std::fs::read_to_string(path).ok()?;
let mut lines = content.lines();
let covered_len: u64 = lines
.next()?
.strip_prefix(SNAPSHOT_HEADER_PREFIX)?
.trim()
.parse()
.ok()?;
let mut map: HashMap<String, LocationUpdate> = HashMap::new();
for line in lines {
if line.is_empty() {
continue;
}
let body = record_body(line, LogVersion::V2)?;
let (timestamp, ns, id, position, metadata) = parse_update_body(body)?;
map.insert(
format!("{}::{}", ns, id),
LocationUpdate {
timestamp,
position,
metadata,
},
);
}
Some((map, covered_len))
}
fn write_snapshot(
path: &Path,
state: &std::collections::HashMap<String, LocationUpdate>,
covered_len: u64,
) -> Result<()> {
let mut tmp = path.as_os_str().to_os_string();
tmp.push(".tmp");
let tmp = std::path::PathBuf::from(tmp);
{
let file = File::create(&tmp)?;
let mut w = BufWriter::new(file);
writeln!(w, "{}{}", SNAPSHOT_HEADER_PREFIX, covered_len)?;
for (key, update) in state {
let (ns, id) = key.split_once("::").unwrap_or((key.as_str(), ""));
let micros = micros_since_epoch(update.timestamp);
let body = format_update_body(micros, ns, id, &update.position, &update.metadata);
write_record(&mut w, LogVersion::V2, &body)?;
}
w.flush()?;
w.get_ref().sync_all()?;
}
std::fs::rename(&tmp, path)?;
sync_parent_dir(path);
Ok(())
}
struct FileScanTarget {
path: std::path::PathBuf,
version: LogVersion,
len: u64,
}
fn scan_file(
target: &FileScanTarget,
namespace: &str,
object_id: &str,
start_time: SystemTime,
end_time: SystemTime,
exclude: &std::collections::HashSet<SystemTime>,
) -> Result<Vec<LocationUpdate>> {
let FileScanTarget { path, version, len } = target;
let (version, len) = (*version, *len);
let mut out: Vec<LocationUpdate> = Vec::new();
if !path.exists() || len == 0 {
return Ok(out);
}
let file = File::open(path)?;
let reader = std::io::BufReader::new(std::io::Read::take(file, len));
for line_result in std::io::BufRead::lines(reader) {
let line = match line_result {
Ok(l) => l,
Err(_) => continue,
};
let Some(body) = record_body(&line, version) else {
continue;
};
let Some((timestamp, ns, id, position, metadata)) = parse_update_body(body) else {
continue;
};
if ns != namespace || id != object_id {
continue;
}
if exclude.contains(×tamp) {
continue;
}
if timestamp < start_time || timestamp > end_time {
continue;
}
out.push(LocationUpdate {
timestamp,
position,
metadata,
});
}
Ok(out)
}
#[derive(Clone)]
enum MemRecord {
Update {
namespace: String,
object_id: String,
update: LocationUpdate,
},
Tombstone {
namespace: String,
object_id: String,
},
}
enum LogBackend {
File {
writer: BufWriter<File>,
path: std::path::PathBuf,
pending_writes: usize,
writes_since_sync: usize,
last_sync: Instant,
buffer_limit: usize,
sync: SyncSettings,
version: LogVersion,
},
Memory {
records: Vec<MemRecord>,
},
}
struct TrajectoryLog {
backend: LogBackend,
}
impl TrajectoryLog {
fn open_file(path: &Path, buffer_limit: usize, sync: SyncSettings) -> Result<Self> {
let existing_len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let version = if existing_len == 0 {
LogVersion::V2
} else {
let mut first_line = String::new();
let probe = File::open(path)?;
std::io::BufRead::read_line(&mut std::io::BufReader::new(probe), &mut first_line)?;
if first_line.trim_end_matches(['\n', '\r']) == LOG_HEADER_V2 {
LogVersion::V2
} else {
LogVersion::V1
}
};
let file = OpenOptions::new().create(true).append(true).open(path)?;
if existing_len == 0 {
sync_parent_dir(path);
}
let mut writer = BufWriter::new(file);
if existing_len == 0 {
writeln!(writer, "{}", LOG_HEADER_V2)?;
}
Ok(Self {
backend: LogBackend::File {
writer,
path: path.to_path_buf(),
pending_writes: 0,
writes_since_sync: 0,
last_sync: Instant::now(),
buffer_limit,
sync,
version,
},
})
}
fn open_memory() -> Self {
Self {
backend: LogBackend::Memory {
records: Vec::new(),
},
}
}
fn maybe_sync(&mut self, force: bool) -> Result<()> {
let LogBackend::File {
writer,
pending_writes,
writes_since_sync,
last_sync,
buffer_limit,
sync,
..
} = &mut self.backend
else {
return Ok(());
};
let fsync = match sync.policy {
SyncPolicy::Never => false,
SyncPolicy::Always => force || *writes_since_sync >= sync.batch_size,
SyncPolicy::EverySecond => force || last_sync.elapsed() >= Duration::from_secs(1),
};
if fsync {
writer.flush()?;
match sync.mode {
SyncMode::All => writer.get_ref().sync_all()?,
SyncMode::Data => writer.get_ref().sync_data()?,
}
*pending_writes = 0;
*writes_since_sync = 0;
*last_sync = Instant::now();
} else if force || *pending_writes >= *buffer_limit {
writer.flush()?;
*pending_writes = 0;
}
Ok(())
}
fn append(&mut self, namespace: &str, object_id: &str, update: &LocationUpdate) -> Result<()> {
match &mut self.backend {
LogBackend::File {
writer,
pending_writes,
writes_since_sync,
version,
..
} => {
let body = format_update_body(
micros_since_epoch(update.timestamp),
namespace,
object_id,
&update.position,
&update.metadata,
);
write_record(writer, *version, &body)?;
*pending_writes += 1;
*writes_since_sync += 1;
}
LogBackend::Memory { records } => {
records.push(MemRecord::Update {
namespace: namespace.to_string(),
object_id: object_id.to_string(),
update: update.clone(),
});
return Ok(());
}
}
self.maybe_sync(false)
}
fn append_tombstone(&mut self, micros: u128, namespace: &str, object_id: &str) -> Result<()> {
match &mut self.backend {
LogBackend::File {
writer,
pending_writes,
writes_since_sync,
version,
..
} => {
let body = format!("TOMBSTONE|{}|{}|{}", micros, namespace, object_id);
write_record(writer, *version, &body)?;
*pending_writes += 1;
*writes_since_sync += 1;
}
LogBackend::Memory { records } => {
let _ = micros;
records.push(MemRecord::Tombstone {
namespace: namespace.to_string(),
object_id: object_id.to_string(),
});
return Ok(());
}
}
self.maybe_sync(false)
}
fn flush(&mut self) -> Result<()> {
self.maybe_sync(true)
}
fn flush_and_file_target(&mut self) -> Result<Option<FileScanTarget>> {
match &mut self.backend {
LogBackend::File {
writer,
path,
pending_writes,
version,
..
} => {
writer.flush()?;
*pending_writes = 0;
let len = std::fs::metadata(&*path).map(|m| m.len()).unwrap_or(0);
Ok(Some(FileScanTarget {
path: path.clone(),
version: *version,
len,
}))
}
LogBackend::Memory { .. } => Ok(None),
}
}
fn scan_memory(
&self,
namespace: &str,
object_id: &str,
start_time: SystemTime,
end_time: SystemTime,
exclude: &std::collections::HashSet<SystemTime>,
) -> Vec<LocationUpdate> {
let LogBackend::Memory { records } = &self.backend else {
return Vec::new();
};
let mut out = Vec::new();
for rec in records {
let MemRecord::Update {
namespace: ns,
object_id: id,
update,
} = rec
else {
continue;
};
if ns != namespace || id != object_id {
continue;
}
if exclude.contains(&update.timestamp) {
continue;
}
if update.timestamp < start_time || update.timestamp > end_time {
continue;
}
out.push(update.clone());
}
out
}
fn replay(
&self,
from_offset: u64,
entries: &mut std::collections::HashMap<String, Option<LocationUpdate>>,
) -> Result<()> {
fn merge(slot: &mut Option<LocationUpdate>, update: LocationUpdate) {
match slot {
None => *slot = Some(update),
Some(existing) if update.timestamp > existing.timestamp => *slot = Some(update),
_ => {}
}
}
match &self.backend {
LogBackend::File { path, version, .. } => {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
let version = *version;
if !path.exists() {
return Ok(());
}
let mut file = std::fs::File::open(path)?;
if from_offset > 0 {
file.seek(SeekFrom::Start(from_offset))?;
}
let reader = BufReader::new(file);
for (line_num, line_result) in reader.lines().enumerate() {
let line = match line_result {
Ok(l) => l,
Err(e) => {
log::warn!(
"Failed to read line {} in trajectory log: {}",
line_num + 1,
e
);
continue;
}
};
let Some(body) = record_body(&line, version) else {
continue;
};
if body.starts_with("TOMBSTONE|") {
let parts: Vec<&str> = body.splitn(4, '|').collect();
if parts.len() != 4 {
log::warn!("Malformed tombstone on line {}", line_num + 1);
continue;
}
entries.insert(format!("{}::{}", parts[2], parts[3]), None);
continue;
}
let Some((timestamp, namespace, object_id, position, metadata)) =
parse_update_body(body)
else {
log::warn!("Malformed log line {}", line_num + 1);
continue;
};
let slot = entries
.entry(format!("{}::{}", namespace, object_id))
.or_insert(None);
merge(
slot,
LocationUpdate {
timestamp,
position,
metadata,
},
);
}
}
LogBackend::Memory { records } => {
for rec in records {
match rec {
MemRecord::Update {
namespace,
object_id,
update,
} => {
let slot = entries
.entry(format!("{}::{}", namespace, object_id))
.or_insert(None);
merge(slot, update.clone());
}
MemRecord::Tombstone {
namespace,
object_id,
} => {
entries.insert(format!("{}::{}", namespace, object_id), None);
}
}
}
}
}
Ok(())
}
}
impl Drop for TrajectoryLog {
fn drop(&mut self) {
if let Err(e) = self.maybe_sync(true) {
log::warn!("Failed to flush trajectory log on drop: {}", e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::PersistenceConfig;
use std::time::Duration;
use tempfile::tempdir;
#[test]
fn test_sync_policy_always_persists_immediately() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig {
buffer_size: 10_000,
},
SyncSettings {
policy: SyncPolicy::Always,
mode: SyncMode::Data,
batch_size: 1,
},
)
.unwrap();
cold.append_update(
"v",
"o",
Point3d::new(1.0, 2.0, 3.0),
serde_json::json!({"k": "v"}),
UNIX_EPOCH + Duration::from_secs(1),
)
.unwrap();
let contents = std::fs::read_to_string(&log_path).unwrap();
assert!(
contents.contains("|v|o|"),
"Always policy must fsync the record to disk immediately, got: {contents:?}"
);
}
#[test]
fn test_flush_persists_under_never_policy() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig {
buffer_size: 10_000,
},
SyncSettings {
policy: SyncPolicy::Never,
mode: SyncMode::All,
batch_size: 1,
},
)
.unwrap();
cold.append_update(
"v",
"o",
Point3d::new(1.0, 2.0, 3.0),
serde_json::json!({}),
UNIX_EPOCH + Duration::from_secs(1),
)
.unwrap();
assert!(std::fs::read_to_string(&log_path).unwrap().is_empty());
cold.flush().unwrap();
assert!(
std::fs::read_to_string(&log_path)
.unwrap()
.contains("|v|o|")
);
}
#[test]
fn test_append_and_query_buffer() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig::default(),
SyncSettings::default(),
)
.unwrap();
let pos1 = Point3d::new(-74.0, 40.7, 0.0);
let pos2 = Point3d::new(-74.1, 40.8, 0.0);
let t1 = UNIX_EPOCH + Duration::from_secs(1000);
let t2 = UNIX_EPOCH + Duration::from_secs(2000);
cold.append_update(
"vehicles",
"truck_001",
pos1,
serde_json::json!({"msg": "m1"}),
t1,
)
.unwrap();
cold.append_update(
"vehicles",
"truck_001",
pos2,
serde_json::json!({"msg": "m2"}),
t2,
)
.unwrap();
let history = cold
.query_trajectory(
"vehicles",
"truck_001",
t1,
t2 + Duration::from_secs(1),
100,
)
.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].position.x(), -74.1); assert_eq!(history[1].position.x(), -74.0);
}
#[test]
fn test_buffer_capacity() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
2,
PersistenceConfig { buffer_size: 0 },
SyncSettings::default(),
)
.unwrap();
let pos = Point3d::new(0.0, 0.0, 0.0);
for i in 0..5 {
let t = UNIX_EPOCH + Duration::from_secs(i);
cold.append_update("v", "o", pos.clone(), serde_json::json!({"id": i}), t)
.unwrap();
}
let key = ColdState::make_key("v", "o");
let buffer = cold.recent_buffer.get(&key).unwrap();
assert_eq!(buffer.len(), 2);
assert_eq!(buffer[0].timestamp, UNIX_EPOCH + Duration::from_secs(3));
assert_eq!(buffer[1].timestamp, UNIX_EPOCH + Duration::from_secs(4));
let history = cold
.query_trajectory(
"v",
"o",
UNIX_EPOCH,
UNIX_EPOCH + Duration::from_secs(10),
100,
)
.unwrap();
assert_eq!(history.len(), 5); assert_eq!(history[0].timestamp, UNIX_EPOCH + Duration::from_secs(4));
assert_eq!(history[1].timestamp, UNIX_EPOCH + Duration::from_secs(3));
}
#[test]
fn test_recover_current_locations() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig { buffer_size: 0 },
SyncSettings::default(),
)
.unwrap();
let t1 = UNIX_EPOCH + Duration::from_secs(1000);
let t2 = UNIX_EPOCH + Duration::from_secs(2000);
let t3 = UNIX_EPOCH + Duration::from_secs(3000);
cold.append_update(
"vehicles",
"truck_001",
Point3d::new(-74.0, 40.0, 100.0),
serde_json::json!({"data": "old"}),
t1,
)
.unwrap();
cold.append_update(
"vehicles",
"truck_001",
Point3d::new(-74.1, 40.1, 200.0),
serde_json::json!({"data": "new"}),
t2,
)
.unwrap();
cold.append_update(
"aircraft",
"plane_001",
Point3d::new(-75.0, 41.0, 5000.0),
serde_json::json!({"type": "flight"}),
t3,
)
.unwrap();
let recovered = cold.recover_current_locations().unwrap();
assert_eq!(recovered.len(), 2);
let truck_key = "vehicles::truck_001";
let truck = recovered.get(truck_key).unwrap();
assert_eq!(truck.position.x(), -74.1);
assert_eq!(truck.position.y(), 40.1);
assert_eq!(truck.timestamp, t2);
assert_eq!(truck.metadata, serde_json::json!({"data": "new"}));
let plane_key = "aircraft::plane_001";
let plane = recovered.get(plane_key).unwrap();
assert_eq!(plane.position.x(), -75.0);
assert_eq!(plane.position.z(), 5000.0);
assert_eq!(plane.timestamp, t3);
}
#[test]
fn test_tombstone_beats_future_timestamp_on_recovery() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig { buffer_size: 0 },
SyncSettings::default(),
)
.unwrap();
let future_ts = UNIX_EPOCH + Duration::from_secs(99_999_999_999);
cold.append_update(
"ns",
"obj",
Point3d::new(1.0, 2.0, 0.0),
serde_json::json!({}),
future_ts,
)
.unwrap();
cold.append_tombstone("ns", "obj").unwrap();
let recovered = cold.recover_current_locations().unwrap();
assert!(
!recovered.contains_key("ns::obj"),
"future-timestamped object must not reappear after tombstone"
);
}
#[test]
fn test_tombstone_excludes_deleted_object_on_recovery() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig { buffer_size: 0 },
SyncSettings::default(),
)
.unwrap();
let t1 = UNIX_EPOCH + Duration::from_secs(1000);
let t2 = UNIX_EPOCH + Duration::from_secs(2000);
cold.append_update(
"ns",
"obj_keep",
Point3d::new(1.0, 2.0, 0.0),
serde_json::json!({}),
t1,
)
.unwrap();
cold.append_update(
"ns",
"obj_del",
Point3d::new(3.0, 4.0, 0.0),
serde_json::json!({}),
t1,
)
.unwrap();
cold.append_tombstone("ns", "obj_del").unwrap();
cold.append_update(
"ns",
"obj_keep",
Point3d::new(1.1, 2.1, 0.0),
serde_json::json!({}),
t2,
)
.unwrap();
let recovered = cold.recover_current_locations().unwrap();
assert!(
recovered.contains_key("ns::obj_keep"),
"kept object should be recovered"
);
assert!(
!recovered.contains_key("ns::obj_del"),
"deleted object must not be recovered"
);
}
#[test]
fn test_tombstone_then_reinsert_recovers_object() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig { buffer_size: 0 },
SyncSettings::default(),
)
.unwrap();
let t1 = UNIX_EPOCH + Duration::from_secs(1000);
cold.append_update(
"ns",
"obj",
Point3d::new(1.0, 2.0, 0.0),
serde_json::json!({}),
t1,
)
.unwrap();
cold.append_tombstone("ns", "obj").unwrap();
let t2 = SystemTime::now() + Duration::from_secs(1);
cold.append_update(
"ns",
"obj",
Point3d::new(5.0, 6.0, 0.0),
serde_json::json!({}),
t2,
)
.unwrap();
let recovered = cold.recover_current_locations().unwrap();
assert!(
recovered.contains_key("ns::obj"),
"re-inserted object should survive recovery"
);
assert_eq!(recovered["ns::obj"].position.x(), 5.0);
}
#[test]
fn test_trajectory_query_out_of_order_timestamps() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
2,
PersistenceConfig { buffer_size: 0 },
SyncSettings::default(),
)
.unwrap();
let mk = |secs| UNIX_EPOCH + Duration::from_secs(secs);
cold.append_update(
"v",
"o",
Point3d::new(5.0, 0.0, 0.0),
serde_json::json!({}),
mk(5000),
)
.unwrap();
cold.append_update(
"v",
"o",
Point3d::new(1.0, 0.0, 0.0),
serde_json::json!({}),
mk(1000),
)
.unwrap();
cold.append_update(
"v",
"o",
Point3d::new(2.0, 0.0, 0.0),
serde_json::json!({}),
mk(2000),
)
.unwrap();
cold.append_update(
"v",
"o",
Point3d::new(3.0, 0.0, 0.0),
serde_json::json!({}),
mk(3000),
)
.unwrap();
let newest = cold
.query_trajectory("v", "o", UNIX_EPOCH, mk(6000), 1)
.unwrap();
assert_eq!(newest.len(), 1);
assert_eq!(
newest[0].timestamp,
mk(5000),
"must return the globally-newest record even when it was evicted from the buffer"
);
}
#[test]
fn test_disk_based_trajectory_query() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
2,
PersistenceConfig { buffer_size: 0 },
SyncSettings::default(),
)
.unwrap();
let t1 = UNIX_EPOCH + Duration::from_secs(1000);
let t2 = UNIX_EPOCH + Duration::from_secs(2000);
let t3 = UNIX_EPOCH + Duration::from_secs(3000);
let t4 = UNIX_EPOCH + Duration::from_secs(4000);
let t5 = UNIX_EPOCH + Duration::from_secs(5000);
for (i, t) in [t1, t2, t3, t4, t5].iter().enumerate() {
cold.append_update(
"vehicles",
"truck_001",
Point3d::new(-74.0 + i as f64 * 0.1, 40.0, 0.0),
serde_json::json!({"data": format!("data_{}", i)}),
*t,
)
.unwrap();
}
let trajectory = cold
.query_trajectory("vehicles", "truck_001", t1, t5, 10)
.unwrap();
assert_eq!(trajectory.len(), 5);
assert_eq!(trajectory[0].timestamp, t5);
assert_eq!(trajectory[1].timestamp, t4);
assert_eq!(trajectory[2].timestamp, t3);
assert_eq!(trajectory[3].timestamp, t2);
assert_eq!(trajectory[4].timestamp, t1);
let limited = cold
.query_trajectory("vehicles", "truck_001", t1, t5, 3)
.unwrap();
assert_eq!(limited.len(), 3);
assert_eq!(limited[0].timestamp, t5);
}
#[test]
fn test_trajectory_sees_buffered_but_evicted_records() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
let cold = ColdState::new(
&log_path,
2, PersistenceConfig {
buffer_size: 10_000,
}, SyncSettings {
policy: SyncPolicy::Never, mode: SyncMode::All,
batch_size: 1,
},
)
.unwrap();
let base = UNIX_EPOCH + Duration::from_secs(1000);
for i in 0..5u64 {
cold.append_update(
"ns",
"o",
Point3d::new(i as f64, 0.0, 0.0),
serde_json::json!({ "i": i }),
base + Duration::from_secs(i),
)
.unwrap();
}
let traj = cold
.query_trajectory("ns", "o", base, base + Duration::from_secs(10), 100)
.unwrap();
assert_eq!(
traj.len(),
5,
"all records must be visible even though 3 were evicted from the \
buffer and none were explicitly flushed"
);
}
#[test]
fn test_concurrent_trajectory_scan_is_stable() {
use std::sync::Arc;
use std::thread;
let dir = tempdir().unwrap();
let cold = Arc::new(
ColdState::new(
&dir.path().join("traj.log"),
2, PersistenceConfig::default(),
SyncSettings::default(),
)
.unwrap(),
);
let base = UNIX_EPOCH + Duration::from_secs(1_000);
let n = 500u64;
let writer = {
let cold = Arc::clone(&cold);
thread::spawn(move || {
for i in 0..n {
cold.append_update(
"ns",
"o",
Point3d::new(1.0, 2.0, 0.0),
serde_json::json!({ "i": i }),
base + Duration::from_millis(i),
)
.unwrap();
}
})
};
let window_end = base + Duration::from_secs(10);
let mut last = 0usize;
for _ in 0..200 {
let traj = cold
.query_trajectory("ns", "o", base, window_end, 100_000)
.unwrap();
assert!(
traj.len() >= last,
"visible record count must not go backwards"
);
last = traj.len();
}
writer.join().unwrap();
let final_traj = cold
.query_trajectory("ns", "o", base, window_end, 100_000)
.unwrap();
assert_eq!(
final_traj.len() as u64,
n,
"all appended records visible once the writer finishes"
);
}
#[test]
fn test_crc32_check_value() {
assert_eq!(super::crc32(b"123456789"), 0xCBF4_3926);
assert_eq!(super::crc32(b""), 0);
}
#[test]
fn test_corrupt_record_is_skipped_on_recovery() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("traj.log");
{
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig::default(),
SyncSettings::default(),
)
.unwrap();
cold.append_update(
"ns",
"good",
Point3d::new(1.0, 2.0, 0.0),
serde_json::json!({"v": 1}),
UNIX_EPOCH + Duration::from_secs(1),
)
.unwrap();
cold.append_update(
"ns",
"bad",
Point3d::new(3.0, 4.0, 0.0),
serde_json::json!({"v": 2}),
UNIX_EPOCH + Duration::from_secs(2),
)
.unwrap();
cold.flush().unwrap();
}
let contents = std::fs::read_to_string(&log_path).unwrap();
let corrupted: String = contents
.lines()
.map(|line| {
if line.contains("|ns|bad|") {
line.replacen("|ns|bad|", "|ns|bad|9", 1)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&log_path, format!("{corrupted}\n")).unwrap();
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig::default(),
SyncSettings::default(),
)
.unwrap();
let recovered = cold.recover_current_locations().unwrap();
assert!(
recovered.contains_key("ns::good"),
"healthy record must survive"
);
assert!(
!recovered.contains_key("ns::bad"),
"CRC-failed record must be skipped, not silently trusted"
);
}
#[test]
fn test_legacy_v1_log_is_readable() {
let dir = tempdir().unwrap();
let log_path = dir.path().join("legacy.log");
std::fs::write(
&log_path,
"1000000|ns|obj|2.000000|1.000000|0.000000|2|{}\n",
)
.unwrap();
let cold = ColdState::new(
&log_path,
10,
PersistenceConfig::default(),
SyncSettings::default(),
)
.unwrap();
let recovered = cold.recover_current_locations().unwrap();
let loc = recovered
.get("ns::obj")
.expect("legacy V1 record must recover");
assert_eq!(loc.position.x(), 1.0);
assert_eq!(loc.position.y(), 2.0);
}
}