use crate::model::metric::{MetricQuery, MetricQueryResult};
use duckdb::Connection;
use crate::storage::metric_query::{MetricSource, query_metric};
use crate::storage::{
ColumnSchema, MetricReader, SchemaReport, StorageError, validate_metric_point_schema,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParquetSource(String);
impl ParquetSource {
pub fn new(location: impl Into<String>) -> Result<Self, StorageError> {
let location = location.into();
if location.trim().is_empty() {
return Err(StorageError::EmptySource);
}
Ok(Self(location))
}
pub fn location(&self) -> &str {
&self.0
}
}
pub struct ParquetMetricReader<'connection> {
connection: &'connection Connection,
source: ParquetSource,
schema: SchemaReport,
}
impl<'connection> ParquetMetricReader<'connection> {
pub fn open(
connection: &'connection Connection,
source: ParquetSource,
) -> Result<Self, StorageError> {
let schema = inspect_schema(connection, &source)?;
Ok(Self {
connection,
source,
schema,
})
}
pub const fn schema(&self) -> &SchemaReport {
&self.schema
}
pub fn query_metric(&self, query: &MetricQuery) -> Result<MetricQueryResult, StorageError> {
query_metric(
self.connection,
MetricSource::Parquet(self.source.location()),
query,
)
}
}
impl MetricReader for ParquetMetricReader<'_> {
fn query_metric(&self, query: &MetricQuery) -> Result<MetricQueryResult, StorageError> {
Self::query_metric(self, query)
}
}
pub struct StandaloneMetricReader {
connection: Connection,
source: ParquetSource,
schema: SchemaReport,
}
impl StandaloneMetricReader {
pub fn open(source: ParquetSource) -> Result<Self, StorageError> {
let connection = Connection::open_in_memory()?;
let schema = inspect_schema(&connection, &source)?;
Ok(Self {
connection,
source,
schema,
})
}
pub const fn schema(&self) -> &SchemaReport {
&self.schema
}
pub fn query_metric(&self, query: &MetricQuery) -> Result<MetricQueryResult, StorageError> {
query_metric(
&self.connection,
MetricSource::Parquet(self.source.location()),
query,
)
}
}
impl MetricReader for StandaloneMetricReader {
fn query_metric(&self, query: &MetricQuery) -> Result<MetricQueryResult, StorageError> {
Self::query_metric(self, query)
}
}
fn inspect_schema(
connection: &Connection,
source: &ParquetSource,
) -> Result<SchemaReport, StorageError> {
for file_name in parquet_file_names(connection, source)? {
let columns = describe_parquet(connection, &file_name, false)?;
validate_metric_point_schema(columns)?;
}
let columns = describe_parquet(connection, source.location(), true)?;
validate_metric_point_schema(columns)
}
fn parquet_file_names(
connection: &Connection,
source: &ParquetSource,
) -> Result<Vec<String>, StorageError> {
let mut statement = connection.prepare(
"SELECT DISTINCT file_name
FROM parquet_schema(?)
ORDER BY file_name",
)?;
let rows = statement.query_map([source.location()], |row| row.get(0))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StorageError::from)
}
fn describe_parquet(
connection: &Connection,
location: &str,
union_by_name: bool,
) -> Result<Vec<ColumnSchema>, StorageError> {
let sql = if union_by_name {
"DESCRIBE SELECT * FROM read_parquet(
?, hive_partitioning = true, union_by_name = true
)"
} else {
"DESCRIBE SELECT * FROM read_parquet(?, hive_partitioning = true)"
};
let mut statement = connection.prepare(sql)?;
let rows = statement.query_map([location], |row| {
let nullable: String = row.get(2)?;
Ok(ColumnSchema::new(
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
nullable.eq_ignore_ascii_case("YES"),
))
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StorageError::from)
}
#[cfg(test)]
mod tests {
use std::error::Error;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::model::metric::{MetricKey, ReductionPolicy, Step};
use crate::model::run::RunId;
use super::*;
static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(0);
struct TestParquet {
directory: PathBuf,
location: String,
}
impl TestParquet {
fn create(connection: &Connection) -> Result<Self, Box<dyn Error>> {
let directory = test_directory();
fs::create_dir_all(&directory)?;
let path = directory.join("metric_points.parquet");
connection.execute_batch(&format!(
"CREATE TABLE metric_points (
run_id VARCHAR NOT NULL,
metric_key VARCHAR NOT NULL,
metric_key_encoded VARCHAR NOT NULL,
step BIGINT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
value_f64 DOUBLE NOT NULL,
ingested_at TIMESTAMPTZ NOT NULL
);
INSERT INTO metric_points
SELECT 'run-1', 'train/loss', 'train%2Floss', step,
to_timestamp((1767225600 + step)::DOUBLE), 1.0 / step,
to_timestamp((1767225610 + step)::DOUBLE)
FROM range(1, 26) AS generated(step);
INSERT INTO metric_points VALUES
('run-1', 'train/loss', 'train%2Floss', 2,
'2026-01-01 00:00:02+00', 0.2, '2026-01-01 00:01:00+00');
COPY metric_points TO '{}' (FORMAT PARQUET);",
path.display()
))?;
Ok(Self {
directory,
location: path.to_string_lossy().into_owned(),
})
}
fn create_missing_column(connection: &Connection) -> Result<Self, Box<dyn Error>> {
let directory = test_directory();
fs::create_dir_all(&directory)?;
let path = directory.join("metric_points.parquet");
connection.execute_batch(&format!(
"COPY (SELECT 'run-1'::VARCHAR AS run_id,
'loss'::VARCHAR AS metric_key,
1::BIGINT AS step,
now() AS timestamp,
1.0::DOUBLE AS value_f64,
now() AS ingested_at)
TO '{}' (FORMAT PARQUET);",
path.display()
))?;
Ok(Self {
directory,
location: path.to_string_lossy().into_owned(),
})
}
}
impl Drop for TestParquet {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.directory);
}
}
fn test_directory() -> PathBuf {
let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("seex-storage-{}-{id}", std::process::id()))
}
fn query(reduction: ReductionPolicy) -> Result<MetricQuery, Box<dyn Error>> {
Ok(MetricQuery::new(
RunId::from_string("run-1"),
MetricKey::from_string("train/loss"),
Some(Step::new(2)),
Some(Step::new(5)),
reduction,
)?)
}
#[test]
fn reader_rejects_a_physical_file_missing_a_contract_column() -> Result<(), Box<dyn Error>> {
let connection = Connection::open_in_memory()?;
let parquet = TestParquet::create_missing_column(&connection)?;
let source = ParquetSource::new(parquet.location.clone())?;
let result = ParquetMetricReader::open(&connection, source);
assert!(matches!(
result,
Err(StorageError::MissingColumn {
name: "metric_key_encoded"
})
));
Ok(())
}
#[test]
fn reader_queries_full_and_screen_reduced_observations() -> Result<(), Box<dyn Error>> {
let connection = Connection::open_in_memory()?;
let parquet = TestParquet::create(&connection)?;
let reader =
ParquetMetricReader::open(&connection, ParquetSource::new(parquet.location.clone())?)?;
let full = reader.query_metric(&query(ReductionPolicy::Full)?)?;
let screen = reader.query_metric(&query(ReductionPolicy::screen_budget(1, 1)?)?)?;
let lttb = reader.query_metric(&query(ReductionPolicy::lttb(10)?)?)?;
assert_eq!(full.source_row_count, 4);
assert_eq!(full.points.len(), 4);
assert_eq!(full.points[0].step.value(), 2);
let mut duplicate_values = full
.points
.iter()
.filter(|p| p.step.value() == 2)
.map(|p| p.value_f64)
.collect::<Vec<_>>();
duplicate_values.sort_by(f64::total_cmp);
assert_eq!(duplicate_values, vec![0.2, 0.5]);
assert!(screen.points.len() <= 4);
assert_eq!(screen.source_row_count, 4);
assert_eq!(lttb.points, full.points);
Ok(())
}
#[test]
fn owned_reader_queries_step_facts() -> Result<(), Box<dyn Error>> {
let connection = Connection::open_in_memory()?;
let parquet = TestParquet::create(&connection)?;
let reader = StandaloneMetricReader::open(ParquetSource::new(parquet.location.clone())?)?;
let steps = reader.query_metric(&query(ReductionPolicy::Full)?)?;
assert_eq!(steps.source_row_count, 4);
Ok(())
}
}