A snowdb on disk¶
A snowdb is a plain directory. Nothing about it is a database in the
server sense: it is a tree of JSON configs, GeoJSON records, and
cloud-optimized GeoTIFFs that any process can read directly, and that
snowtool reaches entirely by following one root config at the top.
This page is the map of that tree — what each file is, who writes it, who
reads it, and which files are the source of truth versus derived artifacts
that can always be rebuilt. The configs themselves, field by field, are
Configuration in depth; provenance tagging and
staleness are provenance.
The tree¶
A snowdb laid out by snowtool init and populated with one
path-linked dataset looks like this:
/srv/snowdb/
├── snowdb_conf.json root config (snowtool.snowdb/v1) — the entry point
├── pourpoints/
│ ├── index.geojson derived manifest, rebuildable (`pourpoint reindex`)
│ └── records/
│ └── <triplet>.geojson source-of-truth pourpoint records
├── data/
│ └── <dataset>/
│ ├── dataset.json dataset config (snowtool.dataset/v1)
│ ├── cogs/
│ │ └── <YYYYMMDD>/ one directory per ingested date
│ │ └── *__<var>.tif per-variable COGs for that date
│ ├── aoi-rasters/
│ │ └── <triplet>.tif per-basin AOI rasters burned onto this grid
│ ├── terrain/ terrain zone-layer set (elevation, aspect, …)
│ └── landcover/ land-cover zone-layer set (forest_cover)
└── .cache/
└── landcover/ cached NLCD source download (safe to delete)
The only fixed name in the whole system is snowdb_conf.json
(CONFIG_FILENAME in snowdb/config.py): it is what snowtool init writes
and what SnowDb.open looks for when handed a directory. Everything else
is reached by following the config rather than by assuming a path, so
almost every location here is a default the root config could override.
The root config¶
snowdb_conf.json is the system's single entry point. Handed one path,
SnowDb (snowdb/db.py) resolves everything else — the datasets, the
pourpoint index and records — from it. It is a small RootConfig
(snowdb/config.py) written and read only through snowtool: snowtool
init creates it, and the dataset command group edits its datasets
map. It is pure source of truth — the record of which datasets exist and
which readers serve — and carries no derived data, so it is never
regenerated, only edited. Its fields are covered in
Configuration in depth.
Pourpoints: records versus index¶
pourpoints/ is the catalog, and it is the canonical example of the
source-of-truth/derived split that runs through the whole store.
records/<triplet>.geojson are the pourpoint records: one lossless
GeoJSON file per pourpoint — a station triplet, an outflow point, and
optionally a delineated upstream basin polygon. These are the source of
truth. pourpoint import/sync write them (importing a source file
verbatim where possible, via atomic_copy), and pourpoint remove
deletes them. Their semantics — triplets, the optional basin, how a basin
becomes an AOI raster — are pourpoints.
index.geojson is the pourpoint index: a derived, rebuildable
manifest (PourpointIndex in snowdb/pourpoint_index.py). Parsing every
record — each basin is thousands of coordinate pairs — just to list
pourpoints is wasteful, so the index denormalizes the list-relevant fields
into one Point feature per pourpoint (id = triplet, geometry = the
outflow point, properties = name, geodesic area_meters, per-dataset
coverage, and the basin geometry_hash as an internal rebuild signal). It
is deliberately GeoJSON-native: the same file is a plottable point layer
and the API's listing payload. Because it is derived it never has to be
trusted as primary data — if it drifts, pourpoint reindex rebuilds it
from records/ in full. Import, sync, and remove keep it up to date
incrementally; reindex is the recovery path for out-of-band edits and
for a grid change to an already-registered dataset.
Per-dataset data¶
Each registered dataset owns a directory (data/<name>/ by convention),
managed by Dataset in snowdb/dataset.py. For a path-linked dataset the
directory holds a dataset.json (DatasetConfig, snowtool.dataset/v1)
that fully describes the dataset — its grid, variables, ingester, and
zones; for an inline dataset that config lives in the root config instead
and there is no dataset.json on disk. Everything else under the
directory is a derived, rebuildable artifact:
cogs/<YYYYMMDD>/ holds one directory per ingested date, each containing
the per-variable COGs for that date. Files are named
<source-provenance>__<key>.tif, and the read path is dataset-agnostic:
it finds a variable's file by the variable's glob and takes the date
from the directory name, never parsing filenames. Ingest writes these
(see ingest); queries read them.
aoi-rasters/<triplet>.tif holds one AOI raster per basin, burned onto
this dataset's grid. The raster is both the basin membership mask and
the per-pixel cell-area weights for a query; pourpoint rasterize builds
it and the query engine reads it. It is fully derived from a basin polygon
plus the grid, and it is tagged with the basin's geometry hash so a
changed basin reads as stale and rebuilds (provenance).
terrain/ and landcover/ hold the zone-layer sets — the derived
elevation/aspect/forest-cover grids a query crosses its statistics over.
Each configured zone-layer provider writes into its own subdirectory
(terrain, landcover), named by the provider's subdir
(snowdb/zones/). A provider's subdirectory exists only for a dataset
whose config zones enables it. These are generated by dataset
generate-zones and are covered in zones and
zone generation.
The source cache¶
.cache/ is not database content at all: it holds inputs a
download-and-cache generation source keeps around between runs. Its one
occupant today is .cache/landcover/, where the default land-cover source
fetches the ~1.5 GB Annual NLCD bundle and extracts the national GeoTIFF
the forest-cover layer is binned from
(zone generation). The cache sits under the snowdb
root rather than a temp directory deliberately: temp directories are
reaped (on reboot, or by age-based cleaners), which would turn a later
regeneration back into a re-download; the snowdb's volume is the one sized
for bulk raster data, where tmp may be a small or RAM-backed partition;
and scoping it to the root means the cache lives and dies with the
database — deleting the snowdb orphans nothing elsewhere.
It is pure cache: once zone layers are generated it is safe to delete, and
the only cost of deleting it is re-downloading on the next generation run.
A snowdb whose land-cover source is pinned to a local file (the root
config's sources map, or --source landcover PATH) never creates it.
Atomic writes¶
Every persisted file is read back later by another process — a query, a
reindex, the API at startup — so a write that dies partway through (a
crash, ENOSPC, ^C) must never leave a torn file where a reader will
find it. The helpers in snowdb/atomic.py give every writer that
guarantee with one primitive: write into a uniquely named temp path
beside the destination (same directory, so guaranteed same filesystem),
then os.replace it onto the destination. A same-filesystem rename is
atomic on POSIX and on Windows, so the swap can never be observed
half-done, and any failure removes the temp file and leaves the
destination exactly as it was. atomic_write_text covers the JSON configs
and the index; atomic_copy imports a source record byte-for-byte without
re-encoding it.
A whole per-date COG directory is committed the same way through
staged_dir: the caller populates a fresh temp directory beside the
target, and on clean exit the old directory (if any) is renamed aside, the
temp directory is renamed onto the target, and the old one is removed.
POSIX has no primitive that swaps two non-empty directories in one step,
so there is a brief, deliberate window between those two renames when
nothing exists at the target — but a reader can never see a partial
directory, only the wholly-old tree, a sub-millisecond gap, or the
wholly-new tree. This also means a re-ingest cannot leave stale COGs from a
differently-named source lingering beside the new ones.
Note
This is crash-consistency of content, not durability against power
loss. There is no fsync, so a kernel or power failure immediately
after a successful rename can still lose the write to page cache. What
ends up on disk is always a complete prior version or a complete new
version, never a partial one — a deliberate trade-off, since a snowdb
is a rebuildable store and not a durability-critical one.
Resource-typed entities¶
Every persisted config carries an opaque, versioned resource
discriminator as its first field: snowtool.snowdb/v1 for the root config,
snowtool.dataset/v1 for a dataset config (ResourceModel in
snowdb/config.py). The /vN is human-facing, but the whole string is an
exact-match type tag: a schema change is a new type with its own model
and migration, never an in-place reinterpretation of the old one. No
entity's version constrains another's, and there is deliberately no global
snowdb version number. A TypeAdapter union (load_entity) routes any
file off disk to exactly one model by its resource string, so a file can
be loaded without knowing its kind up front. This is a greenfield store —
every resource starts at v1 and there is no migration machinery; see
provenance for how the same versioned-hash idea tags
derived artifacts.
Relocatability¶
Because the root config is the one entry point and everything is reached
from it, a snowdb is relocatable as long as its internal links stay
relative. A relative link — a dataset path, pourpoint_index,
pourpoint_records, or a generation source — resolves against the root
config's own directory, so moving the whole tree keeps every link valid.
An absolute link points at a fixed location and decouples that piece from
the tree (a dataset staged elsewhere, a shared DEM). Data-directory
conventions follow the same rule: a path-linked dataset's data lives
beside its config wherever the path points, and an inline dataset's data
lives at data/<name>/ under the root. A RootConfig built in code with
no path on disk has no root to resolve against, so it can use only absolute
links and inline datasets — which is exactly how the test suite builds a
whole snowdb with no files at all. The resolution rules are spelled out in
Configuration in depth.