#![cfg(feature = "testkit")]
use meterstore::config::TableConfig;
use meterstore::testkit::{MeteringWorkload, TestHarness};
use meterstore::{MeterCatalog, ValidatedTableConfig};
use time::macros::datetime;
use time::{Duration, OffsetDateTime};
const START: OffsetDateTime = datetime!(2026-07-20 00:00 UTC);
const SECOND: &str = "esa_typ2_versions";
fn config(name: &str) -> ValidatedTableConfig {
TableConfig::new(name)
.settlement_lag(Duration::days(1))
.build()
.expect("config")
}
async fn two_tables() -> (TestHarness, MeterCatalog) {
let harness = TestHarness::start().await.expect("harness");
let catalog = MeterCatalog::builder()
.table(
harness
.builder_for(config(TestHarness::TABLE))
.await
.expect("primary builder"),
)
.table(
harness
.builder_for(config(SECOND))
.await
.expect("secondary builder"),
)
.build()
.await
.expect("catalog");
catalog.create_tables().await.expect("create both tables");
for store in catalog.tables() {
store
.hot_store()
.ensure_partitions(
store.config().name(),
START,
START + Duration::days(3),
Duration::DAY,
)
.await
.expect("partitions");
harness
.seed_watermark_for(store.config().name(), START, Duration::DAY)
.await
.expect("watermark");
}
let billing = MeteringWorkload::new(START)
.seed(0xB111)
.malo_ids(3)
.days(2);
let esa = MeteringWorkload::new(START)
.seed(0xE5A)
.malo_offset(500)
.malo_ids(1)
.days(2);
catalog
.table(TestHarness::TABLE)
.expect("primary")
.append(&billing.generate().expect("workload"))
.await
.expect("append billing");
catalog
.table(SECOND)
.expect("secondary")
.append(&esa.generate().expect("workload"))
.await
.expect("append esa");
(harness, catalog)
}
fn count(result: &meterstore::QueryResult) -> i64 {
use meterstore::arrow::array::AsArray;
result.batches()[0]
.column(0)
.as_primitive::<meterstore::arrow::datatypes::Int64Type>()
.value(0)
}
#[tokio::test]
async fn one_statement_can_mention_both_tables() {
let (_h, catalog) = two_tables().await;
let joined = catalog
.query(
"SELECT COUNT(*) FROM readings r \
JOIN esa_typ2 e ON r.malo_id = e.malo_id",
)
.await
.expect("a cross-table join must plan and run");
assert_eq!(count(&joined), 0);
let both = catalog
.query(
"SELECT COUNT(*) FROM (\
SELECT malo_id FROM readings UNION ALL SELECT malo_id FROM esa_typ2\
) AS all_rows",
)
.await
.expect("union");
let billing_only = catalog
.query("SELECT COUNT(*) FROM readings")
.await
.expect("billing");
let esa_only = catalog
.query("SELECT COUNT(*) FROM esa_typ2")
.await
.expect("esa");
assert_eq!(count(&both), count(&billing_only) + count(&esa_only));
assert!(count(&esa_only) > 0, "the second table must hold something");
assert_ne!(
count(&billing_only),
count(&esa_only),
"the two tables must differ, or a mix-up would be invisible"
);
}
#[tokio::test]
async fn an_isolated_session_cannot_name_the_other_table() {
let (_h, catalog) = two_tables().await;
assert!(catalog.query("SELECT COUNT(*) FROM esa_typ2").await.is_ok());
let billing = catalog.isolated("readings").await.expect("isolated");
assert!(billing.query("SELECT COUNT(*) FROM readings").await.is_ok());
assert!(
billing
.query("SELECT COUNT(*) FROM readings_versions")
.await
.is_ok()
);
for sql in [
"SELECT COUNT(*) FROM esa_typ2",
"SELECT COUNT(*) FROM esa_typ2_versions",
"SELECT COUNT(*) FROM readings UNION ALL SELECT COUNT(*) FROM esa_typ2",
"SELECT (SELECT COUNT(*) FROM esa_typ2)",
] {
assert!(
billing.query(sql).await.is_err(),
"an isolated session must not reach the other table: {sql}"
);
}
assert!(catalog.query("SELECT COUNT(*) FROM esa_typ2").await.is_ok());
assert_eq!(catalog.len(), 2);
}
#[tokio::test]
async fn isolating_an_unknown_table_names_what_is_there() {
let (_h, catalog) = two_tables().await;
let err = catalog
.isolated("no_such_table")
.await
.unwrap_err()
.to_string();
assert!(err.contains("no_such_table"), "{err}");
assert!(
err.contains("readings"),
"the message lists what is held: {err}"
);
}
#[tokio::test]
async fn a_result_carries_every_table_boundary_not_one() {
let (_h, catalog) = two_tables().await;
catalog
.table(TestHarness::TABLE)
.expect("primary")
.archive(START + Duration::days(2), 1)
.await
.expect("archive");
let result = catalog
.query("SELECT COUNT(*) FROM readings UNION ALL SELECT COUNT(*) FROM esa_typ2")
.await
.expect("query");
let watermarks = result.watermarks();
assert_eq!(watermarks.len(), 2, "one entry per table: {watermarks:?}");
let primary = watermarks
.iter()
.find(|(n, _)| n == TestHarness::TABLE)
.expect("primary boundary");
let secondary = watermarks
.iter()
.find(|(n, _)| n == SECOND)
.expect("secondary boundary");
assert_ne!(
primary.1, secondary.1,
"the fixture must leave the two tables at different boundaries"
);
assert_eq!(result.watermark(), secondary.1.min(primary.1));
}
#[tokio::test]
async fn a_result_is_attributed_only_to_the_tables_it_read() {
let (_h, catalog) = two_tables().await;
catalog
.table(TestHarness::TABLE)
.expect("primary")
.archive(START + Duration::days(2), 1)
.await
.expect("archive");
let one = catalog
.query("SELECT COUNT(*) FROM readings")
.await
.expect("query");
assert_eq!(
one.watermarks().len(),
1,
"only the table the statement read: {:?}",
one.watermarks()
);
assert_eq!(one.watermarks()[0].0, TestHarness::TABLE);
assert_eq!(
one.watermark(),
catalog
.table(TestHarness::TABLE)
.expect("primary")
.watermark()
.await
.expect("watermark"),
"and the scalar is that table's own boundary"
);
let raw = catalog
.query("SELECT COUNT(*) FROM readings_versions")
.await
.expect("query");
assert_eq!(raw.watermarks().len(), 1);
let none = catalog.query("SELECT 1").await.expect("query");
assert!(none.watermarks().is_empty(), "{:?}", none.watermarks());
let nested = catalog
.query(
"SELECT (SELECT COUNT(*) FROM readings) AS billing, \
(SELECT COUNT(*) FROM esa_typ2) AS second",
)
.await
.expect("query");
let named: Vec<&str> = nested
.watermarks()
.iter()
.map(|(t, _)| t.as_str())
.collect();
assert_eq!(named.len(), 2, "both subqueries name a table: {named:?}");
assert!(nested.watermark() > meterstore::TieringWatermark::empty());
let cte = catalog
.query("WITH r AS (SELECT * FROM readings) SELECT COUNT(*) FROM r")
.await
.expect("query");
assert_eq!(cte.watermarks().len(), 1, "{:?}", cte.watermarks());
}
#[tokio::test]
async fn system_tables_show_every_table() {
let (_h, catalog) = two_tables().await;
catalog
.archive_all(START + Duration::days(2), 4)
.await
.expect("archive");
catalog
.refresh_system_tables(START + Duration::days(3))
.await
.expect("refresh");
for relation in ["tables", "config", "resolution", "snapshots"] {
let result = catalog
.query(&format!(
"SELECT DISTINCT \"table\" FROM system.{relation} ORDER BY 1"
))
.await
.unwrap_or_else(|e| panic!("querying system.{relation}: {e}"));
use meterstore::arrow::array::AsArray;
let mut names = Vec::new();
for batch in result.batches() {
let column = batch.column(0).as_string::<i32>();
for i in 0..batch.num_rows() {
names.push(column.value(i).to_string());
}
}
names.sort();
assert_eq!(
names,
vec![SECOND.to_string(), TestHarness::TABLE.to_string()],
"system.{relation} must attribute its rows to both tables"
);
}
}
#[tokio::test]
async fn each_table_keeps_its_own_watermark_and_archiver() {
let (_h, catalog) = two_tables().await;
let before = catalog
.table(SECOND)
.expect("secondary")
.watermark()
.await
.expect("watermark");
catalog
.table(TestHarness::TABLE)
.expect("primary")
.archive(START + Duration::days(2), 1)
.await
.expect("archive");
let after = catalog
.table(SECOND)
.expect("secondary")
.watermark()
.await
.expect("watermark");
assert_eq!(before, after, "one table's archival is its own");
let primary = catalog
.table(TestHarness::TABLE)
.expect("primary")
.watermark()
.await
.expect("watermark");
assert!(primary > after, "and the archived one did move");
}
#[tokio::test]
async fn archiving_the_whole_catalog_reports_per_table() {
let (_h, catalog) = two_tables().await;
let outcomes = catalog
.archive_all(START + Duration::days(2), 4)
.await
.expect("archive all");
let names: Vec<&str> = outcomes.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(
names,
[SECOND, TestHarness::TABLE],
"name order, both present"
);
for store in catalog.tables() {
assert!(
store.watermark().await.expect("watermark").get() > START,
"{} did not advance",
store.config().name()
);
}
assert!(catalog.verify_invariant().await.expect("check").is_empty());
}
#[tokio::test]
async fn a_duplicate_table_name_is_refused() {
let harness = TestHarness::start().await.expect("harness");
let err = MeterCatalog::builder()
.table(
harness
.builder_for(config(TestHarness::TABLE))
.await
.expect("first"),
)
.table(
harness
.builder_for(config(TestHarness::TABLE))
.await
.expect("second"),
)
.build()
.await
.expect_err("a duplicate name must be refused");
let msg = err.to_string();
assert!(msg.contains("readings_versions"), "{msg}");
assert!(
msg.contains("silently read whichever won"),
"the message names the consequence: {msg}"
);
}
#[tokio::test]
async fn an_empty_catalog_is_refused() {
let err = MeterCatalog::builder()
.build()
.await
.expect_err("an empty catalog answers no query");
assert!(err.to_string().contains("at least one table"));
}
#[tokio::test]
async fn two_tables_whose_registered_names_collide_are_refused() {
let harness = TestHarness::start().await.expect("harness");
let err = MeterCatalog::builder()
.table(
harness
.builder_for(config("readings_versions"))
.await
.expect("first"),
)
.table(
harness
.builder_for(config("readings"))
.await
.expect("second"),
)
.build()
.await
.expect_err("colliding registered names must be refused");
let msg = err.to_string();
assert!(msg.contains("readings"), "{msg}");
assert!(
msg.contains("registers") || msg.contains("register"),
"the message must be about the registered name, not the configured one: {msg}"
);
}
#[tokio::test]
async fn a_catalog_with_mixed_read_modes_is_refused() {
use meterstore::ReadMode;
let harness = TestHarness::start().await.expect("harness");
let err = MeterCatalog::builder()
.table(
harness
.builder_for(config(TestHarness::TABLE))
.await
.expect("first"),
)
.table(
harness
.builder_for(config(SECOND))
.await
.expect("second")
.read_mode(ReadMode::Historical),
)
.build()
.await
.expect_err("mixed read modes must be refused");
let msg = err.to_string();
assert!(msg.contains("read mode"), "{msg}");
assert!(
msg.contains("mutable tier"),
"the message names the risk: {msg}"
);
}
#[tokio::test]
async fn a_cross_table_query_takes_bound_parameters() {
let (_h, catalog) = two_tables().await;
let all = catalog
.query("SELECT COUNT(*) FROM readings")
.await
.expect("unfiltered");
let filtered = catalog
.query_with_params(
"SELECT COUNT(*) FROM readings WHERE malo_id = $1",
vec![datafusion::scalar::ScalarValue::Utf8(Some(
"10000000009".to_string(),
))],
)
.await
.expect("a bound parameter must reach the engine");
assert!(count(&filtered) > 0, "the fixture must contain that meter");
assert!(
count(&filtered) < count(&all),
"and the parameter must actually have filtered"
);
}
#[cfg(feature = "flight")]
#[tokio::test]
async fn a_flight_client_over_a_catalog_store_sees_every_table() {
use arrow_flight::sql::client::FlightSqlServiceClient;
use futures::TryStreamExt;
use meterstore::serve::FlightSqlServer;
use tonic::transport::Channel;
let (_h, catalog) = two_tables().await;
let store = catalog.table(TestHarness::TABLE).expect("primary").clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let (shutdown, rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(FlightSqlServer::new(store).into_service())
.serve_with_incoming_shutdown(
tokio_stream::wrappers::TcpListenerStream::new(listener),
async {
let _ = rx.await;
},
)
.await
.expect("flight server");
});
let channel = Channel::from_shared(format!("http://{address}"))
.expect("endpoint")
.connect()
.await
.expect("connect");
let mut client = FlightSqlServiceClient::new(channel);
let info = client
.execute(
"SELECT table_name FROM information_schema.tables ORDER BY 1".to_string(),
None,
)
.await
.expect("get_flight_info");
let ticket = info.endpoint[0].ticket.clone().expect("ticket");
let batches: Vec<_> = client
.do_get(ticket)
.await
.expect("do_get")
.try_collect()
.await
.expect("collect");
use meterstore::arrow::array::AsArray;
let mut names = Vec::new();
for batch in &batches {
let column = batch.column(0).as_string::<i32>();
for i in 0..batch.num_rows() {
names.push(column.value(i).to_string());
}
}
for expected in [
"readings",
"readings_versions",
"esa_typ2",
"esa_typ2_versions",
] {
assert!(
names.iter().any(|n| n == expected),
"a Flight client must see {expected}: {names:?}"
);
}
drop(shutdown);
}
#[tokio::test]
async fn one_maintenance_loop_covers_every_table_and_names_them_apart() {
let (_h, catalog) = two_tables().await;
let maintenance = catalog.maintenance();
let mut covered = maintenance.tables();
covered.sort_unstable();
assert_eq!(covered, vec![SECOND, TestHarness::TABLE]);
let outcome = maintenance
.run_once(START + Duration::days(2))
.await
.expect("cycle");
assert_eq!(outcome.tables.len(), 2);
let mut named: Vec<&str> = outcome.tables.iter().map(|t| t.table.as_str()).collect();
named.sort_unstable();
assert_eq!(named, vec![SECOND, TestHarness::TABLE]);
assert!(outcome.rows_archived() > 0, "a window must have moved");
assert!(outcome.healthy(), "{:?}", outcome.tables);
assert_eq!(outcome.unhealthy().count(), 0);
for store in catalog.tables() {
assert!(
store.watermark().await.expect("watermark").get() > START,
"{} did not advance",
store.config().name()
);
}
}
fn tenanted(name: &str) -> ValidatedTableConfig {
use datafusion::arrow::datatypes::{DataType, Field};
TableConfig::new(name)
.settlement_lag(Duration::days(1))
.identity_column(Field::new("tenant", DataType::Utf8, false))
.build()
.expect("config")
}
fn tenant_reading(tenant: &str, kwh: i64, version: u128) -> meterstore::encode::StoredSeries {
use datafusion::common::ScalarValue;
use metering::interval::{MeterInterval, Sparte};
use metering::measurement_series::{MeasurementSeries, MeasurementSource};
let interval = MeterInterval {
from: START,
to: START + Duration::minutes(15),
value: rust_decimal::Decimal::new(kwh, 0),
quality: metering::QualityFlag::Measured,
obis_code: "1-0:1.8.0".parse().ok(),
};
let series = MeasurementSeries::new(
"11111111115".parse().unwrap(),
"1-0:1.8.0".parse().ok(),
vec![interval],
MeasurementSource::ManualEntry {
operator_id: "test".to_string(),
reason: "fixture".to_string(),
},
datetime!(2026-07-27 06:00 UTC),
);
meterstore::encode::StoredSeries::new(
series,
meterstore::ScopedVersion::new(
meterstore::VersionScope::for_interval("9900000000001", START, Sparte::Strom).unwrap(),
meterstore::Version::new(version).unwrap(),
),
datetime!(2026-07-27 06:00 UTC),
)
.with_extra("tenant", ScalarValue::Utf8(Some(tenant.to_string())))
}
async fn two_tenanted_tables() -> (TestHarness, MeterCatalog) {
let harness = TestHarness::with_config(tenanted(TestHarness::TABLE))
.await
.expect("harness");
let catalog = MeterCatalog::builder()
.table(
harness
.builder_for(tenanted(TestHarness::TABLE))
.await
.expect("primary builder"),
)
.table(
harness
.builder_for(tenanted(SECOND))
.await
.expect("secondary builder"),
)
.build()
.await
.expect("catalog");
catalog.create_tables().await.expect("create both");
for store in catalog.tables() {
store
.hot_store()
.ensure_partitions(
store.config().name(),
START,
START + Duration::days(2),
Duration::DAY,
)
.await
.expect("partitions");
harness
.seed_watermark_for(store.config().name(), START, Duration::DAY)
.await
.expect("watermark");
store
.append(&[
tenant_reading("a", 10, 20_260_720_000_001),
tenant_reading("b", 7, 20_260_720_000_002),
])
.await
.expect("append");
}
(harness, catalog)
}
#[tokio::test]
async fn a_catalog_confines_every_table_to_one_tenant() {
let (_h, catalog) = two_tenanted_tables().await;
async fn total(c: &MeterCatalog) -> i64 {
count(
&c.query("SELECT SUM(value)::BIGINT FROM readings")
.await
.expect("query"),
)
}
assert_eq!(total(&catalog).await, 17);
let confined = catalog.scoped("tenant", "a").await.expect("scoped");
assert_eq!(total(&confined).await, 10, "one tenant's rows only");
let joined = confined
.query(
"SELECT SUM(r.value)::BIGINT FROM readings r \
JOIN esa_typ2 e ON r.malo_id = e.malo_id",
)
.await
.expect("a scoped catalog still joins");
assert_eq!(count(&joined), 10, "the second table is scoped too");
let escape = confined
.query("SELECT SUM(value)::BIGINT FROM readings WHERE tenant = 'b'")
.await
.expect("plans");
assert_eq!(count(&escape), 0, "the scope is enforced, not advisory");
let raw = confined
.query("SELECT COUNT(DISTINCT tenant)::BIGINT FROM readings_versions")
.await
.expect("plans");
assert_eq!(count(&raw), 1);
}
#[tokio::test]
async fn a_catalog_scope_is_all_of_its_tables_or_none_of_them() {
let harness = TestHarness::with_config(tenanted(TestHarness::TABLE))
.await
.expect("harness");
let catalog = MeterCatalog::builder()
.table(
harness
.builder_for(tenanted(TestHarness::TABLE))
.await
.expect("primary"),
)
.table(
harness
.builder_for(config(SECOND))
.await
.expect("secondary"),
)
.build()
.await
.expect("catalog");
let err = catalog
.scoped("tenant", "a")
.await
.expect_err("one table cannot carry the scope")
.to_string();
assert!(err.contains(SECOND), "the message names the table: {err}");
assert!(err.contains("tenant"), "{err}");
assert!(
err.contains("isolate"),
"the message names the honest alternative: {err}"
);
assert!(catalog.scoped("bilanzkreis", "BK-1").await.is_err());
}
#[tokio::test]
async fn a_catalog_scope_only_ever_narrows() {
let (_h, catalog) = two_tenanted_tables().await;
let a = catalog.scoped("tenant", "a").await.expect("scoped");
a.scoped("tenant", "a").await.expect("same value");
let err = a
.scoped("tenant", "b")
.await
.expect_err("re-scoping widens")
.to_string();
assert!(err.contains("narrows"), "{err}");
}
#[tokio::test]
async fn a_derived_catalog_keeps_the_scope_it_was_given() {
let (_h, catalog) = two_tenanted_tables().await;
let confined = catalog.scoped("tenant", "a").await.expect("scoped");
let historical = confined
.in_read_mode(meterstore::ReadMode::Historical)
.await
.expect("historical");
for store in historical.tables() {
assert_eq!(
store.row_scope().len(),
1,
"{} lost its scope",
store.config().name()
);
}
let known = confined
.as_known_at(datetime!(2026-07-28 00:00 UTC))
.await
.expect("as_known_at");
for store in known.tables() {
assert_eq!(store.row_scope().len(), 1);
}
let err = catalog
.in_read_mode(meterstore::ReadMode::AsOf {
snapshot: meterstore::SnapshotSelector::Id(1),
max_version: None,
})
.await
.expect_err("a snapshot belongs to one table")
.to_string();
assert!(err.contains("one table"), "{err}");
assert!(err.contains("as_known_at"), "{err}");
}
#[tokio::test]
async fn a_failing_table_reaches_the_caller_as_the_error_it_raised() {
let (_h, catalog) = two_tables().await;
let store = catalog.table(SECOND).expect("secondary");
store
.hot_store()
.drop_table(SECOND)
.await
.expect("drop the hot half");
let direct = store
.archive(START + Duration::days(30), 4)
.await
.expect_err("the hot table is gone");
let through_catalog = catalog
.archive_all(START + Duration::days(30), 4)
.await
.expect_err("so the catalog cannot archive it either");
assert_eq!(
through_catalog.to_string(),
direct.to_string(),
"the catalog wrapped the error instead of passing it on"
);
assert_eq!(
through_catalog.is_retryable(),
direct.is_retryable(),
"wrapping changed whether a supervisor will retry"
);
}