use std::sync::Arc;
use datafusion::datasource::file_format::parquet::ParquetFormat;
use datafusion::datasource::listing::ListingOptions;
use datafusion::execution::object_store::ObjectStoreUrl;
use datafusion::prelude::SessionContext;
use crate::error::{DbError, Result};
pub async fn register_cold_tier(
ctx: &SessionContext,
url: &str,
measurement: &str,
table_name: &str,
) -> Result<()> {
if measurement.is_empty()
|| measurement.contains('/')
|| measurement.contains('\\')
|| measurement.contains("..")
|| measurement.contains('=')
{
return Err(DbError::Internal(format!(
"cold tier measurement {measurement:?} is not a usable path segment"
)));
}
let root = url.strip_suffix('/').unwrap_or(url);
let table_url = format!("{root}/measurement={measurement}/");
let parsed = url::Url::parse(&table_url)
.map_err(|e| DbError::Internal(format!("cold tier URL {table_url} is not a URL: {e}")))?;
let (store, _path) = object_store::parse_url(&parsed).map_err(|e| {
DbError::Internal(format!(
"cold tier URL {table_url} is not a supported object store: {e}"
))
})?;
let store_url = ObjectStoreUrl::parse(&parsed[..url::Position::BeforePath]).map_err(|e| {
DbError::Internal(format!("cold tier URL {table_url} is not registrable: {e}"))
})?;
ctx.register_object_store(store_url.as_ref(), Arc::from(store));
let options =
ListingOptions::new(Arc::new(ParquetFormat::default())).with_file_extension(".parquet");
ctx.register_listing_table(table_name, &table_url, options, None, None)
.await
.map_err(|e| {
DbError::Internal(format!(
"failed to register cold tier {table_url} as `{table_name}`: {e}"
))
})?;
tracing::info!(
url = %table_url,
measurement,
table = table_name,
"cold tier registered for SQL"
);
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use arrow::array::{Float64Array, RecordBatch, StringArray, TimestampNanosecondArray};
use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
use parquet::arrow::ArrowWriter;
fn write_cold_object(dir: &std::path::Path, name: &str) {
std::fs::create_dir_all(dir).unwrap();
let schema = Arc::new(Schema::new(vec![
Field::new(
"_time",
DataType::Timestamp(TimeUnit::Nanosecond, None),
false,
),
Field::new("host", DataType::Utf8, true),
Field::new("watts", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(TimestampNanosecondArray::from(vec![1_i64, 2, 3])),
Arc::new(StringArray::from(vec!["a", "a", "b"])),
Arc::new(Float64Array::from(vec![10.0, 20.0, 30.0])),
],
)
.unwrap();
let file = std::fs::File::create(dir.join(name)).unwrap();
let mut w = ArrowWriter::try_new(file, schema, None).unwrap();
w.write(&batch).unwrap();
w.close().unwrap();
}
fn archive_with(root: &std::path::Path, measurement: &str, shard: i64, name: &str) {
write_cold_object(
&root
.join(format!("measurement={measurement}"))
.join(format!("shard={shard}")),
name,
);
}
#[tokio::test]
async fn archived_objects_are_queryable_through_sql() {
let tmp = tempfile::tempdir().unwrap();
archive_with(tmp.path(), "power", 1, "part-a.parquet");
archive_with(tmp.path(), "power", 2, "part-b.parquet");
let ctx = SessionContext::new();
let url = format!("file://{}/", tmp.path().display());
register_cold_tier(&ctx, &url, "power", "power_cold")
.await
.unwrap();
let batches = ctx
.sql("SELECT count(*) AS n, avg(watts) AS a FROM power_cold")
.await
.unwrap()
.collect()
.await
.unwrap();
let batch = &batches[0];
let n = batch
.column(0)
.as_any()
.downcast_ref::<arrow::array::Int64Array>()
.unwrap()
.value(0);
assert_eq!(n, 6, "both shards must be in the table");
let a = batch
.column(1)
.as_any()
.downcast_ref::<Float64Array>()
.unwrap()
.value(0);
assert!((a - 20.0).abs() < 1e-9, "values must survive, got {a}");
}
#[tokio::test]
async fn a_table_sees_only_its_own_measurement() {
let tmp = tempfile::tempdir().unwrap();
archive_with(tmp.path(), "power", 1, "part-a.parquet");
archive_with(tmp.path(), "cpu", 1, "part-a.parquet");
let ctx = SessionContext::new();
let url = format!("file://{}/", tmp.path().display());
register_cold_tier(&ctx, &url, "power", "power_cold")
.await
.unwrap();
let batches = ctx
.sql("SELECT count(*) AS n FROM power_cold")
.await
.unwrap()
.collect()
.await
.unwrap();
let n = batches[0]
.column(0)
.as_any()
.downcast_ref::<arrow::array::Int64Array>()
.unwrap()
.value(0);
assert_eq!(n, 3, "another measurement's objects must not be in scope");
}
#[tokio::test]
async fn the_root_may_omit_its_trailing_slash() {
let tmp = tempfile::tempdir().unwrap();
archive_with(tmp.path(), "power", 1, "part-a.parquet");
let ctx = SessionContext::new();
let url = format!("file://{}", tmp.path().display());
register_cold_tier(&ctx, &url, "power", "power_cold")
.await
.unwrap();
let batches = ctx
.sql("SELECT count(*) AS n FROM power_cold")
.await
.unwrap()
.collect()
.await
.unwrap();
assert_eq!(
batches[0]
.column(0)
.as_any()
.downcast_ref::<arrow::array::Int64Array>()
.unwrap()
.value(0),
3
);
}
#[tokio::test]
async fn cold_tier_answers_filtered_queries_on_time_and_tags() {
let tmp = tempfile::tempdir().unwrap();
archive_with(tmp.path(), "power", 1, "part-a.parquet");
let ctx = SessionContext::new();
let url = format!("file://{}/", tmp.path().display());
register_cold_tier(&ctx, &url, "power", "cold")
.await
.unwrap();
let batches = ctx
.sql(
"SELECT sum(watts) AS s FROM cold \
WHERE host = 'a' AND _time < '1970-01-01T00:00:00.000000003Z'",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let s = batches[0]
.column(0)
.as_any()
.downcast_ref::<Float64Array>()
.unwrap()
.value(0);
assert!((s - 30.0).abs() < 1e-9, "filters must apply, got {s}");
}
#[tokio::test]
async fn a_traversing_measurement_name_is_refused() {
let ctx = SessionContext::new();
for bad in ["", "../etc", "a/b", "shard=1"] {
let err = register_cold_tier(&ctx, "file:///tmp/archive/", bad, "x")
.await
.expect_err("must be refused: {bad}");
assert!(
err.to_string().contains("not a usable path segment"),
"error should name the problem, got: {err}"
);
}
}
#[tokio::test]
async fn an_unsupported_url_is_refused() {
let ctx = SessionContext::new();
let err = register_cold_tier(&ctx, "not a url", "power", "x")
.await
.expect_err("a malformed URL must be refused");
assert!(
err.to_string().contains("not a URL"),
"error should name the problem, got: {err}"
);
}
}