#![cfg(feature = "testkit")]
use meterstore::arrow::array::RecordBatch;
use meterstore::encode::schema::col;
use meterstore::hot::PostgresHot;
use meterstore::tiering::store::{BatchStream, HotStore, PartitionId, ScanSpec};
async fn collect_stream(stream: BatchStream) -> Vec<datafusion::arrow::array::RecordBatch> {
use futures::StreamExt;
stream.map(|b| b.expect("batch")).collect::<Vec<_>>().await
}
use meterstore::watermark::TieringWatermark;
use metering::measurement_series::MeasurementSource;
use sqlx::{PgPool, Row};
use time::macros::datetime;
use time::{Duration, OffsetDateTime};
const TABLE: &str = "readings";
const D20: OffsetDateTime = datetime!(2026-07-20 00:00 UTC);
const D21: OffsetDateTime = datetime!(2026-07-21 00:00 UTC);
const D22: OffsetDateTime = datetime!(2026-07-22 00:00 UTC);
struct Harness {
hot: PostgresHot,
}
impl Harness {
async fn start() -> Self {
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let pool = PgPool::connect(&url).await.expect("connect");
let hot = PostgresHot::new(pool);
hot.create_table(TABLE).await.expect("create table");
Self { hot }
}
fn pool(&self) -> &PgPool {
self.hot.pool()
}
async fn insert_readings(&self, start: OffsetDateTime, count: i64) {
let source = MeasurementSource::Mscons {
pid: 13_005,
message_ref: None,
sender_mp_id: "99".to_string(),
};
let source_detail = serde_json::to_string(&source).expect("serialize source");
for i in 0..count {
let from = start + Duration::minutes(15 * i);
sqlx::query(
r#"INSERT INTO readings
(malo_id, melo_id, obis_code, sparte, "from", "to", value, unit,
quality, resolution, source_kind, source_detail, provenance,
version, version_scope, recorded_at, balancing_day)
VALUES ($1,$2,$3,'STROM',$4,$5,$6,'KWH',$7,$8,$9,$10,$11,$12,$13,$14,
CAST(($4 AT TIME ZONE 'Europe/Berlin') AS DATE))"#,
)
.bind("12345678905")
.bind(Some("DE0001234567890123456789012345678"))
.bind(meterstore::canonical_obis("1-0:1.8.0").unwrap())
.bind(from)
.bind(from + Duration::minutes(15))
.bind(rust_decimal::Decimal::new(1_234_567, 6))
.bind(metering::QualityFlag::Measured.as_str())
.bind(Some("PT15M"))
.bind("MSCONS")
.bind(Some(source_detail.as_str()))
.bind(Some("[]"))
.bind(rust_decimal::Decimal::new(20_260_727_000_001, 0))
.bind("99:2026-07")
.bind(datetime!(2026-07-27 06:00 UTC))
.execute(self.pool())
.await
.expect("insert reading");
}
}
async fn row_count(&self) -> i64 {
sqlx::query_scalar::<_, i64>(r#"SELECT count(*) FROM "readings""#)
.fetch_one(self.pool())
.await
.expect("count")
}
async fn relation_exists(&self, name: &str) -> bool {
sqlx::query_scalar::<_, bool>("SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = $1)")
.bind(name)
.fetch_one(self.pool())
.await
.expect("relation lookup")
}
async fn dead_tuples(&self) -> i64 {
sqlx::query_scalar::<_, Option<i64>>(
"SELECT sum(n_dead_tup)::bigint FROM pg_stat_all_tables
WHERE relname LIKE 'readings%'",
)
.fetch_one(self.pool())
.await
.expect("dead tuple stats")
.unwrap_or(0)
}
}
#[tokio::test]
async fn creates_partitions_across_a_range() {
let h = Harness::start().await;
let created = h
.hot
.ensure_partitions(TABLE, D20, D22, Duration::DAY)
.await
.unwrap();
assert_eq!(created.len(), 2);
assert!(h.relation_exists("readings_2026_07_20_0000").await);
assert!(h.relation_exists("readings_2026_07_21_0000").await);
}
#[tokio::test]
async fn ensure_partitions_is_idempotent() {
let h = Harness::start().await;
let first = h
.hot
.ensure_partitions(TABLE, D20, D22, Duration::DAY)
.await
.unwrap();
let second = h
.hot
.ensure_partitions(TABLE, D20, D22, Duration::DAY)
.await
.unwrap();
assert_eq!(first.len(), 2);
assert!(second.is_empty(), "second run must create nothing");
}
#[tokio::test]
async fn partition_bounds_are_aligned_regardless_of_the_requested_start() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, datetime!(2026-07-20 13:47 UTC), D21, Duration::DAY)
.await
.unwrap();
assert!(h.relation_exists("readings_2026_07_20_0000").await);
}
#[tokio::test]
async fn rows_route_to_the_partition_covering_their_interval() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D22, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 4).await;
h.insert_readings(D21, 6).await;
let in_first =
sqlx::query_scalar::<_, i64>(r#"SELECT count(*) FROM "readings_2026_07_20_0000""#)
.fetch_one(h.pool())
.await
.unwrap();
assert_eq!(in_first, 4);
assert_eq!(h.row_count().await, 10);
}
#[tokio::test]
async fn insert_fails_when_no_partition_covers_the_interval() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
let result = sqlx::query(
r#"INSERT INTO readings
(malo_id, obis_code, sparte, "from", "to", value, unit, quality,
source_kind, version, version_scope, recorded_at, balancing_day)
VALUES ('1','1-0:1.8.0','STROM',$1,$2,1.0,'KWH','MEASURED','MSCONS',1,
'99:2026-07',$1,CAST(($1 AT TIME ZONE 'Europe/Berlin') AS DATE))"#,
)
.bind(D22)
.bind(D22 + Duration::minutes(15))
.execute(h.pool())
.await;
assert!(result.is_err(), "insert outside any partition must fail");
}
#[tokio::test]
async fn detach_hides_rows_from_the_parent_but_keeps_them_readable() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 8).await;
assert_eq!(h.row_count().await, 8);
let partition = PartitionId::new(TABLE, D20);
h.hot.detach_partition(&partition).await.unwrap();
assert_eq!(h.row_count().await, 0, "parent must no longer see the rows");
assert!(
h.relation_exists("readings_2026_07_20_0000").await,
"the data must still exist"
);
let batches = collect_stream(
h.hot
.scan_detached(&partition, &ScanSpec::core())
.await
.unwrap(),
)
.await;
let scanned: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(scanned, 8, "archiver must still be able to read it");
}
#[tokio::test]
async fn a_partition_being_archived_is_still_readable_through_the_query_path() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 8).await;
h.hot
.detach_partition(&PartitionId::new(TABLE, D20))
.await
.unwrap();
assert_eq!(h.row_count().await, 0, "the parent no longer holds them");
let scanned: usize = collect_stream(
h.hot
.scan_range(
TABLE,
meterstore::planner::TimeRange::between(D20, D21),
&ScanSpec::core(),
)
.await
.unwrap(),
)
.await
.iter()
.map(|b| b.num_rows())
.sum();
assert_eq!(scanned, 8, "a query must not lose a window mid-archival");
}
#[tokio::test]
async fn a_detached_window_the_scan_does_not_ask_for_is_not_read_twice() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D22, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 8).await;
h.insert_readings(D21, 4).await;
h.hot
.detach_partition(&PartitionId::new(TABLE, D20))
.await
.unwrap();
let scanned: usize = collect_stream(
h.hot
.scan_range(
TABLE,
meterstore::planner::TimeRange::between(D21, D22),
&ScanSpec::core(),
)
.await
.unwrap(),
)
.await
.iter()
.map(|b| b.num_rows())
.sum();
assert_eq!(scanned, 4, "the archived window is Iceberg's now");
}
#[tokio::test]
async fn a_changed_merge_key_is_refused_rather_than_silently_ignored() {
let h = Harness::start().await;
let widened: Vec<String> = ["malo_id", "melo_id", "obis_code", "from"]
.iter()
.map(|s| (*s).to_string())
.collect();
let err = h
.hot
.create_table_with_key(
TABLE,
&widened,
&[],
meterstore::config::TimeModel::Interval,
)
.await
.expect_err("a merge key cannot change under an existing table")
.to_string();
assert!(err.contains("primary key"), "{err}");
assert!(
err.contains("melo_id"),
"the message names the difference: {err}"
);
h.hot.create_table(TABLE).await.expect("idempotent");
}
#[tokio::test]
async fn a_changed_time_model_is_refused_too() {
let h = Harness::start().await;
let key: Vec<String> = ["malo_id", "obis_code", "from"]
.iter()
.map(|s| (*s).to_string())
.collect();
let err = h
.hot
.create_table_with_key(TABLE, &key, &[], meterstore::config::TimeModel::Point)
.await
.expect_err("an interval table cannot become a point table")
.to_string();
assert!(err.contains("INTERVAL") && err.contains("POINT"), "{err}");
}
#[tokio::test]
async fn detached_partition_survives_as_an_orphan() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 4).await;
h.hot
.detach_partition(&PartitionId::new(TABLE, D20))
.await
.unwrap();
let orphans = h.hot.orphaned_partitions(TABLE).await.unwrap();
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].start(), D20);
}
struct HoldingAccessShare {
_pool: PgPool,
tx: Option<sqlx::Transaction<'static, sqlx::Postgres>>,
}
impl HoldingAccessShare {
async fn on(url: &str, table: &str) -> Self {
let pool = PgPool::connect(url).await.expect("connect");
let leaked: &'static PgPool = Box::leak(Box::new(pool.clone()));
let mut tx = leaked.begin().await.expect("begin");
sqlx::query(&format!(r#"SELECT count(*) FROM "{table}""#))
.fetch_one(&mut *tx)
.await
.expect("read the parent, taking ACCESS SHARE");
Self {
_pool: pool,
tx: Some(tx),
}
}
async fn release(mut self) {
if let Some(tx) = self.tx.take() {
tx.rollback().await.expect("rollback");
}
}
}
#[tokio::test]
async fn creating_a_partition_does_not_block_on_a_reader() {
let h = Harness::start().await;
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let pool = PgPool::connect(&url).await.expect("connect");
let hot = PostgresHot::new(pool).ddl_lock_timeout(Duration::seconds(2));
hot.create_table(TABLE).await.expect("create table");
let reader = HoldingAccessShare::on(&url, TABLE).await;
let started = std::time::Instant::now();
let made = hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.expect("a reader must not be able to block partition creation");
let elapsed = started.elapsed();
assert_eq!(made.len(), 1);
assert!(
elapsed < std::time::Duration::from_secs(2),
"creation waited {elapsed:?}, so it queued for a lock it should not need"
);
reader.release().await;
drop(h);
}
#[tokio::test]
async fn a_detach_that_cannot_get_its_lock_reports_a_lock_timeout() {
let url = meterstore::testkit::postgres::fresh_database()
.await
.expect("postgres");
let pool = PgPool::connect(&url).await.expect("connect");
let hot = PostgresHot::new(pool).ddl_lock_timeout(Duration::milliseconds(250));
hot.create_table(TABLE).await.expect("create table");
hot.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
let reader = HoldingAccessShare::on(&url, TABLE).await;
let err = hot
.detach_partition(&PartitionId::new(TABLE, D20))
.await
.expect_err("a reader holds ACCESS SHARE, so the detach cannot proceed");
assert!(
matches!(err, meterstore::Error::LockTimeout { .. }),
"expected a typed lock timeout, got {err:?}"
);
assert!(err.is_retryable());
reader.release().await;
hot.detach_partition(&PartitionId::new(TABLE, D20))
.await
.expect("the lock is free now");
}
#[tokio::test]
async fn a_created_partition_carries_its_bounds_and_no_redundant_check() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
let name = PartitionId::new(TABLE, D20).relation_name().unwrap();
let bound: Option<String> = sqlx::query_scalar(
"SELECT pg_get_expr(c.relpartbound, c.oid)
FROM pg_class c WHERE c.relname = $1",
)
.bind(&name)
.fetch_one(h.pool())
.await
.unwrap();
let bound = bound.expect("the relation is attached as a partition");
assert!(bound.contains("2026-07-20"), "{bound}");
assert!(bound.contains("2026-07-21"), "{bound}");
let checks: Vec<String> = sqlx::query_scalar(
"SELECT conname FROM pg_constraint
WHERE conrelid = $1::regclass AND contype = 'c'",
)
.bind(&name)
.fetch_all(h.pool())
.await
.unwrap();
assert!(
!checks.iter().any(|c| c.ends_with("_bound")),
"the redundant bound check must be dropped after the attach: {checks:?}"
);
let parent_checks: Vec<String> = sqlx::query_scalar(
"SELECT conname FROM pg_constraint
WHERE conrelid = $1::regclass AND contype = 'c'
ORDER BY conname",
)
.bind(TABLE)
.fetch_all(h.pool())
.await
.unwrap();
let mut inherited: Vec<&String> = checks
.iter()
.filter(|c| !c.starts_with(&name))
.collect::<Vec<_>>();
inherited.sort();
assert_eq!(
inherited,
parent_checks.iter().collect::<Vec<_>>(),
"the partition must carry the parent's checks once each, no more and no fewer"
);
assert!(
parent_checks.iter().any(|c| c == "sparte_known"),
"the parent's own vocabulary checks should be among them: {parent_checks:?}"
);
}
#[tokio::test]
async fn attached_partitions_are_not_reported_as_orphans() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D22, Duration::DAY)
.await
.unwrap();
assert!(h.hot.orphaned_partitions(TABLE).await.unwrap().is_empty());
}
#[tokio::test]
async fn scan_returns_the_storage_schema_in_merge_key_order() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 3).await;
let partition = PartitionId::new(TABLE, D20);
h.hot.detach_partition(&partition).await.unwrap();
let batches = collect_stream(
h.hot
.scan_detached(&partition, &ScanSpec::core())
.await
.unwrap(),
)
.await;
let batch = &batches[0];
assert_eq!(
batch.schema(),
meterstore::encode::schema::storage_schema(&[])
);
let decoded = meterstore::encode::from_record_batch(batch).unwrap();
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].series.intervals.len(), 3);
assert_eq!(
decoded[0].series.intervals[0].value,
"1.234567".parse::<rust_decimal::Decimal>().unwrap(),
"decimals must not lose precision through NUMERIC"
);
let from = batch
.column_by_name(col::FROM)
.expect("from column present");
assert_eq!(from.len(), 3);
}
#[tokio::test]
async fn scanning_an_empty_partition_yields_no_batches() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
let partition = PartitionId::new(TABLE, D20);
h.hot.detach_partition(&partition).await.unwrap();
assert!(
collect_stream(
h.hot
.scan_detached(&partition, &ScanSpec::core())
.await
.unwrap()
)
.await
.is_empty()
);
}
#[tokio::test]
async fn purge_creates_no_dead_tuples() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 96).await;
sqlx::query("ANALYZE").execute(h.pool()).await.unwrap();
let before = h.dead_tuples().await;
let partition = PartitionId::new(TABLE, D20);
h.hot.detach_partition(&partition).await.unwrap();
h.hot.drop_partition(&partition).await.unwrap();
sqlx::query("ANALYZE").execute(h.pool()).await.unwrap();
let after = h.dead_tuples().await;
assert!(!h.relation_exists("readings_2026_07_20_0000").await);
assert_eq!(
after, before,
"dropping a partition must not produce dead tuples"
);
assert_eq!(h.row_count().await, 0);
}
#[tokio::test]
async fn drop_is_idempotent() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
let partition = PartitionId::new(TABLE, D20);
h.hot.detach_partition(&partition).await.unwrap();
h.hot.drop_partition(&partition).await.unwrap();
h.hot.drop_partition(&partition).await.unwrap();
}
#[tokio::test]
async fn a_non_canonical_obis_code_is_rejected_at_the_write() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
for bad in ["1-0:1.8.0*255", "not-an-obis", "1-0:1.8"] {
let result = sqlx::query(
r#"INSERT INTO readings
(malo_id, obis_code, sparte, "from", "to", value, unit, quality,
source_kind, version, version_scope, recorded_at, balancing_day)
VALUES ('1',$1,'STROM',$2,$3,1.0,'KWH','MEASURED','MSCONS',1,
'99:2026-07',$2,CAST(($2 AT TIME ZONE 'Europe/Berlin') AS DATE))"#,
)
.bind(bad)
.bind(D20)
.bind(D20 + Duration::minutes(15))
.execute(h.pool())
.await;
assert!(result.is_err(), "{bad:?} must be rejected");
}
}
#[tokio::test]
async fn a_storage_group_that_carries_information_is_accepted() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
sqlx::query(
r#"INSERT INTO readings
(malo_id, obis_code, sparte, "from", "to", value, unit, quality,
source_kind, version, version_scope, recorded_at, balancing_day)
VALUES ('1','1-0:1.8.0*1','STROM',$1,$2,1.0,'KWH','MEASURED','MSCONS',1,
'99:2026-07',$1,CAST(($1 AT TIME ZONE 'Europe/Berlin') AS DATE))"#,
)
.bind(D20)
.bind(D20 + Duration::minutes(15))
.execute(h.pool())
.await
.expect("a meaningful storage group must be storable");
}
#[tokio::test]
async fn quality_is_stored_as_its_stable_code_not_an_integer() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 1).await;
let stored: String = sqlx::query_scalar(r#"SELECT quality FROM readings LIMIT 1"#)
.fetch_one(h.pool())
.await
.unwrap();
assert_eq!(stored, metering::QualityFlag::Measured.as_str());
assert_eq!(
stored.parse::<metering::QualityFlag>().unwrap(),
metering::QualityFlag::Measured
);
}
fn default_key() -> Vec<String> {
meterstore::encode::schema::MERGE_KEY
.iter()
.map(|s| (*s).to_string())
.collect()
}
fn batch(start: OffsetDateTime, count: usize, kwh: i64, version: i64) -> RecordBatch {
use meterstore::arrow::array::{Decimal128Array, StringArray, TimestampMicrosecondArray};
use meterstore::encode::schema;
use std::sync::Arc;
let schema_ref = schema::storage_schema(&[]);
let micros = |t: OffsetDateTime| (t.unix_timestamp_nanos() / 1_000) as i64;
let obis = meterstore::canonical_obis("1-0:1.8.0").unwrap();
let froms: Vec<i64> = (0..count)
.map(|i| micros(start + Duration::minutes(15 * i as i64)))
.collect();
let tos: Vec<i64> = froms.iter().map(|f| f + 15 * 60 * 1_000_000).collect();
let columns: Vec<meterstore::arrow::array::ArrayRef> = vec![
Arc::new(StringArray::from(vec!["11111111115"; count])),
Arc::new(StringArray::from(vec![None::<&str>; count])),
Arc::new(StringArray::from(vec![obis.as_str(); count])),
Arc::new(StringArray::from(vec!["STROM"; count])),
Arc::new(TimestampMicrosecondArray::from(froms).with_timezone("UTC")),
Arc::new(TimestampMicrosecondArray::from(tos).with_timezone("UTC")),
Arc::new(
Decimal128Array::from(vec![i128::from(kwh) * 1_000_000; count])
.with_precision_and_scale(schema::VALUE_PRECISION, schema::VALUE_SCALE)
.unwrap(),
),
Arc::new(StringArray::from(vec!["KWH"; count])),
Arc::new(StringArray::from(vec!["MEASURED"; count])),
Arc::new(StringArray::from(vec!["PT15M"; count])),
Arc::new(StringArray::from(vec!["MSCONS"; count])),
Arc::new(StringArray::from(vec!["{}"; count])),
Arc::new(StringArray::from(vec!["[]"; count])),
Arc::new(
Decimal128Array::from(vec![i128::from(version); count])
.with_precision_and_scale(schema::VERSION_PRECISION, schema::VERSION_SCALE)
.unwrap(),
),
Arc::new(StringArray::from(vec!["99:2026-07"; count])),
Arc::new(TimestampMicrosecondArray::from(vec![micros(D20); count]).with_timezone("UTC")),
Arc::new(meterstore::arrow::array::Date32Array::from(
(0..count)
.map(|i| {
let at = start + Duration::minutes(15 * i as i64);
let day =
meterstore::planner::balancing_day(at, metering::interval::Sparte::Strom);
(day - time::Date::from_ordinal_date(1970, 1).unwrap()).whole_days() as i32
})
.collect::<Vec<i32>>(),
)),
];
RecordBatch::try_new(schema_ref, columns).unwrap()
}
#[tokio::test]
async fn a_redelivered_batch_is_idempotent() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
let b = batch(D20, 4, 10, 20_260_720_000_001);
let first = h
.hot
.append(TABLE, &default_key(), std::slice::from_ref(&b))
.await
.unwrap();
let second = h
.hot
.append(TABLE, &default_key(), std::slice::from_ref(&b))
.await
.expect("a replayed batch must not error");
assert_eq!(first, 4, "first delivery writes every row");
assert_eq!(second, 0, "replay writes nothing new");
assert_eq!(h.row_count().await, 4, "and stores nothing twice");
}
#[tokio::test]
async fn the_same_version_may_not_carry_a_different_value() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.hot
.append(
TABLE,
&default_key(),
&[batch(D20, 2, 10, 20_260_720_000_001)],
)
.await
.unwrap();
let err = h
.hot
.append(
TABLE,
&default_key(),
&[batch(D20, 2, 99, 20_260_720_000_001)],
)
.await
.expect_err("a differing value under the same version must be rejected, not ignored");
assert!(
matches!(&err, meterstore::Error::IntegrityViolation { constraint, .. }
if constraint.as_deref() == Some("version_identifies_one_assertion")),
"expected a typed integrity violation, got {err:?}"
);
assert!(!err.is_retryable());
}
#[tokio::test]
async fn a_range_scan_streams_in_bounded_chunks() {
use futures::StreamExt;
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 25).await;
let hot = PostgresHot::new(h.pool().clone()).scan_chunk_rows(10);
let mut stream = hot
.scan_range(
TABLE,
meterstore::planner::TimeRange::unbounded(),
&ScanSpec::core(),
)
.await
.unwrap();
let mut batches = 0;
let mut rows = 0;
while let Some(batch) = stream.next().await {
let batch = batch.unwrap();
assert!(
batch.num_rows() <= 10,
"a batch must not exceed the chunk size"
);
batches += 1;
rows += batch.num_rows();
}
assert_eq!(rows, 25, "every row is still delivered");
assert_eq!(
batches, 3,
"25 rows in chunks of 10 is three round trips: 10, 10, 5"
);
}
#[tokio::test]
async fn a_streamed_scan_resumes_at_the_right_place() {
use futures::StreamExt;
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 30).await;
let hot = PostgresHot::new(h.pool().clone()).scan_chunk_rows(7);
let mut stream = hot
.scan_range(
TABLE,
meterstore::planner::TimeRange::unbounded(),
&ScanSpec::core(),
)
.await
.unwrap();
let mut seen: Vec<i64> = Vec::new();
while let Some(batch) = stream.next().await {
let batch = batch.unwrap();
let from = batch
.column_by_name(col::FROM)
.unwrap()
.as_any()
.downcast_ref::<meterstore::arrow::array::TimestampMicrosecondArray>()
.unwrap();
for i in 0..from.len() {
seen.push(from.value(i));
}
}
assert_eq!(seen.len(), 30, "no row lost or repeated across pages");
let mut sorted = seen.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 30, "no duplicates across page boundaries");
assert_eq!(seen, sorted, "pages arrive in key order");
}
#[tokio::test]
async fn invariant_violations_counts_rows_below_the_watermark() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D22, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 5).await;
h.insert_readings(D21, 7).await;
let stranded = h
.hot
.invariant_violations(TABLE, TieringWatermark::new(D21))
.await
.unwrap();
assert_eq!(stranded, 5);
let clean = h
.hot
.invariant_violations(TABLE, TieringWatermark::new(D20))
.await
.unwrap();
assert_eq!(clean, 0);
}
#[tokio::test]
async fn corrections_coexist_with_the_values_they_supersede() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 1).await;
sqlx::query(
r#"INSERT INTO readings
(malo_id, obis_code, sparte, "from", "to", value, unit, quality,
source_kind, version, version_scope, recorded_at, balancing_day)
VALUES ('12345678905','1-0:1.8.0','STROM',$1,$2,9.9,'KWH','CORRECTED','MSCONS',
20260728000002,'99:2026-07',$3,
CAST(($1 AT TIME ZONE 'Europe/Berlin') AS DATE))"#,
)
.bind(D20)
.bind(D20 + Duration::minutes(15))
.bind(datetime!(2026-07-28 06:00 UTC))
.execute(h.pool())
.await
.expect("correction must be insertable alongside the original");
assert_eq!(h.row_count().await, 2);
let rows = sqlx::query(r#"SELECT version FROM readings WHERE "from" = $1 ORDER BY version"#)
.bind(D20)
.fetch_all(h.pool())
.await
.unwrap();
assert_eq!(rows.len(), 2);
let latest: rust_decimal::Decimal = rows[1].try_get(0).unwrap();
assert_eq!(latest.to_string(), "20260728000002");
}
#[tokio::test]
async fn a_chunk_boundary_inside_a_tie_does_not_drop_rows() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
let source = MeasurementSource::Mscons {
pid: 13_005,
message_ref: None,
sender_mp_id: "99".to_string(),
};
let detail = serde_json::to_string(&source).unwrap();
for obis in ["1-0:1.8.0", "1-0:2.8.0"] {
for version in [
20_260_701_000_001i64,
20_260_702_000_002,
20_260_703_000_003,
] {
sqlx::query(
r#"INSERT INTO readings
(malo_id, melo_id, obis_code, sparte, "from", "to", value, unit,
quality, resolution, source_kind, source_detail, provenance,
version, version_scope, recorded_at, balancing_day)
VALUES ($1,NULL,$2,'STROM',$3,$4,1,'KWH','MEASURED','PT15M','MSCONS',
$5,'[]',$6,'99:2026-07',$7,
CAST(($3 AT TIME ZONE 'Europe/Berlin') AS DATE))"#,
)
.bind("12345678905")
.bind(meterstore::canonical_obis(obis).unwrap())
.bind(D20)
.bind(D20 + Duration::minutes(15))
.bind(detail.as_str())
.bind(rust_decimal::Decimal::new(version, 0))
.bind(D20)
.execute(h.pool())
.await
.expect("insert");
}
}
let paged = PostgresHot::new(h.pool().clone()).scan_chunk_rows(2);
let batches = collect_stream(
paged
.scan_range(
TABLE,
meterstore::planner::TimeRange::unbounded(),
&ScanSpec::core(),
)
.await
.unwrap(),
)
.await;
let scanned: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(scanned, 6, "every row must survive a chunked scan");
}
#[tokio::test]
async fn a_chunked_scan_returns_rows_in_the_declared_sort_order() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
h.insert_readings(D20, 20).await;
let paged = PostgresHot::new(h.pool().clone()).scan_chunk_rows(3);
let batches = collect_stream(
paged
.scan_range(
TABLE,
meterstore::planner::TimeRange::unbounded(),
&ScanSpec::core(),
)
.await
.unwrap(),
)
.await;
use datafusion::arrow::array::AsArray;
let mut seen: Vec<(String, i64)> = Vec::new();
for batch in &batches {
let malo = batch
.column_by_name("malo_id")
.expect("malo_id")
.as_string::<i32>();
let from = batch
.column_by_name("from")
.expect("from")
.as_primitive::<datafusion::arrow::datatypes::TimestampMicrosecondType>();
for i in 0..batch.num_rows() {
seen.push((malo.value(i).to_string(), from.value(i)));
}
}
assert_eq!(seen.len(), 20);
let mut sorted = seen.clone();
sorted.sort();
assert_eq!(seen, sorted, "chunks must not reorder the stream");
}
#[tokio::test]
async fn overlapping_intervals_in_one_version_are_refused() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
let insert = |from: OffsetDateTime, to: OffsetDateTime, version: i64| {
sqlx::query(
r#"INSERT INTO readings
(malo_id, obis_code, sparte, "from", "to", value, unit, quality,
source_kind, version, version_scope, recorded_at, balancing_day)
VALUES ('12345678905','1-0:1.8.0','STROM',$1,$2,1.0,'KWH','MEASURED',
'MSCONS',$3,'99:2026-07',$1,
CAST(($1 AT TIME ZONE 'Europe/Berlin') AS DATE))"#,
)
.bind(from)
.bind(to)
.bind(rust_decimal::Decimal::new(version, 0))
.execute(h.pool())
};
insert(D20, D20 + Duration::hours(1), 20_260_720_000_001)
.await
.expect("the first delivery is fine");
let clash = insert(
D20 + Duration::minutes(15),
D20 + Duration::minutes(30),
20_260_720_000_001,
)
.await;
let err = clash.expect_err("an overlapping range at the same version must be refused");
let constraint = err
.as_database_error()
.and_then(sqlx::error::DatabaseError::constraint);
assert!(
constraint.is_some_and(|c| c.ends_with("_no_overlap")),
"the exclusion constraint should be the one that refused it, got {constraint:?}"
);
insert(
D20 + Duration::minutes(15),
D20 + Duration::minutes(30),
20_260_728_000_002,
)
.await
.expect("a correction must remain legal");
}
#[tokio::test]
async fn a_malformed_version_scope_is_refused_by_the_table() {
let h = Harness::start().await;
h.hot
.ensure_partitions(TABLE, D20, D21, Duration::DAY)
.await
.unwrap();
let insert = |scope: &'static str| {
sqlx::query(
r#"INSERT INTO readings
(malo_id, obis_code, sparte, "from", "to", value, unit, quality,
source_kind, version, version_scope, recorded_at, balancing_day)
VALUES ('12345678905','1-0:1.8.0','STROM',$1,$2,1.0,'KWH','MEASURED',
'MSCONS',1,$3,$1,CAST(($1 AT TIME ZONE 'Europe/Berlin') AS DATE))"#,
)
.bind(D20)
.bind(D20 + Duration::minutes(15))
.bind(scope)
.execute(h.pool())
};
for bad in ["99", "99:2026", "99:2026-13", "a:b:2026-07", "2026-07"] {
assert!(
insert(bad).await.is_err(),
"{bad:?} is not a canonical version scope and must be refused"
);
}
insert("99:2026-07")
.await
.expect("the canonical form must be storable");
}