meterstore 0.12.0

Hot/cold tiered store for metering time series — PostgreSQL for the recent window, Apache Iceberg for history.
# MeterStore — deployment configuration.
#
# `meterstore check` validates this file without connecting to anything, and it
# runs the same cross-field checks the Rust builder does. There is no setting
# reachable from one and not the other.
#
# A `${DATABASE_URL}`-style placeholder in any value is interpolated from the
# environment. A missing variable is an error, not an empty string: a connection
# URL that silently became `postgresql://@/` would fail somewhere far from the
# typo. Comments are left alone, which is why this one can say so.

# --- The hot tier: PostgreSQL holds the recent interval window. ---
[hot]
url = "${DATABASE_URL}"
max_connections = 16

# How long a DDL statement waits for a lock before giving up.
#
# PostgreSQL grants locks in arrival order, so a statement waiting for an
# ACCESS EXCLUSIVE lock blocks every reader and writer that arrives behind it.
# Archival detaches a partition on its own schedule, and a detach that queued
# would take ingest down for as long as whatever it waits on runs. With this it
# gives up having changed nothing, and the cycle is reported as deferred.
#
# Raise it on a deployment that reports out of the same tables it writes; every
# second added is a second the whole table can stall for. `0s` disables it,
# which is PostgreSQL's own default and this crate's advice against.
ddl_lock_timeout = "3s"

# --- The cold tier: Apache Iceberg holds the history. ---
[cold]
# "rest" for Polaris, Lakekeeper, Nessie or Gravitino — what every engine
# already speaks, so an external reader needs nothing extra. "sql" for a
# PostgreSQL-backed catalogue on the same database as the hot tier, which is one
# fewer service to run and needs the JDBC catalogue implementation on the far
# side. "s3tables" for an AWS S3 Tables bucket, where `warehouse` below is the
# table bucket ARN and `uri` is unused.
catalog = "sql"
uri = "${DATABASE_URL}"

# file:// and memory:// are always available. s3://, gs:// and abfss:// need the
# matching `object-store-*` feature, and a scheme whose feature was not compiled
# in is a configuration error rather than a surprise on the first commit.
warehouse = "file:///var/lib/meterstore/warehouse"
namespace = "metering"

# Target size for a data file. Larger files mean less manifest to plan against
# and better compression; smaller ones mean finer pruning.
file_target_bytes = 536870912
metadata_pool_max_connections = 4

# --- The subject registry, if any table declares a `subject_column`. ---
#
# Only needed then. A `subject_column` names the column holding pseudonymous
# references to data subjects, and a deployment declaring one without a registry
# is refused at startup: the references would resolve to nothing and an Article
# 17 erasure would have no mapping to destroy.
#
# `erasure_secret` (at least 32 bytes) turns on the suppression list. Without it
# erasure works and does not *stay* worked — once the mapping is gone, nothing
# distinguishes an erased identifier from one never seen, so a pipeline replaying
# old messages registers a fresh reference and silently re-links the subject.
# That is every deployment fed by a message broker.
#
# The key must outlive every erasure and is not recoverable from the database.
#
# [privacy]
# erasure_secret = "${METERSTORE_ERASURE_SECRET}"

# --- One entry per managed table. ---
[[tables]]
# The physical table holds *every version* of every reading — the audit trail.
# The `_versions` suffix is load-bearing: queries read `readings`, a view that
# keeps only the value currently in force, and summing the physical table
# double-counts every corrected interval.
name = "readings_versions"

# "interval" for a Lastgang — energy over [from, to). "point" for a
# Zählerstandsgang — a cumulative register value at an instant.
#
# Never the same table: `value` means two different things, and summing the two
# together gives a number with no meaning that looks exactly like a consumption
# total.
time_model = "interval"

[tables.hot]
# How far ahead partitions are pre-created. Running out makes inserts fail
# outright, so `partitions_ahead` in `meterstore status` is the number that
# predicts a failure rather than describing one.
partition_headroom = "7d"

[tables.archival]
# How far behind wall clock archival stays, so a late correction still finds its
# interval in the hot tier where it can be superseded cheaply. It must cover at
# least one `archival_step`, or a window can close while corrections for it are
# still arriving — and they would land below the watermark, where no query looks.
settlement_lag = "7d"

# One window per commit — and the hot table's partition granularity, which is
# the same number. The purge of an archived window is DROP TABLE, not DELETE,
# and that only holds when a window is exactly one partition.
#
# It cannot be changed once a table has archived: the watermark sits on the old
# grid, and a window off that grid names a partition relation nothing creates.
archival_step = "1d"

# How long an archived partition is kept before its space is reclaimed.
#
# A query reads the tier boundary when it is *planned* and reads the tiers when
# it *executes*. Archival between the two moves a window across the boundary the
# plan already decided against, so the partition stays readable for this long
# afterwards. Set it above the longest query this deployment runs; the cost is a
# fraction of one partition's disk.
reader_grace = "1h"

[tables.maintenance]
# A snapshot is what makes a past settlement reproducible, so this is a
# compliance decision rather than a disk-space one — which is why expiry is
# opt-in (`meterstore maintain --expire-snapshots`) rather than automatic.
snapshot_retention = "10y"

# --- Columns beyond the core schema. ---
#
# `identity = true` puts the column in the merge key, so two rows differing in
# it are different readings. A tenant discriminator declared as an attribute
# instead would let one tenant's correction supersede another's reading.
#
# [[tables.extra_columns]]
# name = "tenant"
# type = "string"
# identity = true
#
# `values` declares a closed vocabulary and renders a `CHECK ... IN (...)`;
# `check` declares an identifier scheme, and the write path parses every value
# with it and stores that scheme's canonical spelling. The two are mutually
# exclusive.
#
#   "EIC"   16 characters, ENTSO-E — a Bilanzkreis, a Bilanzierungsgebiet.
#           The check character is enforced, so a transposition is caught while
#           the delivery that carried it is still in hand.
#   "EIC:X" the same, and a party code — which is what a Bilanzkreis is.
#           "EIC:Y" is an area (a Bilanzierungsgebiet); the rest of ENTSO-E's
#           list is Z W T V A. The two share everything but position 3, so
#           without this a Bilanzierungsgebiet stores as a Bilanzkreis and every
#           MaBiS grouping over it is wrong. Unlike the check character, this
#           half is a regular expression, so the database enforces it as well.
#   "MALO"  11 digits — a Marktlokations-ID, check digit enforced.
#   "MELO"  33 characters — a Zählpunktbezeichnung. No check digit exists;
#           what this adds is the casing, so one Messlokation is one key.
#   "BDEW"  13 digits — a Marktpartner-ID. The 13th digit is deliberately NOT
#           checked: BDEW's Bildungsvorschrift exempts GS1-issued GLNs.
#
# [[tables.extra_columns]]
# name = "bilanzkreis"
# type = "string"
# identity = false
# check = "EIC:X"
#
# [[tables.extra_columns]]
# name = "lieferant"
# type = "string"
# identity = false
# check = "BDEW"