use std::fmt;
use std::sync::Arc;
use arrow::array::{RecordBatch, StringArray, UInt64Array};
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::execution::SessionStateBuilder;
use datafusion::functions_window::expr_fn::row_number;
use datafusion::logical_expr::ExprFunctionExt;
use datafusion::prelude::{DataFrame, SessionConfig, SessionContext, ident, lit};
use datafusion::sql::TableReference;
use datafusion::sql::sqlparser::dialect::dialect_from_str;
use super::catalog::{INFORMATION_SCHEMA, LakeCatalog};
use super::provider::TimelakeProvider;
use super::rewrite::LatestRewrite;
use super::rollup::{definition_of, refresh_session, rollup_frame};
use super::route::{RollupRewrite, RouteMap, prepare};
use super::settings::SessionSettings;
use super::statement::{self, RollupStatement, Statement};
use super::{CATALOG, PLAN_CACHE_TTL};
use crate::error::{Error, Result};
use crate::lake::Lake;
use crate::name::{DEFAULT_NAMESPACE, TableName};
use crate::rollup::{Definition, RollupRefresh};
use crate::{Chunk, TableOptions, parse_duration, property};
const ROW_NUMBER: &str = "__timelake_row_number";
const REMOTE_PARTITIONS_PER_CORE: usize = 4;
const MIN_REMOTE_PARTITIONS: usize = 32;
#[derive(Clone)]
pub struct Session {
lake: Lake,
ctx: SessionContext,
catalog: Arc<LakeCatalog>,
routes: RouteMap,
}
impl Session {
pub fn new(lake: Lake) -> Self {
Self::with_config(lake, Self::config())
}
pub fn config() -> SessionConfig {
SessionConfig::new().with_option_extension(SessionSettings::default())
}
pub fn with_config(lake: Lake, config: SessionConfig) -> Self {
let namespace = match config.options().catalog.default_schema.as_str() {
"public" => DEFAULT_NAMESPACE.to_string(),
s => s.to_string(),
};
let cores = std::thread::available_parallelism().map_or(8, std::num::NonZero::get);
let remote = !matches!(lake.storage().url().scheme(), "file" | "memory");
let config = if remote && config.target_partitions() == cores {
config.with_target_partitions((cores * REMOTE_PARTITIONS_PER_CORE).max(MIN_REMOTE_PARTITIONS))
} else {
config
};
let config = if config.options().extensions.get::<SessionSettings>().is_some() {
config
} else {
config.with_option_extension(SessionSettings::default())
};
let config = config
.with_information_schema(true)
.with_default_catalog_and_schema(CATALOG, namespace)
.set_bool("datafusion.execution.parquet.pushdown_filters", false);
let routes = RouteMap::default();
let state = SessionStateBuilder::new()
.with_config(config)
.with_default_features()
.with_optimizer_rule(Arc::new(LatestRewrite))
.with_optimizer_rule(Arc::new(RollupRewrite { routes: routes.clone() }))
.build();
let ctx = SessionContext::new_with_state(state);
ctx.register_object_store(lake.storage().url(), lake.storage().object_store());
let catalog = Arc::new(LakeCatalog::new(lake.clone()));
ctx.register_catalog(CATALOG, catalog.clone());
let time_bucket = datafusion::functions::datetime::date_bin().as_ref().clone().with_aliases(["time_bucket"]);
ctx.register_udf(time_bucket);
Self { lake, ctx, catalog, routes }
}
pub fn lake(&self) -> &Lake {
&self.lake
}
pub fn context(&self) -> &SessionContext {
&self.ctx
}
pub async fn sql(&self, sql: &str) -> Result<DataFrame> {
let mut statements = self.parse(sql)?;
match (statements.pop(), statements.is_empty()) {
(Some(statement), true) => self.execute(statement).await,
_ => Err(Error::invalid("expected one SQL statement")),
}
}
pub fn parse(&self, sql: &str) -> Result<Vec<Statement>> {
let state = self.ctx.state();
let options = &state.config().options().sql_parser;
let dialect = dialect_from_str(options.dialect)
.ok_or_else(|| Error::config(format!("unsupported SQL dialect {}", options.dialect)))?;
statement::parse(sql, dialect.as_ref(), options.recursion_limit)
}
pub async fn execute(&self, statement: Statement) -> Result<DataFrame> {
match statement {
Statement::Sql(s) => self.execute_sql(*s).await,
Statement::Rollup(r) => self.execute_rollup(r).await,
}
}
async fn execute_sql(&self, statement: datafusion::sql::parser::Statement) -> Result<DataFrame> {
let state = self.ctx.state();
if let Ok(references) = state.resolve_table_references(&statement) {
if references.iter().any(|r| r.schema() == Some(INFORMATION_SCHEMA)) {
self.catalog.refresh_listing().await?;
}
let default_namespace = &state.config().options().catalog.default_schema;
let names: Vec<TableName> = references
.iter()
.filter(|r| r.catalog().is_none_or(|c| c == CATALOG))
.filter_map(|r| TableName::new(r.schema().unwrap_or(default_namespace), r.table()).ok())
.collect();
futures::future::join_all(names.iter().map(|n| self.prefetch(n))).await;
futures::future::join_all(names.iter().map(|n| prepare(&self.lake, n, &self.routes))).await;
}
let plan = state.statement_to_plan(statement).await?;
Ok(self.ctx.execute_logical_plan(plan).await?)
}
async fn execute_rollup(&self, statement: RollupStatement) -> Result<DataFrame> {
let (name, rows) = match statement {
RollupStatement::Create { name, query, options, if_not_exists } => {
let name = self.table_name(&name)?;
match self.lake.table(&name).await {
Ok(_) if if_not_exists => (name, 0),
Ok(_) => return Err(Error::AlreadyExists(name.to_string())),
Err(Error::NotFound(_)) => {
let rows = self.create_rollup(&name, &query, options).await?;
(name, rows)
}
Err(e) => return Err(e),
}
}
RollupStatement::Drop { name, if_exists } => {
let name = self.table_name(&name)?;
match crate::rollup::drop(&self.lake, &name).await {
Ok(()) => {}
Err(Error::NotFound(_)) if if_exists => {}
Err(e) => return Err(e),
}
(name, 0)
}
RollupStatement::Refresh { name } => {
let name = self.table_name(&name)?;
let rows = crate::rollup::refresh(&self.lake, &name, None, RollupRefresh::Always).await?;
(name, rows)
}
};
let schema = Arc::new(Schema::new(vec![
Field::new("rollup", DataType::Utf8, false),
Field::new("rows", DataType::UInt64, false),
]));
let batch = RecordBatch::try_new(
schema,
vec![Arc::new(StringArray::from(vec![name.to_string()])), Arc::new(UInt64Array::from(vec![rows]))],
)?;
Ok(self.ctx.read_batch(batch)?)
}
async fn create_rollup(&self, name: &TableName, query: &str, options: Vec<(String, String)>) -> Result<u64> {
let mut config = self.ctx.copied_config();
config.options_mut().set("timelake.rollup_routing", "false")?;
let planner = Session::with_config(self.lake.clone(), config);
let mut statements = planner.parse(query)?;
let (Some(Statement::Sql(select)), true) = (statements.pop(), statements.is_empty()) else {
return Err(Error::invalid(format!("rollup {name}: AS must be followed by one SELECT")));
};
let plan = planner.execute_sql(*select).await?.into_optimized_plan()?;
let (raw, def, order) = definition_of(&plan, name)?;
let ctx = refresh_session(&self.lake)?;
let order: Vec<&str> = order.iter().map(String::as_str).collect();
let frame = rollup_frame(&ctx, &raw, &def, Some(&[]))?.select_columns(&order)?;
let schema = frame.schema().as_arrow().clone();
crate::rollup::create(&raw, name, &def, &schema, rollup_options(&def, options)?).await
}
fn table_name(&self, name: &str) -> Result<TableName> {
match name.split_once('.') {
Some((namespace, table)) => TableName::new(namespace, table),
None => TableName::new(&self.ctx.state().config().options().catalog.default_schema, name),
}
}
async fn prefetch(&self, name: &TableName) {
let Ok(table) = self.lake.table_recent(name, PLAN_CACHE_TTL).await else { return };
let index = table.last_value_index();
let read_index = async {
if let Some(uri) = index {
let _ = crate::lvi::read(&table, &uri).await;
}
};
let (_, ()) = tokio::join!(table.files(), read_index);
}
pub async fn latest<N>(&self, table: N) -> Result<DataFrame>
where
N: TryInto<TableName>,
Error: From<N::Error>,
{
let table = self.lake.table_recent(&table.try_into()?, PLAN_CACHE_TTL).await?;
let Some(time_column) = table.contract().time_column.clone() else {
return Err(Error::invalid(format!("{} has no time column", table.name())));
};
if table.last_value_index().is_some() {
return Ok(self.ctx.read_table(Arc::new(TimelakeProvider::latest(table)))?);
}
let keys = table.contract().series_key.iter().map(ident).collect();
let rn = row_number().partition_by(keys).order_by(vec![ident(time_column).sort(false, true)]).build()?;
let name = table.name();
let df = self
.ctx
.table(TableReference::full(CATALOG, name.namespace(), name.name()))
.await?
.window(vec![rn.alias(ROW_NUMBER)])?
.filter(ident(ROW_NUMBER).eq(lit(1u64)))?
.drop_columns(&[ROW_NUMBER])?;
Ok(df)
}
}
fn rollup_options(def: &Definition, options: Vec<(String, String)>) -> Result<TableOptions> {
const HOUR_MICROS: i64 = 3_600_000_000;
let mut opts = TableOptions {
time_column: Some(def.bucket.clone()),
series_key: def.keys.clone(),
chunk: Some(if def.width < HOUR_MICROS { Chunk::Day } else { Chunk::Month }),
dedup: Some(true),
ingest_time: Some(false),
..Default::default()
};
for (key, value) in options {
match key.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"chunk-interval" | "chunk" => opts.chunk = Some(value.parse()?),
"retention" => opts.retention = Some(parse_duration(&value)?),
"refresh-interval" => {
opts.properties.insert(property::ROLLUP_REFRESH_INTERVAL.into(), value);
}
_ => {
opts.properties.insert(crate::alter::property_key(&key)?, value);
}
}
}
Ok(opts)
}
impl fmt::Debug for Session {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Session").field("lake", &self.lake).finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::array::{Float64Array, Int32Array, RecordBatch, TimestampMicrosecondArray};
use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
use super::*;
use crate::TableOptions;
use crate::testing::{T0, append_without_index, faulty_lake};
#[tokio::test]
async fn latest_without_index_quotes_names() {
let (lake, _store) = faulty_lake();
let schema = Arc::new(Schema::new(vec![
Field::new("the time", DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())), false),
Field::new("dev\"ice", DataType::Int32, true),
Field::new("value", DataType::Float64, true),
]));
let opts = TableOptions {
time_column: Some("the time".into()),
series_key: vec!["dev\"ice".into()],
..Default::default()
};
let t = lake.create_table("iot.my-readings", &schema, opts).await.unwrap();
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(TimestampMicrosecondArray::from(vec![T0, T0 + 1, T0, T0 + 2]).with_timezone("UTC")),
Arc::new(Int32Array::from(vec![1, 1, 2, 2])),
Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0])),
],
)
.unwrap();
let t = append_without_index(&t, &[batch]).await;
assert!(t.last_value_index().is_none());
let df = Session::new(lake).latest("iot.my-readings").await.unwrap();
let batches = df.sort(vec![ident("dev\"ice").sort(true, true)]).unwrap().collect().await.unwrap();
let batch = arrow::compute::concat_batches(&batches[0].schema(), &batches).unwrap();
assert_eq!(batch.num_columns(), 4, "time, device, value and the insertion time");
let values = batch.column(2).as_any().downcast_ref::<Float64Array>().unwrap();
assert_eq!(values.values(), &[2.0, 4.0]);
}
#[tokio::test]
async fn parse_splits_statements_outside_literals() {
let (lake, _store) = faulty_lake();
let s = Session::new(lake);
let statements = s.parse("SELECT 'a;b'; -- c;d\nSELECT 2;;").unwrap();
assert_eq!(statements.len(), 2);
assert!(s.parse("SELECT 'unterminated").is_err());
}
}