meterstore 0.10.0

Hot/cold tiered store for metering time series β€” PostgreSQL for the recent window, Apache Iceberg for history.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
# MeterStore

**Hot/cold tiered storage for metering time series.** PostgreSQL holds the recent
interval window at low latency; Apache Iceberg holds the history at analytical
scale. A single explicit timestamp separates them, and one SQL statement spans
both.

[![CI](https://github.com/hupe1980/meterstore/actions/workflows/ci.yml/badge.svg)](https://github.com/hupe1980/meterstore/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](#license)

πŸ“– **[Documentation](https://hupe1980.github.io/meterstore)** Β· [API reference](https://docs.rs/meterstore) Β· [Changelog](CHANGELOG.md)

> **Pre-alpha, and unpublished on purpose.** Storage, tiering, archival,
> querying, reproducible reads and completeness work end to end against real
> PostgreSQL 16 and a real Iceberg warehouse. The API is still settling;
> integrating against a real workload is what settles it.

---

## The problem

An intelligent measuring system produces one value per measuring point, per OBIS
code, per interval. Fifteen minutes is the German settlement grain:

| Scale | Rows/day | Rows/year |
|-------|----------|-----------|
| 10 k measuring points | ~1 M | ~350 M |
| 100 k (mid-size utility) | ~9.6 M | ~3.5 B |
| 1 M (metering operator) | ~96 M | ~35 B |

Retention is regulatory β€” years to decades for the settlement record. PostgreSQL
handles the first row comfortably, the second with care, and the third not at all
without becoming a full-time job.

But the operational workload genuinely needs Postgres: recent data is written
continuously, corrected, and read transactionally by billing and market
communication. Meanwhile settlement, forecasting and grid analysis scan years
across hundreds of thousands of meters β€” an object-storage-and-columnar-format
problem.

**The data has a natural split most systems refuse to exploit:** recent intervals
are hot and still being corrected; historical intervals are cold and settled. The
boundary between them is a timestamp.

## How it works

```
  MeterInterval.from ─────────────────────────────────────▢

  │◀──── Iceberg (cold, settled) ────▢│
                                      │◀── Postgres (hot) ──▢│
  epoch                       tiering_watermark            now
```

A row's interval start alone decides its tier, so the tiers are disjoint by
construction β€” no deduplication, no merge, no double-counting. Four decisions
carry most of the weight:

- **The watermark lives inside the Iceberg snapshot.** Archival writes the tier
  boundary into the snapshot summary in the same commit as the data. Iceberg
  commits are a compare-and-swap, so rows and boundary become durable together or
  not at all.
- **Purge is `DROP TABLE`, never `DELETE` β€” and deferred.** An archived window
  is exactly one partition: detached when it is read, dropped a cycle later once
  no query planned against the old boundary can still need it. Deleting a day of
  readings for 100 k meters row by row would leave ~9.6 M dead tuples for
  autovacuum.
- **Corrections are versions, not overwrites.** MSCONS corrects a value by
  *versioning* it, so the store needs only Iceberg's `append` β€” and a past
  settlement stays reproducible.
- **Nothing on the archival path holds a window.** Peak memory is the chunk size,
  not the ~9.6 M-row window.

[How tiering works β†’]https://hupe1980.github.io/meterstore/docs/architecture/

## Quick start

Without writing a program:

```bash
cargo install meterstore --features cli

meterstore init      # a commented starter configuration
meterstore check     # full validation β€” no database needed
meterstore create    # both tiers, every declared table
meterstore status    # boundary, lag, write runway, health
```

`meterstore query` runs SQL across both tiers and prints the boundary the answer
was computed against; `meterstore completeness --month 2026-06` reports which
channels are short before a settlement run trusts a `SUM`; `meterstore erasures`
prints the audit trail a regulator asks for; `meterstore maintain` is the archival
loop as a foreground process.
[The CLI β†’](https://hupe1980.github.io/meterstore/docs/cli/)

As a library:

```bash
cargo add meterstore
```

```rust
use meterstore::prelude::*;
use meterstore::hot::PostgresHot;
use std::sync::Arc;
use time::Duration;

let hot = Arc::new(PostgresHot::new(pool));   // a pool you already own

let cold = IcebergSqlCatalog {
    database_url: &db_url,
    warehouse_uri: "s3://bucket/warehouse",   // or file:// memory:// gs:// abfss://
    catalog_name: "meterstore",
    namespace: "metering",
    file_target_bytes: 512 * 1024 * 1024,
    metadata_pool_max_connections: 4,
    auth: &WarehouseAuth { region: Some("eu-central-1".into()), ..Default::default() },
}.build().await?.cold();

let store = MeterStore::builder()
    .hot(hot)
    .cold(cold.clone(), cold.table_provider("readings_versions").await?)
    .table(
        TableConfig::new("readings_versions")
            .settlement_lag(Duration::days(7))
            .archival_step(Duration::DAY)
            .build()?,
    )
    .build()
    .await?;

store.create_tables().await?;
```

Then write and read:

```rust
// Routes each interval to the tier that owns it.
store.append(&[stored_series]).await?;

// One statement, both tiers β€” with the boundary it was computed against.
let result = store.query(r#"
    SELECT meter_local_day("from") AS day, SUM(value) AS kwh
    FROM readings
    WHERE malo_id = '41373559241'
      AND "from" >= '2025-01-01' AND "from" < '2026-01-01'
    GROUP BY 1 ORDER BY 1
"#).await?;

result.watermark();        // where cold ended and hot began
result.touched_hot_tier(); // whether the answer is only valid for now
```

A table declares whether it holds **spans or instants**. A *Lastgang* is energy
over `[from, to)`; a *ZΓ€hlerstandsgang* is a cumulative register value at an
instant, which BK6-24-174 (in force 06.06.2025) has made a primary record exactly
as voluminous as the Lastgang differenced out of it β€” and Β§ 146 Abs. 4 AO means
it cannot be discarded afterwards. Both tier the same way, because everything
that does the tiering reads the start timestamp:

```rust
TableConfig::new("meter_reads_versions").time_model(TimeModel::Point)
store.append_readings(&[zaehlerstandsgang]).await?;   // metering::MeterReading
store.readings(malo)?.melo(melo)?.latest().await?;   // what the meter reads now
```

They are never the same table: `value` is interval energy on one and a register
reading on the other, and summing the two together gives a number with no meaning
that looks exactly like a consumption total.

The shape also decides what **names** a reading. A Marktlokation may be measured
by several Messlokationen, and both meters carry `1-0:1.8.0` at the same instants.
A load profile belongs to the market location, so `melo_id` labels it; a register
belongs to the *meter*, so it joins the merge key β€” by default on a point table,
`identify_by_melo` either way. Keyed wrongly, two meters that agree on a number
store as one reading.

A deployment's own columns are declared the same way, and an identifier among
them is **parsed rather than trusted** β€” the argument that makes `malo_id` a
`MaloId` rather than eleven digits. A Bilanzkreis is an ENTSO-E EIC with a check
character; a Lieferant is a thirteen-digit Marktpartner-ID:

```rust
TableConfig::new("readings_versions")
    .identity_column(Field::new("tenant", DataType::Utf8, false))            // joins the merge key
    .attribute_column(checked_column("bilanzkreis", ValueCheck::Eic(Some(EicType::Party)), true))
    .attribute_column(checked_column("lieferant", ValueCheck::Bdew, true))   // thirteen digits, no more
```

Values are parsed and stored canonicalised, so one Bilanzkreis in two spellings
cannot become two readings that never supersede each other.

Each scheme stops somewhere different and the documentation says where: the EIC
check character and the MaLo check digit are enforced, a MeLo has no check digit
and gains the casing instead, and a Marktpartner-ID's thirteenth digit is
deliberately **not** checked β€” BDEW's Bildungsvorschrift exempts GS1-issued GLNs,
which is why `version_scope` does not check its operator's digit either.

A `MeasurementSeries` holds one `obis_code`, so a typed read describes **one
channel** β€” folding import and export together sums to twice the truth. A
measuring point is a set of them, so there is a read for that too, in one scan:

```rust
store.series(malo)?.obis("1-0:1.8.0")?.range(from, to).collect().await?;   // one channel
store.series(malo)?.range(from, to).collect_by_channel().await?;          // all of them
```

A service exposing caller-supplied SQL confines it by **injection into the plan**,
so no statement can omit, alias or `UNION` past the boundary β€” including across a
join, where both sides carry their own:

```rust
store.scoped("tenant", t).await?;      // rows, one table
catalog.isolated("readings").await?;   // relations
catalog.scoped("tenant", t).await?;    // rows, every table β€” and the join still plans
```

The last is all-or-nothing: a column missing from some table's merge key is
refused, naming that table, before any table is confined. A scope that covered
three tables and skipped the fourth is not a boundary.

`readings` is version-resolved; `readings_versions` is the raw audit trail. The
naming is load-bearing β€” see
[the version-resolution trap](https://hupe1980.github.io/meterstore/docs/interop/#the-version-resolution-trap)
before pointing an external engine at the warehouse.

A `SUM` over an incomplete month returns a smaller number and no reason, so
**completeness is a query**. The expected count is the DST-aware calendar's β€” 92
on the spring day, 100 on the autumn one, and for gas both on the Gastag rather
than the Sunday. It covers **every balancing day of the range**, so a channel
that stopped mid-month is short by every day after it stopped.

The strongest finding is the one a range cannot make about itself: a channel that
delivered *nothing* produces no rows to aggregate at all, so it needs a roster
from an earlier window.

```rust
store.completeness(from, to).await?;                              // gaps within
store.completeness(from, to).seen_since(from - month).await?;     // and what went silent
```

```bash
# The settlement period, named the way the market names one. --sparte GAS cuts
# the same span at 06:00 local, where the gas Bilanzierungsmonat begins.
meterstore completeness --month 2026-06 --seen-since 30d --gaps-only
```

Personal data comes with a clock rather than a request. Β§ 60 Abs. 6 MsbG says
erase or anonymise *at the latest* three years after the end of the year a value
was collected in β€” **per value**, so the unit of erasure is a `(subject,
collection year)` pair rather than a subject. A reference is minted for one year
and the sweep expires years independently, which is the only way an active
customer's 2021 readings can stop being attributable while their 2026 readings
stay linked:

```rust
let subject = store.register_subject("customer-4821", interval.from).await?;

catalog.maintenance()
    .anonymise_after(Retention::CalendarYears(3), "Β§ 60 Abs. 6 MsbG", "retention-job")
    .spawn();
```

The year is in the reference, so a reference used on another year's readings is
refused at the write β€” otherwise nothing downstream could tell, and the only
symptom would be a sweep that never came due. The sweep itself reads no readings
at all: it is one indexed `DELETE` against the registry.

A table declaring a `subject_column` needs the registry it resolves against, and
a configuration file supplies it β€” one for the whole deployment, because the
mapping is:

```toml
[privacy]
erasure_secret = "${METERSTORE_ERASURE_SECRET}"   # β‰₯ 32 bytes; turns on suppression
```

`meterstore erasures` reads the audit trail from a shell, because *"we deleted
it"* is not evidence. There is deliberately no `meterstore erase`: an Article 17
request usually reaches an application's own tables too, and those must succeed
or fail in **one transaction** with the mapping β€” which `erase_in` gives and a
CLI invocation cannot.

`meter_local_day` is not a convenience, and for **gas it is the wrong function**.
`Europe/Berlin` observes daylight saving, so the UTC day boundary sits at 01:00
or 02:00 local and grouping on UTC days is wrong every day of the year β€” but the
German gas market does not balance on the calendar day either. A *Gastag* runs
06:00 to 06:00 local, so a gas Lastgang grouped by the calendar day books six
hours a day into the neighbouring Bilanzierungstag, with totals that still look
plausible. `meter_balancing_day("from", sparte)` reads the commodity per row and
picks the right one:

```sql
SELECT sparte, meter_balancing_day("from", sparte) AS day, SUM(value)
FROM readings GROUP BY 1, 2;
```

The DST anomaly moves with the boundary: the clocks change *before* 06:00, so the
25-hour gas day is the one named after the **Saturday** while the 25-hour
calendar day is the Sunday.

And the boundary carries up to the **month**. The gas Bilanzierungsmonat runs
01.06 06:00 to 01.07 06:00 (EDI@Energy *Allgemeine Festlegungen* v6.1c, Kap. 3.1),
so an MSCONS version scope for a gas row is cut at 06:00 as well β€” which is why
every `VersionScope` constructor takes a `Sparte`:

```rust
// The network operator's Marktpartner-ID, parsed β€” because a wrong-but-plausible
// one is not an error, it is a different scope that nothing else shares.
VersionScope::for_interval("9900000000001", interval.from, Sparte::Gas)?
```

The month has its own function for the same reason the day does:
`meter_local_month` is the *calendar* month for every row, so grouping a gas
Lastgang by it books six hours into the neighbouring Bilanzierungsmonat twelve
times a year. `meter_balancing_month("from", sparte)` reads the commodity.

**An external engine does not get that function β€” so it gets the answer instead.**
SQL dialects differ on timestamp arithmetic, so no single published expression is
right everywhere. The encoder applies the calendar once, at write time, and stores
the answer:

```sql
-- Every engine. No zone conversion, no DST reasoning, no dialect.
SELECT balancing_day, SUM(value) FROM readings GROUP BY 1;

-- And the settlement month, because a Bilanzierungsmonat is a whole number of
-- balancing days β€” so this is a DATE operation rather than a calendar one.
SELECT date_trunc('month', balancing_day), SUM(value) FROM readings GROUP BY 1;
```

Direction is three-valued, not two booleans. **Both** `obis_is_import` and
`obis_is_export` are false for a register that has no direction at all β€”
Blindarbeit, a gas volume, a Zustandszahl β€” so `NOT obis_is_import(...)` sweeps
those in with the feed-in. `obis_direction(obis_code)` returns `'IMPORT'`,
`'EXPORT'` or null.

A Bilanzkreis and a Bilanzierungsgebiet share the alphabet, the length and the
check character; only **position 3** β€” the ENTSO-E object type β€” tells them apart.
`ValueCheck::Eic(Some(EicType::Party))` (`check = "EIC:X"` in TOML) pins it, and
unlike the check character that half is a regular expression, so PostgreSQL
enforces it on rows this crate did not write.

For a column that does not declare one, it is a query β€” and `eic_normalise` is
what tells *"not an EIC"* apart from *"an EIC whose type letter this build does
not list"*, which is the row a stricter parser downstream will reject:

```sql
SELECT DISTINCT bilanzkreis FROM readings
WHERE eic_normalise(bilanzkreis) IS NOT NULL
  AND eic_object_type(bilanzkreis) IS NULL;
```

[Getting started β†’](https://hupe1980.github.io/meterstore/docs/getting-started/)

## Requirements

| | Version | Why |
|---|---|---|
| Rust | 1.94 | Set by the dependency floor (`metering`, `iceberg`) |
| PostgreSQL | **12 or later** | `ATTACH PARTITION` takes only `SHARE UPDATE EXCLUSIVE` on the parent from 12 β€” see below |
| `metering` | **0.23 or later** | The domain layer β€” MeterStore stores its types, it does not redefine them |
| Apache Iceberg | format v2 | [Deliberately not v3]https://hupe1980.github.io/meterstore/docs/architecture/#format-version |

Partition creation runs on the write path, and `CREATE TABLE … PARTITION OF`
takes `ACCESS EXCLUSIVE` on the parent β€” which, since PostgreSQL grants locks in
arrival order, lets one long query stall every subsequent insert. Partitions are
built standalone and *attached* instead.
[Locks β†’](https://hupe1980.github.io/meterstore/docs/operations/#locks-and-why-ddl-gives-up)

The cold tier takes **any** `Arc<dyn Catalog>` β€” SQL, REST, Polaris, Lakekeeper,
Glue β€” and that seam is driven end to end by the test suite rather than asserted.
Three are built for you: a PostgreSQL-backed SQL catalogue on the same database as
the hot tier, a REST catalogue (`rest-catalog`, on by default), and AWS S3 Tables
behind the `s3tables` feature. A configuration file builds whichever it names β€”
`Settings::connect()` returns the pool, both tiers and every validated table.
[Details](https://hupe1980.github.io/meterstore/docs/getting-started/).

MeterStore needs only `SELECT` plus ownership of its own tables: no server
configuration, no restart, no extension. That is what makes it deployable on RDS,
Cloud SQL and Azure Postgres, where an extension-based approach is not.

## Relationship to `metering`

```
metering    β†’ what a measurement is, and how to compute with it   (zero I/O, no async)
meterstore  β†’ where it lives, how it is tiered, how it is queried  (all I/O)
```

[`metering`](https://crates.io/crates/metering) owns intervals, units, quality
flags, DST-correct calendars, the identifiers (`MaloId`, `MeloId`, `BdewCode`, `Eic`),
validation, Ersatzwertbildung, gas conversion and aggregation. MeterStore adds
exactly three things: **correction versioning**, the **transaction-time axis**,
and the **tiering boundary**.

That boundary is deliberate. Duplicating a domain rule here β€” a unit conversion, a
DST calendar β€” would create a second implementation to keep correct, and it would
drift.

## Documentation

| | |
|---|---|
| [Getting started]https://hupe1980.github.io/meterstore/docs/getting-started/ | Requirements, install, a store over both tiers |
| [Architecture]https://hupe1980.github.io/meterstore/docs/architecture/ | The watermark, the invariant, crash-safe archival |
| [Storage model]https://hupe1980.github.io/meterstore/docs/storage-model/ | Columns, the merge key, identity vs attribute, constraints |
| [Writing readings]https://hupe1980.github.io/meterstore/docs/writing/ | Routed writes, bulk ingest, idempotent redelivery |
| [Querying]https://hupe1980.github.io/meterstore/docs/querying/ | SQL across tiers, provenance, the typed series API |
| [Reproducibility]https://hupe1980.github.io/meterstore/docs/reproducibility/ | Settlement reruns on two independent time axes |
| [Completeness]https://hupe1980.github.io/meterstore/docs/completeness/ | DST-aware gap detection, including the channel that delivered nothing |
| [Operations]https://hupe1980.github.io/meterstore/docs/operations/ | Scheduling, locks, system tables, metrics, failure matrix |
| [The CLI]https://hupe1980.github.io/meterstore/docs/cli/ | `meterstore` β€” check, create, status, archive, maintain, query, serve |
| [External engines]https://hupe1980.github.io/meterstore/docs/interop/ | Spark, Trino, DuckDB β€” and the trap to avoid |
| [Privacy and retention]https://hupe1980.github.io/meterstore/docs/privacy/ | Pseudonymisation, and the three-year duty as a scheduled job |
| [Configuration]https://hupe1980.github.io/meterstore/docs/configuration/ | TOML over the same validated types |

## Status

Everything the documentation describes works end to end against real
infrastructure β€” both tiers, streaming archival, tier-split queries, reproducible
reads, completeness, multi-table sessions and both serving surfaces.

**910 tests**: unit, property, doc and integration against real PostgreSQL 16 and
a real Iceberg warehouse, plus an independently implemented correctness oracle over
generated workloads, covering both record shapes. Ingest, archival and reads also
run **against one table at once**, which is the only way to reach the states that
exist between two steps rather than inside one. **DuckDB** and **PyIceberg** read
the output and agree with it, down to the audit trail's timestamps. The lock
behaviour is asserted against a real server holding a real conflicting lock, not
argued. Compression against PostgreSQL row storage is
**measured** rather than targeted β€” ~109Γ— (457 B/row against 4.2 B/row; the
measurement suite carries the caveats).

Missing: query-latency benchmarks on reference hardware, so the p99 targets remain
aspirational; Spark and Trino interop; a soak across two replicas, which is the
shape the archive lease exists for. Compaction and general orphan-file cleanup
[run out of band](https://hupe1980.github.io/meterstore/docs/operations/#compaction),
because `iceberg-rust` exposes neither.

## Development

Requires a Rust toolchain and, for integration tests, a running Docker daemon.

```bash
just            # list all recipes
just dev        # format + unit tests (no Docker)
just test       # full suite
just check      # everything CI runs
just cli status # run the command-line tool from source
just site       # serve the documentation site
```

The integration suites are **one test binary against one PostgreSQL container**.
Cargo would otherwise compile each file under `tests/` into its own statically
linked executable β€” twenty-odd full copies of DataFusion, Arrow, Iceberg, sqlx and
tonic, about 9 GB, which exhausts a CI runner's disk and fails as a linker bus
error rather than as "no space left". A container per test cost seven times the
wall clock of a database per test, for the same isolation.

`datafusion`, `arrow`, `iceberg`, `parquet`, `metering`, `time`, `rust_decimal`
and `sqlx` must each appear exactly once in the dependency graph; `just deps`
fails the build otherwise. Two versions of `arrow` mean two incompatible
`RecordBatch` types, and two of `sqlx` mean two incompatible `PgPool` types β€”
neither fails obviously.

## License

Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your
option. Part of the [mako](https://github.com/hupe1980/mako) platform.