Caching Reprojected Rasters with Content Hashing
Reprojection is the most cacheable operation in a raster pipeline: deterministic, expensive, and run repeatedly against sources that change far less often than the pipeline does. Content-address the output on a digest of the source bytes plus every warp parameter, look it up with a single HEAD before doing any work, and the nightly run stops re-warping the ninety per cent of scenes that did not change. The whole recipe is thirty lines; the care goes into computing a source digest cheaply and into making sure nothing that changes the pixels is missing from the key.
When to Use This Pattern
- The same scenes are reprojected on a schedule — nightly mosaics, weekly refreshes — while the underlying imagery changes quarterly or less.
- A warp costs minutes, so a lookup that costs milliseconds is obviously worth it.
- The source exposes something you can hash cheaply: an ETag, a checksum header, or a stable version identifier.
- Storage is cheaper than compute, which for object storage against GDAL time is essentially always.
Complete Working Example
The cheap part of this is the digest. Reading a 4 GB scene to hash it defeats the purpose, so use what the store already knows, and fall back to a sampled hash only when it does not.
from __future__ import annotations
import base64
import hashlib
import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import boto3
from botocore.exceptions import ClientError
from pyproj import CRS
WARP_CACHE_VERSION = 4
def source_digest(s3, bucket: str, key: str) -> str:
"""Identify the source's content without reading it.
Preference order: a real content checksum, then the ETag, then a sampled
hash. The last is a fallback, not a default — it reads 2 MB rather than 4 GB
but it can be fooled by a change confined to the middle of the file.
"""
head = s3.head_object(Bucket=bucket, Key=key, ChecksumMode="ENABLED")
if "ChecksumSHA256" in head:
return "sha256:" + head["ChecksumSHA256"]
etag = head["ETag"].strip('"')
if "-" not in etag: # a single-part upload: ETag is the MD5
return "md5:" + etag
# Multipart ETags depend on part size, so they change on re-upload. Sample
# the head, the tail and the size instead, which does not.
size = head["ContentLength"]
head_bytes = s3.get_object(Bucket=bucket, Key=key, Range="bytes=0-1048575")["Body"].read()
tail_bytes = s3.get_object(
Bucket=bucket, Key=key, Range=f"bytes={max(0, size - 1048576)}-{size - 1}"
)["Body"].read()
sampled = hashlib.sha256(head_bytes + tail_bytes + str(size).encode()).hexdigest()
return "sampled:" + sampled
@dataclass(frozen=True)
class WarpSpec:
source_digest: str
dst_crs: str = "EPSG:3857"
resampling: str = "bilinear"
nodata: Optional[float] = -9999.0
resolution: Optional[float] = None
compress: str = "DEFLATE"
def key(self) -> str:
payload = {
"digest": self.source_digest,
# Authority form, so an upstream switch to WKT2 does not re-key everything.
"dst": CRS.from_user_input(self.dst_crs).to_authority(),
"resampling": self.resampling,
"nodata": self.nodata,
"resolution": self.resolution,
"compress": self.compress,
}
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(f"warp:v{WARP_CACHE_VERSION}:{blob}".encode()).digest()
token = base64.urlsafe_b64encode(digest[:15]).decode().rstrip("=")
return f"warp/{token[:2]}/{token[2:4]}/{token}.tif"
def warp_cached(s3, cache_bucket: str, src_uri: str, spec: WarpSpec, scratch: Path) -> str:
key = spec.key()
try:
s3.head_object(Bucket=cache_bucket, Key=key)
return key # hit: no GDAL, no I/O
except ClientError as exc:
if exc.response["Error"]["Code"] not in ("404", "NoSuchKey"):
raise
out = scratch / "warp.tif"
argv = [
"gdalwarp", "-t_srs", spec.dst_crs, "-r", spec.resampling,
"-dstnodata", str(spec.nodata),
"-co", f"COMPRESS={spec.compress}", "-co", "TILED=YES",
"-co", "BLOCKXSIZE=512", "-co", "BLOCKYSIZE=512",
"-wm", "512",
]
if spec.resolution:
argv += ["-tr", str(spec.resolution), str(spec.resolution)]
subprocess.run(argv + [src_uri, str(out)], check=True, start_new_session=True)
staging = key + ".part"
s3.upload_file(str(out), cache_bucket, staging, ExtraArgs={"Metadata": {
"source-digest": spec.source_digest,
"resampling": spec.resampling,
"dst-crs": spec.dst_crs,
"cache-version": str(WARP_CACHE_VERSION),
}})
s3.copy_object(Bucket=cache_bucket, Key=key,
CopySource={"Bucket": cache_bucket, "Key": staging})
s3.delete_object(Bucket=cache_bucket, Key=staging)
return key
The WarpSpec is deliberately the only place warp parameters are named. When a new option is added — a mask band, an alpha channel, a different creation option — it has to go into the dataclass to be used, and going into the dataclass puts it in front of whoever writes the key() method. That coupling is the point: it converts “remember to update the cache key” from a habit into a step the type system nudges you toward. It is not airtight, since a parameter could still be passed around the spec, but it removes the most common path to a stale hit.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
WARP_CACHE_VERSION |
int |
4 |
Bump on any change to the warp itself, including a pinned GDAL upgrade that alters resampling output. |
resampling |
str |
bilinear |
In the key, always. nearest and cubic produce visibly different rasters from identical input. |
nodata |
float |
-9999.0 |
In the key. It changes every edge pixel of the result and is frequently omitted by accident. |
resolution |
float |
source | In the key when set, since -tr changes the output grid entirely. |
-co TILED=YES |
— | on | The cached artefact is read many times; an untiled GeoTIFF makes every windowed read slow. |
BLOCKXSIZE |
int |
512 |
Matches typical read windows. In the key indirectly via compress and creation options if you vary them. |
-wm |
MB | 512 |
Bounds GDAL’s warp memory so the miss path has predictable peak usage. |
Verification & Testing
Two assertions matter: identical inputs hit, and any parameter change misses.
def test_identical_inputs_hit(s3_stub, scratch) -> None:
spec = WarpSpec(source_digest="sha256:abc")
first = warp_cached(s3_stub, "cache", "s3://src/scene.tif", spec, scratch)
calls_before = s3_stub.gdal_calls
second = warp_cached(s3_stub, "cache", "s3://src/scene.tif", spec, scratch)
assert first == second
assert s3_stub.gdal_calls == calls_before, "a hit must not invoke gdalwarp"
def test_every_parameter_changes_the_key() -> None:
base = WarpSpec(source_digest="sha256:abc")
variants = [
WarpSpec(source_digest="sha256:def"),
WarpSpec(source_digest="sha256:abc", dst_crs="EPSG:25832"),
WarpSpec(source_digest="sha256:abc", resampling="cubic"),
WarpSpec(source_digest="sha256:abc", nodata=0.0),
WarpSpec(source_digest="sha256:abc", resolution=10.0),
]
keys = {base.key()} | {v.key() for v in variants}
assert len(keys) == 6, "some parameter is missing from the key"
def test_equivalent_crs_spellings_share_a_key() -> None:
a = WarpSpec(source_digest="sha256:abc", dst_crs="EPSG:3857")
b = WarpSpec(source_digest="sha256:abc", dst_crs="epsg:3857")
assert a.key() == b.key()
The second test is the one to write first and keep forever: it fails loudly the day someone adds a warp parameter and forgets the key. Against real storage, the object’s own metadata makes an audit trivial, which matters when someone asks why a particular tile looks the way it does:
aws s3api head-object --bucket cache --key warp/a3/f0/a3f0…tif \
--query 'Metadata' --output table
# source-digest, resampling, dst-crs and cache-version, straight from the artefact.
Monday’s full-price run is worth planning for rather than absorbing. A cold cache on a national dataset can be several times the normal nightly cost, which is fine once and unwelcome as a surprise. The two practical mitigations are to warm the cache incrementally — run the previous night’s scene list against the new key before the change takes effect, spreading the rebuild over a week — and to schedule any deliberate CACHE_VERSION bump for a weekend rather than the night before a delivery. Both are scheduling decisions rather than code, and both are easy to skip and then regret.
Common Pitfalls
- Keying on a multipart ETag. It depends on the upload’s part size, so a re-upload of identical bytes changes it and the cache rebuilds for nothing. Prefer a checksum header; fall back to sampling.
- Omitting
nodatafrom the key. It is easy to forget because it often has a default, and it changes every edge pixel of the warped result. A cache blind to it will serve the wrong edges indefinitely. - Hashing the whole source every run. Reading 4 GB to decide whether to warp 4 GB saves nothing. Use metadata the store already has.
- Writing directly to the final key. An interrupted upload leaves a truncated object that every subsequent run treats as a valid hit. The
.part-then-copy sequence costs one API call and removes the possibility. - Forgetting
TILED=YESon the cached artefact. The object is read far more often than it is written, and an untiled GeoTIFF makes every windowed read pull the whole file — turning a cache hit into a slow one.
Frequently Asked Questions
Is a sampled digest safe enough?
For imagery that is written once and never edited in place — which describes most published scenes — yes. It is unsafe where a producer patches a file’s interior and keeps the size identical, which does happen with some in-place raster editing workflows. If your sources are edited rather than republished, insist on a real checksum or accept the cost of a full hash.
Should the cache be in the same bucket as the source?
Separate buckets are better. It keeps lifecycle rules independent — sources may be archived to cold storage while the cache stays hot — and it makes the cache’s cost visible as its own line rather than blended into source storage. Same region, though, always.
What about caching the mosaic rather than the tiles?
Cache both, at different granularities. The tiles are what change independently, so they get the highest hit rate; the mosaic is what consumers ask for, and caching it saves the assembly step on the many nights when nothing changed. Keying the mosaic on the sorted list of its tiles’ keys makes it invalidate automatically when any tile does.
Does this replace the idempotency ledger?
No. The cache says “here is a result you can reuse”; the ledger says “this work has been done”. A cache entry may be evicted without breaking anything, and a ledger entry may not. Pipelines that write side effects need both, as described in idempotency keys in spatial ETL.
Related
- Caching strategies for spatial tasks — the key design this implements
- Invalidating tile caches after a source update — propagating a change through the pyramid
- Cutting egress costs with COG range reads — making the miss path cheaper
- Setting per-task timeouts for GDAL operations — bounding the miss path