Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
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.
π Documentation Β· API reference Β· Changelog
Pre-alpha, and unpublished on purpose. Storage, tiering, archival, querying, reproducible reads and completeness work end to end against real PostgreSQL 18 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, neverDELETEβ 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.
π Quick start
Without writing a program:
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, and meterstore reassert-watermark is the one step
to run after compacting the table with somebody else's tool.
The CLI β
As a library:
use *;
use PostgresHot;
use Arc;
use Duration;
let hot = new; // a pool you already own
let cold = IcebergSqlCatalog .build.await?.cold;
let store = builder
.hot
.cold
.table
.build
.await?;
// Setup, archival, maintenance and teardown live behind `admin()`, so the
// surface you read and write through stays the size of that job.
store.admin.create_tables.await?;
Then write and read:
// Routes each interval to the tier that owns it.
store.append.await?;
// One statement, both tiers β with the boundary it was computed against.
let result = store.query.await?;
result.watermark; // where cold ended and hot began
result.touched_hot_tier; // whether the answer is only valid for now
That is the quick start. Everything a deployment declares beyond it β the two record shapes, identity versus attribute columns, checked identifiers, scoping, completeness, erasure, settlement reruns β is on the documentation site, indexed below rather than repeated here.
π Requirements
| Version | Why | |
|---|---|---|
| Rust | 1.94 | Set by the dependency floor (metering, iceberg) |
| PostgreSQL | 15 or later | A support floor, not a syntax one β see below. The suite runs on 18 and whole again on 15 |
metering |
0.24 or later | The domain layer β MeterStore stores its types, it does not redefine them |
| Apache Iceberg | format v2 | Deliberately not v3 |
Nothing here needs a server newer than PostgreSQL 12 β partition creation runs on
the write path, and building a partition standalone and attaching it takes only
SHARE UPDATE EXCLUSIVE from 12, where CREATE TABLE β¦ PARTITION OF would take
ACCESS EXCLUSIVE on the parent and let one long query stall every subsequent
insert.
Locks β
The floor is 15 anyway: it is a promise about where this crate may be deployed, and a promise has to name versions somebody still patches. β CI runs the whole integration suite against 15 as well as against 18, so the floor is checked rather than claimed.
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 (sql-catalog, on by default), a REST catalogue (rest-catalog, on
by default), and AWS S3 Tables behind the s3tables feature. A configuration file
builds whichever it names β all three, by catalog = "sql" | "rest" | "s3tables" β
and Settings::connect() returns the pool, both tiers and every validated table.
Details.
MeterStore needs only SELECT plus ownership of its own tables: no server
configuration, no restart, and no extension beyond btree_gist, which ships in
contrib and is created on demand. 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 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 | Requirements, install, a store over both tiers |
| βοΈ Architecture | The watermark, the invariant, crash-safe archival |
| ποΈ Storage model | Columns, the merge key, identity vs attribute, constraints |
| βοΈ Writing readings | Routed writes, bulk ingest, idempotent redelivery |
| π Querying | SQL across tiers, provenance, the typed series API |
| β±οΈ Reproducibility | Settlement reruns on two independent time axes |
| π§© Completeness | DST-aware gap detection, including the channel that delivered nothing |
| π§ Operations | Scheduling, locks, system tables, metrics, failure matrix |
| π» The CLI | meterstore β check, create, status, archive, maintain, query, serve, and the rest |
| π External engines | Spark, Trino, DuckDB β and the trap to avoid |
| π‘οΈ Privacy and retention | Pseudonymisation, and the three-year duty as a scheduled job |
| π 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.
1001 tests β unit, property, doc and integration against real PostgreSQL 18 and a real Iceberg warehouse, plus an independently implemented correctness oracle over generated workloads:
| β Checked, not asserted | |
|---|---|
| Concurrency | Ingest, archival and reads run against one table at once β the only way to reach the states between two steps rather than inside one β and two replicas over separate pools race to archive it |
| Foreign readers | DuckDB and PyIceberg read the output and agree with it, down to the audit trail's timestamps |
| Foreign clients | An ADBC driver drives Flight SQL from the API a non-Rust consumer actually holds |
| Locks | Asserted against a real server holding a real conflicting lock |
| The version floor | The whole integration suite runs again on PostgreSQL 15 |
| Compression | Measured rather than targeted: ~109Γ (457 B/row against 4.2 B/row; the measurement suite carries the caveats) |
β Argued rather than measured, and named here for that reason: query latency
on reference hardware, so the p99 targets remain aspirational; Spark and Trino
interop; a long-horizon soak; and two replicas as two processes rather than two
pools, so a crash mid-lease is reasoned about rather than run. Compaction and
general orphan-file cleanup
run out of band,
because iceberg-rust exposes neither.
β οΈ One caveat worth knowing before you deploy. A query holds the archived partitions its plan is entitled to for up to
max_pin_ageβ six hours by default β and one that runs longer loses its window, with an error naming it and never a short answer. Raise the setting past the longest query you run.
π οΈ Development
Requires a Rust toolchain and, for integration tests, a running Docker daemon.
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,
sqlx and sqlx-postgres 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 or Apache-2.0, at your option. Part of the mako platform.