use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use time::OffsetDateTime;
use crate::error::{Error, Result};
use crate::session::system::TableStatus;
use crate::settings::Settings;
mod render;
pub use render::Format;
#[derive(Debug, Parser)]
#[command(
name = "meterstore",
version,
about,
long_about = None,
propagate_version = true
)]
pub struct Cli {
#[arg(
short,
long,
global = true,
default_value = "meterstore.toml",
env = "METERSTORE_CONFIG",
value_name = "FILE"
)]
pub config: PathBuf,
#[arg(long, global = true, value_enum, default_value_t = Format::Table)]
pub format: Format,
#[arg(
long,
global = true,
env = "RUST_LOG",
default_value = "meterstore=info"
)]
pub log: String,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init {
#[arg(long)]
force: bool,
},
Check,
Create,
Status,
Archive {
#[arg(long, value_name = "NAME")]
table: Option<String>,
#[arg(long, default_value_t = 8, value_name = "N")]
max_windows: usize,
},
Maintain {
#[arg(long, default_value = "15m", value_name = "DURATION")]
interval: String,
#[arg(long)]
expire_snapshots: bool,
},
Query {
#[arg(value_name = "SQL")]
sql: String,
#[arg(long, conflicts_with = "operational")]
historical: bool,
#[arg(long, conflicts_with = "historical")]
operational: bool,
},
Explain {
#[arg(value_name = "SQL")]
sql: String,
},
Snapshots {
#[arg(long, value_name = "NAME")]
table: Option<String>,
},
Serve {
#[arg(long, default_value = "127.0.0.1:50051", value_name = "ADDR")]
addr: String,
#[arg(long, value_name = "ADDR")]
catalog_addr: Option<String>,
},
Purge {
#[arg(long, value_name = "NAME")]
table: String,
#[arg(long, value_name = "NAME")]
confirm: String,
},
}
#[must_use]
pub fn main() -> ExitCode {
let cli = Cli::parse();
install_tracing(&cli.log);
let runtime = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
Err(e) => {
eprintln!("meterstore: cannot start the async runtime: {e}");
return ExitCode::FAILURE;
}
};
match runtime.block_on(run(&cli)) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("meterstore: {e}");
match e.is_retryable() {
true => ExitCode::from(75), false => ExitCode::FAILURE,
}
}
}
}
fn install_tracing(filter: &str) {
use tracing_subscriber::EnvFilter;
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info")))
.with_writer(std::io::stderr)
.try_init();
}
impl Cli {
pub async fn run(&self) -> Result<()> {
run(self).await
}
}
async fn run(cli: &Cli) -> Result<()> {
match &cli.command {
Command::Init { force } => init(&cli.config, *force),
Command::Check => check(&cli.config),
Command::Create => create(cli).await,
Command::Status => status(cli).await,
Command::Archive { table, max_windows } => {
archive(cli, table.as_deref(), *max_windows).await
}
Command::Maintain {
interval,
expire_snapshots,
} => maintain(cli, interval, *expire_snapshots).await,
Command::Query {
sql,
historical,
operational,
} => query(cli, sql, *historical, *operational).await,
Command::Explain { sql } => explain(cli, sql).await,
Command::Snapshots { table } => snapshots(cli, table.as_deref()).await,
Command::Serve { addr, catalog_addr } => serve(cli, addr, catalog_addr.as_deref()).await,
Command::Purge { table, confirm } => purge(cli, table, confirm).await,
}
}
const TEMPLATE: &str = include_str!("template.toml");
fn init(path: &std::path::Path, force: bool) -> Result<()> {
if path.exists() && !force {
return Err(Error::config(format!(
"{} already exists; pass --force to overwrite it",
path.display()
)));
}
std::fs::write(path, TEMPLATE)
.map_err(|e| Error::config(format!("cannot write {}: {e}", path.display())))?;
println!("wrote {}", path.display());
println!("edit it, then run `meterstore check`");
Ok(())
}
fn check(path: &std::path::Path) -> Result<()> {
let settings = Settings::from_path(path)?;
let tables = settings.validate_all()?;
println!("{}: valid", path.display());
for table in &tables {
println!(
" {} — {} model, settlement lag {}, archival step {}",
table.name(),
match table.time_model().has_interval_end() {
true => "interval",
false => "point",
},
fmt_duration(table.settlement_lag()),
fmt_duration(table.archival_step()),
);
}
Ok(())
}
async fn create(cli: &Cli) -> Result<()> {
let catalog = load(cli).await?;
for store in catalog.tables() {
println!("{}: ready", store.table());
}
Ok(())
}
async fn status(cli: &Cli) -> Result<()> {
let catalog = load(cli).await?;
let now = OffsetDateTime::now_utc();
let mut rows = Vec::with_capacity(catalog.len());
for store in catalog.tables() {
rows.push(store.status(now).await?);
}
render::status(&rows, cli.format)?;
let stranded: Vec<&TableStatus> = rows.iter().filter(|r| r.invariant_violations > 0).collect();
if let Some(first) = stranded.first() {
return Err(Error::InvariantViolated {
table: names(&stranded),
detail: format!(
"{} row(s) sit below the watermark in PostgreSQL, where no query looks. \
Query results may be wrong",
first.invariant_violations,
),
});
}
let starved: Vec<&TableStatus> = rows.iter().filter(|r| !r.healthy).collect();
match starved.is_empty() {
true => Ok(()),
false => Err(Error::config(format!(
"{} has no hot partition left that can hold a row written from now on, so \
the next insert fails outright. Archival pre-creates them — check that \
`meterstore maintain` is running",
names(&starved),
))),
}
}
fn names(rows: &[&TableStatus]) -> String {
rows.iter()
.map(|r| r.table.as_str())
.collect::<Vec<_>>()
.join(", ")
}
async fn archive(cli: &Cli, only: Option<&str>, max_windows: usize) -> Result<()> {
let catalog = load(cli).await?;
let now = OffsetDateTime::now_utc();
let mut lines = Vec::new();
for store in selected(&catalog, only)? {
let outcomes = store.archive(now, max_windows).await?;
lines.push(render::ArchiveLine::of(store.table(), &outcomes));
}
render::archive(&lines, cli.format)
}
async fn maintain(cli: &Cli, interval: &str, expire_snapshots: bool) -> Result<()> {
let every = crate::settings::parse_human_duration(interval)?;
let catalog = load(cli).await?;
let handle = catalog
.maintenance()
.interval(every)
.expire_snapshots(expire_snapshots)
.spawn();
tracing::info!(
interval = %interval,
tables = catalog.len(),
"maintenance loop running; press ctrl-c to stop"
);
tokio::signal::ctrl_c()
.await
.map_err(|e| Error::Storage(format!("cannot listen for ctrl-c: {e}")))?;
tracing::info!("stopping after the cycle in flight");
handle.shutdown().await;
Ok(())
}
async fn query(cli: &Cli, sql: &str, historical: bool, operational: bool) -> Result<()> {
let sql = read_sql(sql)?;
let catalog = load(cli).await?;
let mode = match (historical, operational) {
(true, _) => Some(crate::ReadMode::Historical),
(_, true) => Some(crate::ReadMode::Operational),
_ => None,
};
let result = match mode {
None => catalog.query(&sql).await?,
Some(mode) => {
let store = single(&catalog)?;
store.in_read_mode(mode).await?.query(&sql).await?
}
};
render::query(&result, cli.format)
}
async fn explain(cli: &Cli, sql: &str) -> Result<()> {
let sql = read_sql(sql)?;
let catalog = load(cli).await?;
let described = catalog.describe(&sql).await?;
render::describe(&described, cli.format)
}
async fn snapshots(cli: &Cli, only: Option<&str>) -> Result<()> {
let catalog = load(cli).await?;
let mut rows = Vec::new();
for store in selected(&catalog, only)? {
for snapshot in store.snapshots().await? {
rows.push((store.table().to_string(), snapshot));
}
}
render::snapshots(&rows, cli.format)
}
fn selected<'a>(
catalog: &'a crate::MeterCatalog,
only: Option<&str>,
) -> Result<Vec<&'a crate::MeterStore>> {
let Some(name) = only else {
return Ok(catalog.tables().collect());
};
catalog.table(name).map(|store| vec![store]).ok_or_else(|| {
Error::config(format!(
"no table named {name:?} is configured; this deployment has {}",
catalog
.tables()
.map(crate::MeterStore::table)
.collect::<Vec<_>>()
.join(", "),
))
})
}
async fn serve(cli: &Cli, addr: &str, catalog_addr: Option<&str>) -> Result<()> {
use crate::serve::FlightSqlServer;
let deployment = Settings::from_path(&cli.config)?.connect().await?;
let facade = deployment.cold.catalog_facade();
let catalog = deployment.catalog().await?;
let flight_addr = bind_address(addr)?;
tracing::warn!(
"served without authentication; bind to a trusted network only, and put \
your own TLS and authentication in front of it"
);
let stop = std::sync::Arc::new(tokio::sync::Notify::new());
let signal = std::sync::Arc::clone(&stop);
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down");
signal.notify_waiters();
});
tracing::info!(addr = %flight_addr, tables = catalog.len(), "serving Flight SQL");
let flight = {
let stop = std::sync::Arc::clone(&stop);
tonic::transport::Server::builder()
.add_service(FlightSqlServer::new(catalog).into_service())
.serve_with_shutdown(flight_addr, async move { stop.notified().await })
};
let Some(catalog_addr) = catalog_addr else {
return flight
.await
.map_err(|e| Error::Storage(format!("Flight SQL server: {e}")));
};
let rest_addr = bind_address(catalog_addr)?;
let listener = tokio::net::TcpListener::bind(rest_addr)
.await
.map_err(|e| Error::config(format!("cannot bind {rest_addr}: {e}")))?;
tracing::info!(addr = %rest_addr, "serving the read-only Iceberg REST facade");
let rest = axum::serve(listener, facade.router())
.with_graceful_shutdown(async move { stop.notified().await });
tokio::try_join!(
async {
flight
.await
.map_err(|e| Error::Storage(format!("Flight SQL server: {e}")))
},
async {
rest.await
.map_err(|e| Error::Storage(format!("catalog facade: {e}")))
},
)?;
Ok(())
}
fn bind_address(addr: &str) -> Result<std::net::SocketAddr> {
addr.parse().map_err(|e| {
Error::config(format!(
"{addr:?} is not an address to bind: {e}. Write it as host:port, for \
example 127.0.0.1:50051"
))
})
}
async fn purge(cli: &Cli, table: &str, confirm: &str) -> Result<()> {
let catalog = load(cli).await?;
let [store] = selected(&catalog, Some(table))?[..] else {
unreachable!("a named selection is exactly one table")
};
store.purge_table(confirm).await?;
println!("{table}: destroyed");
Ok(())
}
async fn load(cli: &Cli) -> Result<crate::MeterCatalog> {
Settings::from_path(&cli.config)?
.connect()
.await?
.catalog()
.await
}
fn single(catalog: &crate::MeterCatalog) -> Result<&crate::MeterStore> {
let mut tables = catalog.tables();
match (tables.next(), tables.next()) {
(Some(one), None) => Ok(one),
_ => Err(Error::config(
"--historical and --operational select which tiers one table is read \
from, and this configuration declares several. Query them one at a \
time, or drop the flag and read both tiers",
)),
}
}
fn read_sql(sql: &str) -> Result<String> {
if sql != "-" {
return Ok(sql.to_string());
}
use std::io::Read;
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.map_err(|e| Error::config(format!("cannot read the statement from stdin: {e}")))?;
Ok(text)
}
fn fmt_duration(d: time::Duration) -> String {
crate::settings::format_human_duration(d)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn the_argument_parser_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn the_template_is_a_configuration_file_that_validates() {
let settings = Settings::from_toml(
&TEMPLATE.replace("${DATABASE_URL}", "postgresql://localhost/meterstore"),
)
.expect("the template parses");
settings
.validate_all()
.expect("the template passes full validation");
}
#[test]
fn a_missing_config_file_names_itself() {
let err = check(std::path::Path::new("/nonexistent/meterstore.toml"))
.expect_err("there is no such file");
assert!(err.to_string().contains("meterstore.toml"), "{err}");
}
#[test]
fn the_two_read_mode_flags_are_mutually_exclusive() {
let err = Cli::try_parse_from([
"meterstore",
"query",
"SELECT 1",
"--historical",
"--operational",
])
.expect_err("--historical and --operational contradict each other");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn the_facade_is_opt_in_and_flight_is_not() {
let bare = Cli::try_parse_from(["meterstore", "serve"]).expect("no address needed");
let Command::Serve { addr, catalog_addr } = bare.command else {
panic!("expected serve");
};
assert_eq!(addr, "127.0.0.1:50051");
assert_eq!(catalog_addr, None);
assert!(addr.starts_with("127.0.0.1:"));
}
#[test]
fn an_address_that_is_not_one_says_what_a_bind_address_looks_like() {
let err = bind_address("50051").expect_err("a port alone is not an address");
assert!(err.to_string().contains("host:port"), "{err}");
assert!(bind_address("0.0.0.0:50051").is_ok());
}
#[test]
fn purge_needs_the_name_twice() {
assert!(
Cli::try_parse_from(["meterstore", "purge", "--table", "readings_versions"]).is_err(),
"there is no recovery path, so one mention is not enough"
);
assert!(
Cli::try_parse_from([
"meterstore",
"purge",
"--table",
"readings_versions",
"--confirm",
"readings_versions",
])
.is_ok()
);
}
}