Schema Evolution#
HydroModPy V1 ships an Alembic-like migration runner that applies
versioned SQL migrations on every DuckDB the toolbox owns, and pins a
single integer schema version on Zarr and Parquet stores. The runner
records each application in a schema_migrations ledger and keeps one
row per component (catalog, cache, index).
For the storage layout that this policy applies to, see Storage Layout.
Scope#
Covered:
DuckDB databases: project
.hmp/index.duckdb, workspacedata/cache.duckdb, machineindex.duckdb.Zarr stores:
runs/<name>/fields.zarr/, written in Zarr format 3 and carrying HydroModPy’s ownzarr_schema_versionin the root ACDD attributes.Parquet 2.6 outputs with
hmp.schema_versionin KV metadata (PARQUET_SCHEMA_VERSION).Portable
.hmppackages produced byCatalog.export_package.
Out of scope: user TOML files. Their versioning is handled by Pydantic
v2 with ConfigDict(extra="forbid").
The project index is the exception to the chain-of-migrations model. It
is an index over the run directories, so its schema evolves by replacing
the initial DDL and rebuilding from disk with hmp catalog reindex,
which ships and is the supported path. The runner still deploys the DDL
on a fresh index; it is simply not the way that scope is meant to move
forward. The doctrine and the rebuild invariants are stated in
Storage Layout.
Migration runner#
Source: hydromodpy/core/migrations/runner.py plus per-component
migration directories:
hydromodpy/results/catalog/migrations/for the project index;hydromodpy/data/registry/migrations/for the workspace cache;hydromodpy/core/state/migrations/for the global index.
Each migration is a numbered SQL file (0001_initial.sql,
0002_add_<slug>.sql, …) and applies cleanly in version order. The
runner:
ensures the
schema_migrationsledger exists with columnsversion INTEGER,component TEXT,slug TEXT,checksum TEXT,applied_at TIMESTAMP, alongside a_schema_versionrow per component;reads the max applied version for the requested component;
applies every newer migration inside one transaction per file;
records the migration with a SHA-256 checksum of the SQL payload, and refuses to proceed when a recorded checksum no longer matches the file on disk.
Calling ensure_schema() from a backend (DuckDBBackend or any
other adapter implementing the protocol) deploys the latest schema for
that component. The facade hmp.read and hmp.open reach it on
first access so users never see a half-deployed index.
hydromodpy/core/migrations/auto_boot.py wraps that runner for
boot-time upgrades: a FileLock on <db>.lock, an atomic
<db>.bak-<ISO8601Z> snapshot with restore-on-failure, and a rolling
history of at most five snapshots. Only the workspace cache is backed
up. The catalog and index components are listed in
NO_BACKUP_COMPONENTS and skip the snapshot, because an index is
rebuilt, not restored: hmp catalog reindex for a project,
hmp workspace register for the machine scope. HMP_AUTO_MIGRATE=0
turns a pending migration into AutoMigrationDisabled and leaves the
file untouched.
Principles#
One version per component. Each DuckDB has its own ledger row in
schema_migrations. Each Zarr store carrieszarr_schema_versionin its root attributes. Each Parquet file carrieshmp.schema_versionin KV metadata.Additive migrations first. Prefer
ALTER TABLE ... ADD COLUMNwith a default over deletions or renames. Spatial Zarr fields only grow (new datasets); existing ones keep their shape and dtype.Monotone numbering. Versions are integers incremented by one per migration. No gaps. Downgrades are not supported; a migration is a one-way door.
Round-trip tests required. For every migration
v(n) -> v(n+1)a test must cover:a minimal hand-built
v(n)fixture;applying the migration produces a
v(n+1)store readable by the current backend;the migration is idempotent: running it twice is a no-op.
Breaking reader change. Any change to shape, dtype, column order, or semantics of an existing field triggers a version bump and a migration. Pure refactors that do not touch disk do not bump the version.
Export/import boundary. A
.hmparchive carriesformatandformat_versionin its own manifest, plus a SHA-256 for every file it contains. Import verifies the magic and every checksum before materialising anything. It does not currently gate onformat_version, so a package written by a newer library is detected only when a checked file fails to read.
Anti-patterns#
Do not silently accept unknown tables or columns. The reader rejects stores whose version differs from the one it knows.
Do not inject data from outside the migration. The function operates only on the SQL or store handle handed to it.
Do not couple SQL and field-store version numbers. Each evolves independently:
schema_migrationsfor DuckDB,ZARR_SCHEMA_VERSIONandPARQUET_SCHEMA_VERSIONfor the columnar stores.
Versions today#
Component |
Version |
Notes |
|---|---|---|
Project index ( |
|
Initial v2 DDL: simulations, parameters, metrics, provenance,
calibration, workflow, tags, schema_migrations. Evolves by
replacing the DDL plus |
Workspace cache ( |
|
Entries with workspace-relative paths, provenance, failures, validation_reports. |
Machine global index ( |
|
Projects table, one row per project root; |
Zarr field store |
|
Zarr format 3, ACDD root attrs, CF |
Parquet tabular store |
|
pyarrow |
GeoParquet |
|
OGC 1.1, GeoArrow encoding. |
Run seal |
|
|
Session journal |
|
|
Trash marker |
|
|
Portable package |
|
|
See also#
Storage Layout for the storage that this policy applies to.
Design Patterns for the Pydantic config layer that sits above the storage.
results for
hmp.readandCatalogBackend.