Querying a Parcel Layer as of a Past Commit
Answering โwhat did this parcel look like in Aprilโ against a repository means resolving a date to a commit, materialising the layer at that revision, and querying it โ a small pipeline that is easy to get subtly wrong. This page is a focused companion to temporal versioning and time-travel queries.
Concept & Context
Every versioned repository already contains its own history, so the obvious way to answer a historical question is to go and look. That instinct is right, and the mechanics are less obvious than they appear, because three things have to line up: the date has to resolve to a specific commit, that commitโs pointer files have to resolve to artifacts the object store still holds, and the query has to run against a materialisation that is genuinely the historical state rather than a partially updated working tree.
It is worth being precise about what this answers. A commit-based as-of query returns the state the repository recorded by a date. If the April survey was committed in May, an as-of query for late April will not see it. That is transaction time, and for many questions โ what did we publish, what did the analyst see, what was the basis of that decision โ it is exactly the right answer. When the question is about the ground rather than the record, the bitemporal model is the tool, and this technique is the fallback that works before that model exists.
The approach costs nothing to adopt. There is no schema change, no migration, and no new store โ it uses history that is already there. What it costs is query latency on the first request for a revision, which is why the caching step is not optional in anything user-facing.
Core Algorithmic Pipeline
- Resolve the instant to a commit. Take the last commit on the publication branch whose committer date is at or before the target instant. Resolve on the publication branch specifically, not on whatever branch is checked out, or the answer depends on the callerโs working state.
- Verify the pointer resolves. Read the artifactโs pointer file at that commit and confirm the object store still holds the hash it names, before doing any work.
- Materialise into a revision-scoped directory. Fetch into a path keyed by the commit hash, never into the working tree, so a historical query cannot disturb current work or be disturbed by it.
- Query the materialised layer. From here it is an ordinary spatial query against an ordinary file.
- Retain by commit hash. The materialisation for an immutable commit is itself immutable, so it can be cached until space pressure evicts it.
Working Implementation
"""As-of query against a DVC-tracked layer, resolved through the commit graph."""
import hashlib
import json
import subprocess
from datetime import datetime, timezone
from pathlib import Path
import geopandas as gpd
CACHE_ROOT = Path("/var/cache/gdv-asof")
PUBLICATION_BRANCH = "main"
def resolve_commit(instant: datetime, branch: str = PUBLICATION_BRANCH) -> str:
"""Last commit on `branch` recorded at or before `instant`.
Committer date, not author date: author date travels with a cherry-picked
or rebased commit and would resolve to a revision the branch never held.
"""
iso = instant.astimezone(timezone.utc).isoformat()
sha = subprocess.check_output(
["git", "rev-list", "-1", f"--before={iso}", branch], text=True
).strip()
if not sha:
raise LookupError(f"{branch} has no commit at or before {iso}")
return sha
def pointer_at(commit: str, pointer_path: str) -> dict:
"""Read a .dvc pointer as it stood at `commit` without checking anything out."""
blob = subprocess.check_output(
["git", "show", f"{commit}:{pointer_path}"], text=True
)
import yaml
return yaml.safe_load(blob)["outs"][0]
def materialise(commit: str, pointer_path: str, artifact_name: str) -> Path:
"""Fetch the artifact as of `commit` into a revision-scoped cache directory."""
target_dir = CACHE_ROOT / commit
target = target_dir / artifact_name
if target.exists():
return target # immutable commit, immutable result
out = pointer_at(commit, pointer_path)
target_dir.mkdir(parents=True, exist_ok=True)
fetched = subprocess.run(
["dvc", "get", ".", out["path"], "--rev", commit, "-o", str(target)],
capture_output=True, text=True,
)
if fetched.returncode != 0:
raise RuntimeError(
f"artifact for {out['path']} at {commit[:12]} is not retrievable: "
f"{fetched.stderr.strip()}"
)
digest = hashlib.md5(target.read_bytes()).hexdigest()
if digest != out["md5"]:
target.unlink()
raise RuntimeError(
f"hash mismatch at {commit[:12]}: pointer says {out['md5']}, "
f"fetched bytes hash to {digest}"
)
(target_dir / "provenance.json").write_text(json.dumps({
"commit": commit,
"pointer": pointer_path,
"md5": out["md5"],
"materialised_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}, indent=2))
return target
def parcels_as_of(instant: datetime, bbox=None) -> gpd.GeoDataFrame:
"""Return the parcel layer as the repository recorded it at `instant`."""
commit = resolve_commit(instant)
path = materialise(commit, "data/parcels.gpkg.dvc", "parcels.gpkg")
gdf = gpd.read_file(path, bbox=bbox)
gdf.attrs["as_of_commit"] = commit
gdf.attrs["as_of_requested"] = instant.isoformat()
return gdf
if __name__ == "__main__":
target = datetime(2025, 4, 1, tzinfo=timezone.utc)
layer = parcels_as_of(target, bbox=(4321000, 3210000, 4326000, 3215000))
print(f"{len(layer)} parcels as of {target:%Y-%m-%d} "
f"(commit {layer.attrs['as_of_commit'][:12]})")
Two details carry most of the reliability. The hash comparison after fetching means a corrupted or substituted object is caught here rather than in the analysis that follows. And attaching as_of_commit to the returned frame means every downstream result can name the revision it came from โ which matters enormously when two analysts produce different numbers for the same date.
Validation & Output Verification
Confirm the resolution is doing what you think before trusting any answer built on it:
# The commit the date resolves to, and what it says about itself
git rev-list -1 --before=2025-04-01T00:00:00+00:00 main \
| xargs -I{} git show -s --format='%H%n%cI%n%s' {}
# The pointer as it stood at that commit
git show "$(git rev-list -1 --before=2025-04-01T00:00:00+00:00 main):data/parcels.gpkg.dvc"
# Whether the object store still holds it
dvc status --cloud --rev "$(git rev-list -1 --before=2025-04-01T00:00:00+00:00 main)"
Then check the answer is stable and the cache is honest:
# Two runs of the same query must return an identical feature count and commit
python -c "
from asof import parcels_as_of
from datetime import datetime, timezone
t = datetime(2025, 4, 1, tzinfo=timezone.utc)
a, b = parcels_as_of(t), parcels_as_of(t)
assert len(a) == len(b) and a.attrs['as_of_commit'] == b.attrs['as_of_commit']
print('stable:', len(a), 'features at', a.attrs['as_of_commit'][:12])
"
# A cache directory must never exist without its provenance file
find /var/cache/gdv-asof -mindepth 1 -maxdepth 1 -type d \
! -exec test -e '{}/provenance.json' \; -print
The last check catches a half-written cache entry left behind by an interrupted fetch, which otherwise returns a truncated file that opens without complaint and reports the wrong feature count.
Failure Modes
-
The query answers with todayโs data โ symptom: an as-of query for last year returns the current parcel count. Root cause: materialising into the working tree, so the current file was read. Fix: fetch into a revision-scoped directory and never resolve paths relative to the working tree.
-
Two analysts get different answers for one date โ symptom: the same as-of question produces different feature counts. Root cause: resolution ran against whatever branch each caller had checked out. Fix: resolve against the publication branch explicitly, as the implementation above does.
-
A historical query silently returns the nearest available revision โ symptom: results for a date the object store cannot actually serve. Root cause: a fallback on fetch failure. Fix: fail loudly; a wrong answer to a historical question is worse than an error, particularly where the question came from an audit.
-
The first query of the day takes minutes โ symptom: unpredictable latency on historical requests. Root cause: cold cache plus a large artifact. Fix: pre-warm the cache for revisions that get queried regularly โ release tags especially โ on a schedule.
Related
- Temporal Versioning and Time-Travel Queries โ the parent guide, and the model to adopt when valid time matters
- Modelling Valid Time and Transaction Time in PostGIS โ the schema this technique approximates
- Understanding Pointer Files in GeoGit vs DVC โ what the pointer read in step two actually contains
- Release Tagging Strategies for Spatial Basemaps โ named revisions that make good pre-warmed cache entries