use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use time::Duration;
use crate::arrow::datatypes::{DataType, Field};
use crate::config::TableConfig;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Settings {
#[serde(default)]
pub hot: HotSettings,
#[serde(default)]
pub cold: ColdSettings,
#[serde(default)]
pub privacy: PrivacySettings,
#[serde(default)]
pub tables: Vec<TableSettings>,
}
impl Settings {
pub fn from_toml(text: &str) -> Result<Self> {
let interpolated = interpolate(text)?;
toml::from_str(&interpolated)
.map_err(|e| Error::config(format!("invalid meterstore configuration: {e}")))
}
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
let path = path.as_ref();
let text = std::fs::read_to_string(path)
.map_err(|e| Error::config(format!("cannot read {}: {e}", path.display())))?;
Self::from_toml(&text)
}
pub fn to_toml(&self) -> Result<String> {
toml::to_string_pretty(self)
.map_err(|e| Error::config(format!("cannot render configuration: {e}")))
}
pub fn validate(&self) -> Result<Vec<crate::config::ValidatedTableConfig>> {
if self.tables.is_empty() {
return Err(Error::config(
"no [[tables]] declared: a store with no table has nothing to archive or query",
));
}
self.privacy.validate()?;
self.tables.iter().map(TableSettings::validate).collect()
}
pub fn validate_all(&self) -> Result<Vec<crate::config::ValidatedTableConfig>> {
self.hot.validate()?;
self.cold.validate()?;
self.validate()
}
pub async fn connect(&self) -> Result<Deployment> {
let tables = self.validate_all()?;
let pool = self.hot.connect().await?;
let cold = self.cold.build().await?;
let registry = match tables
.iter()
.any(|t| crate::config::ValidatedTableConfig::subject_column(t).is_some())
{
true => Some(self.privacy.registry(pool.clone())?),
false => None,
};
Ok(Deployment {
hot: std::sync::Arc::new(self.hot.hot(pool.clone())),
pool,
cold,
registry,
tables,
})
}
pub fn single_table(&self) -> Result<crate::config::ValidatedTableConfig> {
match self.tables.as_slice() {
[one] => one.validate(),
[] => Err(Error::config("no [[tables]] declared")),
many => Err(Error::config(format!(
"{} tables declared; use Settings::validate to get all of them",
many.len()
))),
}
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrivacySettings {
#[serde(default)]
pub erasure_secret: Option<String>,
#[serde(default)]
pub retired_erasure_secrets: Vec<String>,
}
impl std::fmt::Debug for PrivacySettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PrivacySettings")
.field(
"erasure_secret",
&self.erasure_secret.as_ref().map(|_| "***"),
)
.field(
"retired_erasure_secrets",
&self.retired_erasure_secrets.len(),
)
.finish()
}
}
impl PrivacySettings {
pub fn registry(&self, pool: sqlx::PgPool) -> Result<crate::erasure::SubjectRegistry> {
self.validate()?;
let Some(secret) = &self.erasure_secret else {
return Ok(crate::erasure::SubjectRegistry::new(pool));
};
let mut ring: Vec<&[u8]> = vec![secret.as_bytes()];
ring.extend(self.retired_erasure_secrets.iter().map(String::as_bytes));
crate::erasure::SubjectRegistry::with_erasure_keys(pool, &ring)
}
pub fn validate(&self) -> Result<()> {
let Some(secret) = &self.erasure_secret else {
if !self.retired_erasure_secrets.is_empty() {
return Err(Error::config(
"[privacy] retired_erasure_secrets is set but erasure_secret is \
not: retired keys only recognise tombstones already written, so \
as configured every erasure from now on records nothing and \
re-registration is silently allowed. Set erasure_secret to the \
key that should be writing",
));
}
return Ok(());
};
for (label, key) in std::iter::once(("erasure_secret".to_string(), secret)).chain(
self.retired_erasure_secrets
.iter()
.enumerate()
.map(|(i, k)| (format!("retired_erasure_secrets[{i}]"), k)),
) {
if key.len() < crate::erasure::MIN_ERASURE_SECRET_BYTES {
return Err(Error::config(format!(
"[privacy] {label} is {} bytes and must be at least {} bytes: a \
shorter key can be brute-forced, and the suppression list would \
then leak the identifiers it exists to forget",
key.len(),
crate::erasure::MIN_ERASURE_SECRET_BYTES,
)));
}
}
Ok(())
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HotSettings {
#[serde(default)]
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_ddl_lock_timeout")]
pub ddl_lock_timeout: HumanDuration,
}
impl HotSettings {
pub fn validate(&self) -> Result<()> {
if self.url.trim().is_empty() {
return Err(Error::config(
"[hot] url is empty: the hot tier is PostgreSQL and there is nothing to \
connect to. Set it, or use ${DATABASE_URL} to take it from the \
environment",
));
}
if self.max_connections == 0 {
return Err(Error::config(
"[hot] max_connections is 0: a pool that can hand out no connection \
blocks the first query for ever rather than failing",
));
}
Ok(())
}
pub fn hot(&self, pool: sqlx::PgPool) -> crate::hot::PostgresHot {
crate::hot::PostgresHot::new(pool).ddl_lock_timeout(self.ddl_lock_timeout.0)
}
pub async fn connect(&self) -> Result<sqlx::PgPool> {
self.validate()?;
sqlx::postgres::PgPoolOptions::new()
.max_connections(self.max_connections)
.connect(&self.url)
.await
.map_err(|e| {
Error::Storage(format!(
"connecting to {}: {e}",
crate::error::redacted(&self.url)
))
})
}
}
impl Default for HotSettings {
fn default() -> Self {
Self {
url: String::new(),
max_connections: default_max_connections(),
ddl_lock_timeout: default_ddl_lock_timeout(),
}
}
}
impl std::fmt::Debug for HotSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HotSettings")
.field("url", &crate::error::redacted(&self.url))
.field("max_connections", &self.max_connections)
.field("ddl_lock_timeout", &self.ddl_lock_timeout)
.finish()
}
}
fn default_ddl_lock_timeout() -> HumanDuration {
HumanDuration(Duration::seconds(3))
}
const fn default_max_connections() -> u32 {
16
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ColdSettings {
#[serde(default)]
pub catalog: CatalogKind,
#[serde(default)]
pub uri: String,
#[serde(default)]
pub warehouse: String,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default = "default_file_target_bytes")]
pub file_target_bytes: usize,
#[serde(default = "default_metadata_pool")]
pub metadata_pool_max_connections: u32,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
}
impl std::fmt::Debug for ColdSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ColdSettings")
.field("catalog", &self.catalog)
.field("uri", &crate::error::redacted(&self.uri))
.field("warehouse", &self.warehouse)
.field("namespace", &self.namespace)
.field("file_target_bytes", &self.file_target_bytes)
.field(
"metadata_pool_max_connections",
&self.metadata_pool_max_connections,
)
.field("region", &self.region)
.field("endpoint", &self.endpoint)
.finish()
}
}
const fn default_file_target_bytes() -> usize {
crate::config::defaults::TARGET_FILE_SIZE
}
const fn default_metadata_pool() -> u32 {
4
}
fn default_namespace() -> String {
"metering".to_string()
}
impl ColdSettings {
pub fn validate(&self) -> Result<()> {
if self.uri.trim().is_empty() {
return Err(Error::config(match self.catalog {
CatalogKind::Rest => {
"[cold] uri is empty: a REST catalog is reached by its \
endpoint and there is nothing to reach"
}
CatalogKind::Sql => {
"[cold] uri is empty: a SQL catalog keeps its metadata in \
PostgreSQL and there is no database named. It is normally the same URL \
as [hot] url"
}
}));
}
if self.warehouse.trim().is_empty() {
return Err(Error::config(
"[cold] warehouse is empty: the catalog holds metadata, and the data files \
need somewhere to live — file://, memory://, s3://, gs:// or abfss://",
));
}
if self.namespace.trim().is_empty() {
return Err(Error::config("[cold] namespace must not be empty"));
}
if self.file_target_bytes == 0 {
return Err(Error::config(
"[cold] file_target_bytes is 0: the writer would roll a file per row",
));
}
if self.metadata_pool_max_connections == 0 && self.catalog == CatalogKind::Sql {
return Err(Error::config(
"[cold] metadata_pool_max_connections is 0: the SQL catalog could open no \
connection and every table load would block",
));
}
crate::cold::catalog::warehouse_factory(&self.warehouse)?;
Ok(())
}
pub async fn build(&self) -> Result<crate::cold::ColdTier> {
self.validate()?;
match self.catalog {
CatalogKind::Sql => {
crate::cold::IcebergSqlCatalog {
database_url: &self.uri,
warehouse_uri: &self.warehouse,
catalog_name: "meterstore",
namespace: &self.namespace,
file_target_bytes: self.file_target_bytes,
metadata_pool_max_connections: self.metadata_pool_max_connections,
auth: &crate::cold::WarehouseAuth {
region: self.region.clone(),
endpoint: self.endpoint.clone(),
access_key_id: None,
secret_access_key: None,
},
}
.build()
.await
}
#[cfg(feature = "rest-catalog")]
CatalogKind::Rest => {
crate::cold::IcebergRestCatalog {
uri: &self.uri,
warehouse_uri: &self.warehouse,
namespace: &self.namespace,
file_target_bytes: self.file_target_bytes,
props: std::collections::HashMap::new(),
}
.build()
.await
}
#[cfg(not(feature = "rest-catalog"))]
CatalogKind::Rest => Err(Error::config(
"[cold] catalog = \"rest\" needs the meterstore `rest-catalog` feature, \
which was not compiled in",
)),
}
}
}
pub struct Deployment {
pub hot: std::sync::Arc<crate::hot::PostgresHot>,
pub pool: sqlx::PgPool,
pub cold: crate::cold::ColdTier,
pub registry: Option<crate::erasure::SubjectRegistry>,
pub tables: Vec<crate::config::ValidatedTableConfig>,
}
impl Deployment {
pub async fn table(
&self,
config: crate::config::ValidatedTableConfig,
) -> Result<crate::MeterStoreBuilder> {
use crate::tiering::ColdStore as _;
let cold = self.cold.cold();
cold.create_tables(
config.name(),
&config.identity_column_names(),
&config.extra_columns(),
)
.await?;
let provider = cold.table_provider(config.name()).await?;
let mut builder = crate::MeterStore::builder()
.hot(self.hot.clone() as std::sync::Arc<dyn crate::HotStore>)
.cold(cold as std::sync::Arc<dyn crate::ColdStore>, provider);
if config.subject_column().is_some()
&& let Some(registry) = &self.registry
{
builder = builder.subject_registry(registry.clone());
}
Ok(builder.table(config))
}
pub async fn store(&self) -> Result<crate::MeterStore> {
let config = match self.tables.as_slice() {
[one] => one.clone(),
[] => return Err(Error::config("no [[tables]] declared")),
many => {
return Err(Error::config(format!(
"{} tables are declared ({}), so there is no single store to build. \
Use Deployment::catalog, which puts them in one session and lets a \
statement mention more than one",
many.len(),
many.iter()
.map(crate::config::ValidatedTableConfig::name)
.collect::<Vec<_>>()
.join(", "),
)));
}
};
let store = self.table(config).await?.build().await?;
store.create_tables().await?;
Ok(store)
}
pub async fn catalog(&self) -> Result<crate::MeterCatalog> {
let mut builder = crate::MeterCatalog::builder();
for config in &self.tables {
builder = builder.table(self.table(config.clone()).await?);
}
let catalog = builder.build().await?;
catalog.create_tables().await?;
Ok(catalog)
}
}
impl std::fmt::Debug for Deployment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Deployment")
.field(
"tables",
&self
.tables
.iter()
.map(crate::config::ValidatedTableConfig::name)
.collect::<Vec<_>>(),
)
.field("subject_registry", &self.registry)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CatalogKind {
#[default]
Rest,
Sql,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TableSettings {
pub name: String,
#[serde(default)]
pub time_model: crate::config::TimeModel,
#[serde(default)]
pub identify_by_melo: Option<bool>,
#[serde(default)]
pub hot: TableHotSettings,
#[serde(default)]
pub archival: ArchivalSettings,
#[serde(default)]
pub maintenance: MaintenanceSettings,
#[serde(default)]
pub extra_columns: Vec<ExtraColumn>,
#[serde(default)]
pub subject_column: Option<String>,
}
impl TableSettings {
pub fn validate(&self) -> Result<crate::config::ValidatedTableConfig> {
let mut config = TableConfig::new(&self.name)
.time_model(self.time_model)
.partition_headroom(self.hot.partition_headroom.0)
.archival_step(self.archival.archival_step.0)
.settlement_lag(self.archival.settlement_lag.0)
.reader_grace(self.archival.reader_grace.0)
.scan_chunk_rows(self.archival.scan_chunk_rows)
.snapshot_retention(self.maintenance.snapshot_retention.0)
.min_snapshots_to_keep(self.maintenance.min_snapshots_to_keep);
if let Some(yes) = self.identify_by_melo {
config = config.identify_by_melo(yes);
}
let mut seen = BTreeMap::new();
for column in &self.extra_columns {
seen.insert(column.name.clone(), column.identity);
let field = column.field()?;
config = if column.identity {
config.identity_column(field)
} else {
config.attribute_column(field)
};
}
if let Some(subject) = &self.subject_column {
if seen.get(subject) == Some(&true) {
return Err(Error::config(format!(
"subject_column {subject:?} is also declared as an identity column: a \
pseudonymous reference must never join the merge key, or a correction \
derived from a re-registered reference silently fails to supersede the \
value it corrects (§19.4)"
)));
}
config = config.subject_column(subject);
}
config.build()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TableHotSettings {
#[serde(default = "default_headroom")]
pub partition_headroom: HumanDuration,
}
impl Default for TableHotSettings {
fn default() -> Self {
Self {
partition_headroom: default_headroom(),
}
}
}
fn default_headroom() -> HumanDuration {
HumanDuration(crate::config::defaults::PARTITION_HEADROOM)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ArchivalSettings {
#[serde(default = "default_settlement_lag")]
pub settlement_lag: HumanDuration,
#[serde(default = "default_archival_step")]
pub archival_step: HumanDuration,
#[serde(default = "default_chunk")]
pub scan_chunk_rows: usize,
#[serde(default = "default_reader_grace")]
pub reader_grace: HumanDuration,
}
impl Default for ArchivalSettings {
fn default() -> Self {
Self {
settlement_lag: default_settlement_lag(),
archival_step: default_archival_step(),
scan_chunk_rows: default_chunk(),
reader_grace: default_reader_grace(),
}
}
}
fn default_settlement_lag() -> HumanDuration {
HumanDuration(crate::config::defaults::SETTLEMENT_LAG)
}
fn default_archival_step() -> HumanDuration {
HumanDuration(crate::config::defaults::ARCHIVAL_STEP)
}
const fn default_chunk() -> usize {
crate::config::defaults::SCAN_CHUNK_ROWS
}
fn default_reader_grace() -> HumanDuration {
HumanDuration(crate::config::defaults::READER_GRACE)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MaintenanceSettings {
#[serde(default = "default_retention")]
pub snapshot_retention: HumanDuration,
#[serde(default = "default_min_snapshots")]
pub min_snapshots_to_keep: usize,
}
impl Default for MaintenanceSettings {
fn default() -> Self {
Self {
snapshot_retention: default_retention(),
min_snapshots_to_keep: default_min_snapshots(),
}
}
}
fn default_retention() -> HumanDuration {
HumanDuration(crate::config::defaults::SNAPSHOT_RETENTION)
}
const fn default_min_snapshots() -> usize {
crate::config::defaults::MIN_SNAPSHOTS_TO_KEEP
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExtraColumn {
pub name: String,
#[serde(default = "default_column_type")]
pub r#type: String,
#[serde(default)]
pub identity: bool,
#[serde(default)]
pub values: Option<Vec<String>>,
#[serde(default)]
pub check: Option<String>,
}
fn default_column_type() -> String {
"string".to_string()
}
impl ExtraColumn {
fn field(&self) -> Result<Field> {
let nullable = !self.identity;
if let Some(check) = &self.check {
if self.values.is_some() {
return Err(Error::config(format!(
"extra column {:?} declares both `values` and `check`: a closed \
vocabulary and an identifier scheme are two different claims about \
one column, and there is no order in which both can hold",
self.name
)));
}
if self.data_type()? != DataType::Utf8 {
return Err(Error::config(format!(
"extra column {:?} declares `check` but is not a string column",
self.name
)));
}
return match crate::config::ValueCheck::from_code(check) {
Some(scheme) => Ok(crate::config::checked_column(&self.name, scheme, nullable)),
None => Err(Error::config(format!(
"extra column {:?} declares check {check:?}; the supported checks \
are {:?}",
self.name,
crate::config::ValueCheck::known_codes(),
))),
};
}
let Some(values) = &self.values else {
return Ok(Field::new(&self.name, self.data_type()?, nullable));
};
if self.data_type()? != DataType::Utf8 {
return Err(Error::config(format!(
"extra column {:?} declares `values` but is not a string column: a \
vocabulary is a set of codes",
self.name
)));
}
if values.is_empty() {
return Err(Error::config(format!(
"extra column {:?} declares an empty `values` set, which no write could \
satisfy; omit it to accept any string",
self.name
)));
}
if let Some(bad) = values.iter().find(|v| v.contains(',')) {
return Err(Error::config(format!(
"extra column {:?} has the code {bad:?}, which contains a comma — the \
delimiter the allowed-value set is carried with",
self.name
)));
}
Ok(crate::config::coded_column(
&self.name,
&values.iter().map(String::as_str).collect::<Vec<_>>(),
nullable,
))
}
fn data_type(&self) -> Result<DataType> {
match self.r#type.as_str() {
"string" | "utf8" | "text" => Ok(DataType::Utf8),
other => Err(Error::config(format!(
"extra column {:?} declares type {other:?}; only \"string\" is supported — \
every attribute deployments have wanted (tenant, Bilanzkreis, grid area) is a \
string, and supporting more needs a bind arm per type",
self.name
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HumanDuration(pub Duration);
impl Serialize for HumanDuration {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&format_duration(self.0))
}
}
impl<'de> Deserialize<'de> for HumanDuration {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let text = String::deserialize(d)?;
parse_duration(&text)
.map(HumanDuration)
.map_err(serde::de::Error::custom)
}
}
pub fn parse_human_duration(text: &str) -> Result<Duration> {
parse_duration(text).map_err(Error::config)
}
#[must_use]
pub fn format_human_duration(d: Duration) -> String {
format_duration(d)
}
fn parse_duration(text: &str) -> std::result::Result<Duration, String> {
let trimmed = text.trim();
let split = trimmed
.find(|c: char| c.is_ascii_alphabetic())
.ok_or_else(|| format!("{trimmed:?} has no unit; write 7d, 15m, 10y"))?;
let (value, unit) = trimmed.split_at(split);
let value: i64 = value
.trim()
.parse()
.map_err(|_| format!("{value:?} is not a whole number"))?;
if unit.trim() == "ms" {
return Ok(Duration::milliseconds(value));
}
let seconds = match unit.trim() {
"s" => 1,
"m" => 60,
"h" => 3_600,
"d" => 86_400,
"w" => 604_800,
"y" => 31_536_000,
other => {
return Err(format!(
"unknown unit {other:?}; use ms, s, m, h, d, w or y"
));
}
};
value
.checked_mul(seconds)
.map(Duration::seconds)
.ok_or_else(|| format!("{trimmed:?} overflows"))
}
fn format_duration(d: Duration) -> String {
let millis = d.whole_milliseconds();
if millis % 1_000 != 0 {
return format!("{millis}ms");
}
let s = d.whole_seconds();
for (unit, size) in [
("y", 31_536_000),
("w", 604_800),
("d", 86_400),
("h", 3_600),
("m", 60),
] {
if s != 0 && s % size == 0 {
return format!("{}{unit}", s / size);
}
}
format!("{s}s")
}
fn interpolate(text: &str) -> Result<String> {
let mut out = String::with_capacity(text.len());
for line in text.split_inclusive('\n') {
let (value, comment) = split_comment(line);
interpolate_into(&mut out, value)?;
out.push_str(comment);
}
Ok(out)
}
fn split_comment(line: &str) -> (&str, &str) {
let mut quote: Option<char> = None;
for (i, c) in line.char_indices() {
match (quote, c) {
(None, '"' | '\'') => quote = Some(c),
(Some(open), c) if c == open => quote = None,
(None, '#') => return (&line[..i], &line[i..]),
_ => {}
}
}
(line, "")
}
fn interpolate_into(out: &mut String, text: &str) -> Result<()> {
let mut rest = text;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let tail = &rest[start + 2..];
let end = tail.find('}').ok_or_else(|| {
Error::config("unterminated ${...} in configuration: no closing brace")
})?;
let name = &tail[..end];
let value = std::env::var(name).map_err(|_| {
Error::config(format!(
"configuration references ${{{name}}}, which is not set in the environment"
))
})?;
out.push_str(&value);
rest = &tail[end + 1..];
}
out.push_str(rest);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = r#"
[hot]
url = "postgresql://edm@db.internal/prod"
max_connections = 32
[cold]
catalog = "rest"
uri = "https://catalog.internal"
warehouse = "s3://edm/meterstore"
namespace = "metering"
file_target_bytes = 536870912
metadata_pool_max_connections = 4
region = "eu-central-1"
endpoint = "https://minio.internal"
[privacy]
erasure_secret = "0123456789abcdef0123456789abcdef"
[[tables]]
name = "readings"
time_model = "interval"
subject_column = "subject_ref"
extra_columns = [
{ name = "tenant", identity = true },
{ name = "bilanzkreis", check = "EIC" },
{ name = "lieferant", check = "BDEW" },
{ name = "ingest_source", values = ["MSCONS", "SMGW"] },
]
[tables.hot]
partition_headroom = "14d"
[tables.archival]
settlement_lag = "7d"
archival_step = "1d"
scan_chunk_rows = 50000
[tables.maintenance]
snapshot_retention = "10y"
min_snapshots_to_keep = 20
"#;
#[test]
fn the_documented_example_parses_and_validates() {
let settings = Settings::from_toml(EXAMPLE).unwrap();
assert_eq!(settings.hot.max_connections, 32);
assert_eq!(settings.cold.catalog, CatalogKind::Rest);
let table = settings.single_table().unwrap();
assert_eq!(table.name(), "readings");
assert_eq!(table.archival_step(), Duration::DAY);
assert_eq!(table.settlement_lag(), Duration::days(7));
assert_eq!(table.subject_column(), Some("subject_ref"));
let checks: Vec<_> = table
.attribute_columns()
.iter()
.filter_map(|f| crate::config::declared_value_check(f).map(|c| (f.name().as_str(), c)))
.collect();
assert_eq!(checks, vec![("bilanzkreis", "EIC"), ("lieferant", "BDEW")]);
settings.validate().expect("the key is long enough");
}
#[test]
fn an_identity_column_reaches_the_merge_key() {
let table = Settings::from_toml(EXAMPLE)
.unwrap()
.single_table()
.unwrap();
assert!(table.merge_key().contains(&"tenant".to_string()));
assert!(!table.merge_key().contains(&"bilanzkreis".to_string()));
}
#[test]
fn a_point_table_is_reachable_from_the_configuration_file() {
let s: Settings = toml::from_str(
r#"
[hot]
url = "postgresql://edm@db/prod"
[cold]
catalog = "sql"
uri = "postgresql://edm@db/prod"
warehouse = "file:///tmp/wh"
namespace = "metering"
[[tables]]
name = "meter_reads_versions"
time_model = "point"
"#,
)
.expect("parse");
let table = s.single_table().expect("validate");
assert_eq!(table.time_model(), crate::config::TimeModel::Point);
assert!(table.melo_in_merge_key());
}
#[test]
fn the_messlokation_key_can_be_pinned_from_the_configuration_file() {
let s: Settings = toml::from_str(
r#"
[hot]
url = "postgresql://edm@db/prod"
[cold]
catalog = "sql"
uri = "postgresql://edm@db/prod"
warehouse = "file:///tmp/wh"
namespace = "metering"
[[tables]]
name = "meter_reads_versions"
time_model = "point"
identify_by_melo = false
"#,
)
.expect("parse");
assert!(!s.single_table().expect("validate").melo_in_merge_key());
}
#[test]
fn a_table_defaults_to_the_interval_shape() {
let s: Settings = toml::from_str(EXAMPLE).expect("parse");
let table = s.single_table().expect("validate");
assert_eq!(table.time_model(), crate::config::TimeModel::Interval);
assert!(!table.melo_in_merge_key());
}
#[test]
fn the_subject_column_is_registered_as_an_attribute() {
let table = Settings::from_toml(EXAMPLE)
.unwrap()
.single_table()
.unwrap();
assert_eq!(table.subject_column(), Some("subject_ref"));
assert!(!table.merge_key().contains(&"subject_ref".to_string()));
}
#[test]
fn the_infrastructure_sections_are_validated_too() {
let example: Settings = Settings::from_toml(EXAMPLE).unwrap();
example
.validate_all()
.expect("the documented example is complete");
for (mutate, expected) in [
(
Box::new(|s: &mut Settings| s.hot.url.clear()) as Box<dyn Fn(&mut Settings)>,
"[hot] url",
),
(
Box::new(|s: &mut Settings| s.hot.max_connections = 0),
"max_connections",
),
(
Box::new(|s: &mut Settings| s.cold.uri.clear()),
"[cold] uri",
),
(
Box::new(|s: &mut Settings| s.cold.warehouse.clear()),
"[cold] warehouse",
),
(
Box::new(|s: &mut Settings| s.cold.file_target_bytes = 0),
"file_target_bytes",
),
(
Box::new(|s: &mut Settings| "ftp://host/wh".clone_into(&mut s.cold.warehouse)),
"ftp",
),
] {
let mut broken = example.clone();
mutate(&mut broken);
let err = broken
.validate_all()
.expect_err("a section that cannot build a tier must not validate")
.to_string();
assert!(err.contains(expected), "expected {expected:?} in {err}");
broken
.validate()
.expect("validate checks the tables, which are still fine");
}
}
#[test]
fn the_cold_section_never_prints_its_password() {
let mut settings = Settings::from_toml(EXAMPLE).unwrap();
settings.cold.catalog = CatalogKind::Sql;
"postgresql://edm:hunter2@db.internal/prod".clone_into(&mut settings.cold.uri);
let shown = format!("{:?}", settings.cold);
assert!(!shown.contains("hunter2"), "{shown}");
assert!(shown.contains("postgresql://<redacted>"), "{shown}");
assert!(shown.contains("s3://edm/meterstore"), "{shown}");
}
#[test]
fn a_subject_column_also_listed_in_extra_columns_is_still_the_subject_column() {
let toml = r#"
[[tables]]
name = "readings"
subject_column = "subject_ref"
extra_columns = [{ name = "subject_ref", values = ["A", "B"] }]
"#;
let table = Settings::from_toml(toml).unwrap().single_table().unwrap();
assert_eq!(table.subject_column(), Some("subject_ref"));
let declared: Vec<_> = table
.attribute_columns()
.iter()
.filter(|f| f.name() == "subject_ref")
.collect();
assert_eq!(declared.len(), 1);
assert_eq!(
declared[0]
.metadata()
.get(crate::config::CHECK_VALUES_KEY)
.map(String::as_str),
Some("A,B"),
);
assert!(!table.merge_key().contains(&"subject_ref".to_string()));
}
#[test]
fn a_checked_column_is_declarable_from_a_file() {
let toml = r#"
[[tables]]
name = "readings"
extra_columns = [
{ name = "bilanzkreis", check = "EIC:X" },
{ name = "bilanzierungsgebiet", check = "EIC:Y" },
{ name = "any_eic", check = "EIC" },
{ name = "lieferant", check = "BDEW" },
{ name = "unterliegende_malo", check = "MALO" },
{ name = "referenz_melo", check = "MELO" },
]
"#;
let table = Settings::from_toml(toml).unwrap().single_table().unwrap();
let declared: Vec<_> = table
.attribute_columns()
.iter()
.map(|f| (f.name().as_str(), crate::config::declared_value_check(f)))
.collect();
assert_eq!(
declared,
vec![
("bilanzkreis", Some("EIC:X")),
("bilanzierungsgebiet", Some("EIC:Y")),
("any_eic", Some("EIC")),
("lieferant", Some("BDEW")),
("unterliegende_malo", Some("MALO")),
("referenz_melo", Some("MELO")),
]
);
}
#[test]
fn an_object_type_a_file_cannot_enforce_is_refused_by_name() {
let err = Settings::from_toml(
r#"
[[tables]]
name = "readings"
extra_columns = [{ name = "bilanzkreis", check = "EIC:Q" }]
"#,
)
.expect("parses")
.validate()
.expect_err("Q is not a listed object type")
.to_string();
assert!(err.contains("EIC:Q"), "{err}");
assert!(
err.contains("EIC:X"),
"the message names what is accepted: {err}"
);
}
#[test]
fn every_value_check_the_crate_knows_is_declarable_from_a_file() {
for scheme in crate::config::ValueCheck::ALL {
let toml = format!(
"[[tables]]\nname = \"readings\"\nextra_columns = [{{ name = \"c\", check = \"{scheme}\" }}]\n"
);
let table = Settings::from_toml(&toml)
.unwrap_or_else(|e| panic!("{scheme} is not declarable from a file: {e}"))
.single_table()
.expect("one table");
assert_eq!(
crate::config::declared_value_check(&table.attribute_columns()[0]),
Some(scheme.as_str())
);
}
}
#[test]
fn a_column_cannot_be_both_a_vocabulary_and_an_identifier_scheme() {
let toml = r#"
[[tables]]
name = "readings"
extra_columns = [{ name = "bilanzkreis", check = "EIC", values = ["A", "B"] }]
"#;
let err = Settings::from_toml(toml)
.unwrap()
.single_table()
.unwrap_err()
.to_string();
assert!(err.contains("bilanzkreis"), "{err}");
}
#[test]
fn an_unsupported_check_names_the_ones_that_exist() {
let toml = r#"
[[tables]]
name = "readings"
extra_columns = [{ name = "iban", check = "IBAN" }]
"#;
let err = Settings::from_toml(toml)
.unwrap()
.single_table()
.unwrap_err()
.to_string();
assert!(err.contains("IBAN"), "{err}");
assert!(err.contains("EIC"), "{err}");
}
#[test]
fn a_suppression_key_is_a_secret_and_never_reaches_a_log() {
let settings = Settings::from_toml(
r#"
[privacy]
erasure_secret = "0123456789abcdef0123456789abcdef"
[[tables]]
name = "readings"
subject_column = "subject_ref"
"#,
)
.expect("parses");
let rendered = format!("{:?}", settings.privacy);
assert!(!rendered.contains("0123456789"), "{rendered}");
assert!(rendered.contains("***"), "{rendered}");
assert!(format!("{settings:?}").contains("***"));
}
#[tokio::test]
async fn a_short_suppression_key_is_refused_where_it_is_used() {
let pool = sqlx::PgPool::connect_lazy("postgresql://localhost/unused").expect("lazy pool");
let err = PrivacySettings {
erasure_secret: Some("too short".to_string()),
..PrivacySettings::default()
}
.registry(pool.clone())
.expect_err("32 bytes at least")
.to_string();
assert!(err.contains("32 bytes"), "{err}");
let err = Settings::from_toml(
r#"
[privacy]
erasure_secret = "too short"
[[tables]]
name = "readings"
subject_column = "subject_ref"
"#,
)
.expect("parses")
.validate()
.expect_err("32 bytes at least")
.to_string();
assert!(err.contains("erasure_secret"), "{err}");
assert!(err.contains("32"), "{err}");
let suppressing = PrivacySettings {
erasure_secret: Some("0123456789abcdef0123456789abcdef".to_string()),
..PrivacySettings::default()
}
.registry(pool.clone())
.expect("32 bytes");
assert!(suppressing.suppresses_reregistration());
assert!(
!PrivacySettings::default()
.registry(pool)
.expect("no key is a valid configuration")
.suppresses_reregistration()
);
}
#[tokio::test]
async fn rotating_the_erasure_key_keeps_the_outgoing_one_reading() {
let pool = sqlx::PgPool::connect_lazy("postgresql://localhost/unused").expect("lazy pool");
let settings = Settings::from_toml(
r#"
[privacy]
erasure_secret = "0123456789abcdef0123456789abcdef"
retired_erasure_secrets = ["fedcba9876543210fedcba9876543210"]
[[tables]]
name = "readings"
subject_column = "subject_ref"
"#,
)
.expect("parses");
settings.validate().expect("both keys are long enough");
let registry = settings.privacy.registry(pool.clone()).expect("ring");
assert!(registry.suppresses_reregistration());
assert_eq!(registry.erasure_key_count(), 2);
let err = Settings::from_toml(
r#"
[privacy]
erasure_secret = "0123456789abcdef0123456789abcdef"
retired_erasure_secrets = ["short"]
[[tables]]
name = "readings"
subject_column = "subject_ref"
"#,
)
.expect("parses")
.validate()
.expect_err("a weak retired key is still a weak key")
.to_string();
assert!(err.contains("retired_erasure_secrets[0]"), "{err}");
let err = Settings::from_toml(
r#"
[privacy]
retired_erasure_secrets = ["0123456789abcdef0123456789abcdef"]
[[tables]]
name = "readings"
subject_column = "subject_ref"
"#,
)
.expect("parses")
.validate()
.expect_err("retired keys cannot write")
.to_string();
assert!(err.contains("erasure_secret"), "{err}");
}
#[test]
fn a_deployment_with_no_subject_column_needs_no_privacy_section() {
let settings = Settings::from_toml(
r#"
[[tables]]
name = "readings"
"#,
)
.expect("parses");
assert!(settings.privacy.erasure_secret.is_none());
}
#[test]
fn a_subject_column_declared_as_identity_is_refused() {
let toml = r#"
[[tables]]
name = "readings"
subject_column = "subject_ref"
extra_columns = [{ name = "subject_ref", identity = true }]
"#;
let err = Settings::from_toml(toml)
.unwrap()
.single_table()
.unwrap_err()
.to_string();
assert!(err.contains("merge key"), "{err}");
}
#[test]
fn a_separate_partition_step_is_not_a_setting() {
let toml = r#"
[[tables]]
name = "readings"
[tables.hot]
partition_step = "1w"
"#;
let err = Settings::from_toml(toml).unwrap_err().to_string();
assert!(err.contains("partition_step"), "{err}");
}
#[test]
fn an_unknown_key_is_an_error_rather_than_ignored() {
let toml = r#"
[[tables]]
name = "readings"
[tables.archival]
settlment_lag = "7d"
"#;
assert!(Settings::from_toml(toml).is_err());
}
#[test]
fn a_file_with_no_tables_is_an_error() {
assert!(
Settings::from_toml("[hot]\nurl = \"x\"\n")
.unwrap()
.validate()
.is_err()
);
}
#[test]
fn several_tables_are_not_silently_narrowed_to_the_first() {
let toml = "[[tables]]\nname = \"electricity\"\n\n[[tables]]\nname = \"gas\"\n";
let settings = Settings::from_toml(toml).unwrap();
assert_eq!(settings.validate().unwrap().len(), 2);
assert!(settings.single_table().is_err());
}
#[test]
fn durations_parse_the_way_operators_write_them() {
assert_eq!(
parse_duration("250ms").unwrap(),
Duration::milliseconds(250)
);
assert_eq!(parse_duration("30s").unwrap(), Duration::seconds(30));
assert_eq!(parse_duration("15m").unwrap(), Duration::minutes(15));
assert_eq!(parse_duration("6h").unwrap(), Duration::hours(6));
assert_eq!(parse_duration("7d").unwrap(), Duration::days(7));
assert_eq!(parse_duration("2w").unwrap(), Duration::weeks(2));
assert_eq!(parse_duration("10y").unwrap(), Duration::days(3_650));
}
#[test]
fn a_duration_with_no_unit_is_rejected() {
assert!(parse_duration("7").is_err());
assert!(parse_duration("7 fortnights").is_err());
}
#[test]
fn the_hot_section_carries_a_ddl_lock_timeout() {
let quiet: Settings = toml::from_str(
r#"
[hot]
url = "postgresql://localhost/meterstore"
[[tables]]
name = "readings_versions"
"#,
)
.unwrap();
assert_eq!(quiet.hot.ddl_lock_timeout.0, Duration::seconds(3));
let stated: Settings = toml::from_str(
r#"
[hot]
url = "postgresql://localhost/meterstore"
ddl_lock_timeout = "750ms"
[[tables]]
name = "readings_versions"
"#,
)
.unwrap();
assert_eq!(stated.hot.ddl_lock_timeout.0, Duration::milliseconds(750));
let text = stated.to_toml().unwrap();
let back = Settings::from_toml(&text).unwrap();
assert_eq!(back.hot.ddl_lock_timeout.0, Duration::milliseconds(750));
}
#[test]
fn durations_round_trip_through_the_file_format() {
for text in ["250ms", "30s", "15m", "6h", "7d", "2w", "10y"] {
let parsed = parse_duration(text).unwrap();
assert_eq!(
parse_duration(&format_duration(parsed)).unwrap(),
parsed,
"{text} did not round-trip"
);
}
}
#[test]
fn settings_round_trip_through_toml() {
let original = Settings::from_toml(EXAMPLE).unwrap();
let rendered = original.to_toml().unwrap();
let reparsed = Settings::from_toml(&rendered).unwrap();
assert_eq!(
reparsed.single_table().unwrap().merge_key(),
original.single_table().unwrap().merge_key()
);
}
#[test]
fn environment_variables_are_interpolated() {
let settings =
Settings::from_toml("[hot]\nurl = \"postgresql://${CARGO_PKG_NAME}\"\n").unwrap();
assert_eq!(settings.hot.url, "postgresql://meterstore");
}
#[test]
fn interpolation_handles_several_variables_and_surrounding_text() {
let settings = Settings::from_toml(
"[cold]\nwarehouse = \"s3://${CARGO_PKG_NAME}/${CARGO_PKG_NAME}\"\n",
)
.unwrap();
assert_eq!(settings.cold.warehouse, "s3://meterstore/meterstore");
}
#[test]
fn a_comment_may_document_a_placeholder_without_resolving_it() {
let settings = Settings::from_toml(
"# set url = \"${METERSTORE_DEFINITELY_UNSET}\" to take it from the environment\n\
[hot]\n\
url = \"postgresql://${CARGO_PKG_NAME}\" # and here it is\n",
)
.expect("a comment is documentation, not a reference to resolve");
assert_eq!(settings.hot.url, "postgresql://meterstore");
}
#[test]
fn a_hash_inside_a_value_is_not_a_comment() {
let settings =
Settings::from_toml("[hot]\nurl = \"postgresql://u:p#w@host/db\"\n").unwrap();
assert_eq!(settings.hot.url, "postgresql://u:p#w@host/db");
}
#[test]
fn an_unterminated_placeholder_is_an_error() {
assert!(Settings::from_toml("[hot]\nurl = \"${OOPS\"\n").is_err());
}
#[test]
fn a_missing_environment_variable_is_an_error() {
let err = Settings::from_toml("[hot]\nurl = \"${METERSTORE_DEFINITELY_UNSET}\"\n")
.unwrap_err()
.to_string();
assert!(err.contains("METERSTORE_DEFINITELY_UNSET"), "{err}");
}
#[test]
fn a_connection_url_is_never_printed_in_full() {
let settings = Settings::from_toml(EXAMPLE).unwrap();
let shown = format!("{:?}", settings.hot);
assert!(!shown.contains("db.internal"), "{shown}");
assert!(shown.contains("postgresql://<redacted>"), "{shown}");
}
#[test]
fn a_coded_column_is_declarable_from_a_file() {
let toml = r#"
[[tables]]
name = "readings"
extra_columns = [{ name = "ingest_source", values = ["MSCONS", "SMGW"] }]
"#;
let config = Settings::from_toml(toml).unwrap().single_table().unwrap();
let column = config
.attribute_columns()
.iter()
.find(|f| f.name() == "ingest_source")
.expect("declared");
assert_eq!(
column
.metadata()
.get(crate::config::CHECK_VALUES_KEY)
.map(String::as_str),
Some("MSCONS,SMGW"),
"the vocabulary must reach the field the hot-table DDL reads"
);
}
#[test]
fn a_vocabulary_that_could_not_survive_the_ddl_is_refused() {
for bad in [r#"values = ["A,B"]"#, "values = []"] {
let toml = format!(
r#"
[[tables]]
name = "readings"
extra_columns = [{{ name = "ingest_source", {bad} }}]
"#
);
assert!(
Settings::from_toml(&toml).unwrap().single_table().is_err(),
"{bad} was accepted"
);
}
}
#[test]
fn a_non_string_extra_column_is_rejected_with_a_reason() {
let toml = r#"
[[tables]]
name = "readings"
extra_columns = [{ name = "reading_count", type = "int64" }]
"#;
let err = Settings::from_toml(toml)
.unwrap()
.single_table()
.unwrap_err()
.to_string();
assert!(err.contains("string"), "{err}");
}
}