use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use metering::interval::Sparte;
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,
#[arg(long, value_name = "YEARS")]
anonymise_after_years: Option<u32>,
#[arg(long, default_value = "meterstore maintain", value_name = "WHO")]
anonymise_actor: String,
},
Query {
#[arg(value_name = "SQL")]
sql: String,
#[arg(long, conflicts_with = "operational")]
historical: bool,
#[arg(long, conflicts_with = "historical")]
operational: bool,
},
Completeness {
#[arg(long, value_name = "NAME")]
table: Option<String>,
#[arg(
long,
value_name = "TS",
conflicts_with = "month",
required_unless_present = "month",
requires = "to"
)]
from: Option<String>,
#[arg(
long,
value_name = "TS",
conflicts_with = "month",
required_unless_present = "month",
requires = "from"
)]
to: Option<String>,
#[arg(long, value_name = "YYYY-MM")]
month: Option<String>,
#[arg(
long,
value_name = "SPARTE",
default_value = "STROM",
requires = "month"
)]
sparte: String,
#[arg(long, value_name = "DURATION")]
seen_since: Option<String>,
#[arg(long, value_name = "MALO")]
malo: Option<String>,
#[arg(long, value_name = "MELO")]
melo: Option<String>,
#[arg(long, value_name = "OBIS")]
obis: Option<String>,
#[arg(long)]
gaps_only: 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>,
},
Erasures {
#[arg(long, default_value_t = 50, value_name = "N")]
limit: i64,
},
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,
anonymise_after_years,
anonymise_actor,
} => {
maintain(
cli,
interval,
*expire_snapshots,
*anonymise_after_years,
anonymise_actor,
)
.await
}
Command::Query {
sql,
historical,
operational,
} => query(cli, sql, *historical, *operational).await,
Command::Completeness {
table,
from,
to,
month,
sparte,
seen_since,
malo,
melo,
obis,
gaps_only,
} => {
completeness(
cli,
table.as_deref(),
Period {
from: from.as_deref(),
to: to.as_deref(),
month: month.as_deref(),
sparte,
},
seen_since.as_deref(),
Narrowing {
malo: malo.as_deref(),
melo: melo.as_deref(),
obis: obis.as_deref(),
},
*gaps_only,
)
.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::Erasures { limit } => erasures(cli, *limit).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,
anonymise_after_years: Option<u32>,
actor: &str,
) -> Result<()> {
let every = crate::settings::parse_human_duration(interval)?;
let catalog = load(cli).await?;
let mut maintenance = catalog
.maintenance()
.interval(every)
.expire_snapshots(expire_snapshots);
if let Some(years) = anonymise_after_years {
maintenance = maintenance.anonymise_after(
crate::erasure::Retention::CalendarYears(years),
"§ 60 Abs. 6 MsbG",
actor,
);
}
let handle = maintenance.spawn();
tracing::info!(
interval = %interval,
tables = catalog.len(),
anonymise_after_years,
"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)
}
fn completeness_range(period: Period<'_>) -> Result<(OffsetDateTime, OffsetDateTime)> {
let Period {
from,
to,
month,
sparte,
} = period;
if let Some(month) = month {
let sparte: Sparte = sparte.parse().map_err(|e| {
Error::config(format!(
"--sparte {sparte:?}: {e} — expected one of {:?}",
Sparte::CODES
))
})?;
let (year, month) = parse_year_month(month)?;
return Ok(crate::planner::bilanzierungsmonat(year, month, sparte));
}
let (Some(from), Some(to)) = (from, to) else {
return Err(Error::config(
"completeness needs either --month or both --from and --to",
));
};
let (from, to) = (parse_instant("--from", from)?, parse_instant("--to", to)?);
match from < to {
true => Ok((from, to)),
false => Err(Error::config(format!(
"--from {from} is not before --to {to}; the range is half-open [from, to)"
))),
}
}
fn parse_year_month(text: &str) -> Result<(i32, time::Month)> {
let bad = || {
Error::config(format!(
"--month {text:?} is not a settlement month; write it as YYYY-MM, for \
example 2026-06"
))
};
let (year, month) = text.split_once('-').ok_or_else(bad)?;
let year: i32 = year.parse().map_err(|_| bad())?;
let month: u8 = month.parse().map_err(|_| bad())?;
Ok((year, time::Month::try_from(month).map_err(|_| bad())?))
}
fn parse_instant(flag: &str, text: &str) -> Result<OffsetDateTime> {
OffsetDateTime::parse(text, &time::format_description::well_known::Rfc3339).map_err(|e| {
Error::config(format!(
"{flag} {text:?} is not an instant: {e}. Write it as RFC 3339, for \
example 2026-06-01T00:00:00Z"
))
})
}
#[derive(Debug, Clone, Copy)]
struct Period<'a> {
from: Option<&'a str>,
to: Option<&'a str>,
month: Option<&'a str>,
sparte: &'a str,
}
#[derive(Debug, Clone, Copy, Default)]
struct Narrowing<'a> {
malo: Option<&'a str>,
melo: Option<&'a str>,
obis: Option<&'a str>,
}
async fn completeness(
cli: &Cli,
only: Option<&str>,
period: Period<'_>,
seen_since: Option<&str>,
narrowing: Narrowing<'_>,
gaps_only: bool,
) -> Result<()> {
let (from, to) = completeness_range(period)?;
let roster = match seen_since {
None => None,
Some(text) => {
let window = crate::settings::parse_human_duration(text)?;
if window <= time::Duration::ZERO {
return Err(Error::config(format!(
"--seen-since {text:?} is not a window before the range. A roster \
drawn from the range itself can only hold channels the range \
already reports, so it would find nothing and read as \
\"nothing went silent\""
)));
}
Some(from - window)
}
};
let catalog = load(cli).await?;
let mut rows = Vec::new();
for store in selected(&catalog, only)? {
let mut query = store.completeness(from, to);
if let Some(since) = roster {
query = query.seen_since(since);
}
if let Some(malo) = narrowing.malo {
query = query.malo(malo)?;
}
if let Some(melo) = narrowing.melo {
query = query.melo(melo)?;
}
if let Some(obis) = narrowing.obis {
query = query.obis(obis)?;
}
for row in query.await? {
if gaps_only && row.is_complete() && !row.is_silent() {
continue;
}
rows.push((store.table().to_string(), row));
}
}
render::completeness(&rows, from, to, 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 erasures(cli: &Cli, limit: i64) -> Result<()> {
if limit <= 0 {
return Err(Error::config(format!(
"--limit is a row count and must be positive; got {limit}"
)));
}
let catalog = load(cli).await?;
let registry = catalog
.tables()
.find_map(crate::MeterStore::subject_registry)
.ok_or_else(|| {
Error::config(
"no table in this configuration declares a subject_column, so this \
deployment holds no subject mapping and there is nothing to erase or \
to report. See the privacy documentation",
)
})?;
render::erasures(®istry.erasures(limit).await?, cli.format)
}
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 a_completeness_range_is_stated_once() {
let err = Cli::try_parse_from([
"meterstore",
"completeness",
"--month",
"2026-06",
"--from",
"2026-06-01T00:00:00Z",
])
.expect_err("--month and --from contradict each other");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
assert!(
Cli::try_parse_from([
"meterstore",
"completeness",
"--from",
"2026-06-01T00:00:00Z",
])
.is_err(),
"--from alone does not describe a period"
);
assert!(
Cli::try_parse_from(["meterstore", "completeness"]).is_err(),
"a report with no period is a full-table scan nobody asked for"
);
}
#[test]
fn an_explicit_range_needs_no_month_despite_the_sparte_default() {
let parsed = Cli::try_parse_from([
"meterstore",
"completeness",
"--from",
"2026-06-01T00:00:00Z",
"--to",
"2026-07-01T00:00:00Z",
])
.expect("an explicit range is a complete invocation");
let Command::Completeness { month, sparte, .. } = parsed.command else {
panic!("expected completeness");
};
assert_eq!(month, None);
assert_eq!(sparte, "STROM");
assert!(Cli::try_parse_from(["meterstore", "completeness", "--month", "2026-06"]).is_ok());
}
fn month_of<'a>(month: &'a str, sparte: &'static str) -> Period<'a> {
Period {
from: None,
to: None,
month: Some(month),
sparte,
}
}
fn between<'a>(from: &'a str, to: &'a str) -> Period<'a> {
Period {
from: Some(from),
to: Some(to),
month: None,
sparte: "STROM",
}
}
#[test]
fn a_settlement_month_is_cut_where_the_commodity_balances() {
let (from, to) = completeness_range(month_of("2026-06", "STROM")).unwrap();
assert_eq!(from, time::macros::datetime!(2026-05-31 22:00 UTC));
assert_eq!(to, time::macros::datetime!(2026-06-30 22:00 UTC));
let (gas_from, gas_to) = completeness_range(month_of("2026-06", "GAS")).unwrap();
assert_eq!(gas_from, time::macros::datetime!(2026-06-01 4:00 UTC));
assert_eq!(gas_to, time::macros::datetime!(2026-07-01 4:00 UTC));
assert_eq!(gas_from - from, time::Duration::hours(6));
}
#[test]
fn a_month_needs_a_month_number_that_exists() {
assert!(parse_year_month("2026-06").is_ok());
for bad in [
"2026-00",
"2026-13",
"2026-6-15",
"2026",
"-1-06",
"20xx-06",
] {
assert!(parse_year_month(bad).is_err(), "{bad}");
}
}
#[test]
fn a_period_that_is_not_one_says_what_it_should_look_like() {
for (bad, wanted) in [
("2026-13", "YYYY-MM"),
("June 2026", "YYYY-MM"),
("2026", "YYYY-MM"),
] {
let err = completeness_range(month_of(bad, "STROM"))
.expect_err(bad)
.to_string();
assert!(err.contains(wanted), "{bad}: {err}");
}
let err = completeness_range(month_of("2026-06", "OEL"))
.expect_err("there is no such commodity")
.to_string();
assert!(err.contains("--sparte"), "{err}");
}
#[test]
fn an_explicit_range_must_run_forwards() {
let err = completeness_range(between("2026-06-30T00:00:00Z", "2026-06-01T00:00:00Z"))
.expect_err("the range is half-open [from, to)")
.to_string();
assert!(err.contains("half-open"), "{err}");
let ok =
completeness_range(between("2026-06-01T00:00:00Z", "2026-07-01T00:00:00Z")).unwrap();
assert_eq!(ok.0, time::macros::datetime!(2026-06-01 0:00 UTC));
let err = completeness_range(between("yesterday", "today"))
.expect_err("not an instant")
.to_string();
assert!(err.contains("RFC 3339"), "{err}");
}
#[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()
);
}
}