Setting Per-Task Timeouts for GDAL Operations
A single constant cannot time out GDAL work correctly, because the runtime of gdalwarp varies by two orders of magnitude across the tiles of one job — an ocean tile of uniform nodata finishes in under a second, and a mountainous tile at the same zoom with cubic resampling takes minutes. Size the timeout per invocation from the work the command is about to do: output pixel count, band count, resampling cost and whether the source is local or remote. A constant tuned for the ocean tile kills the mountain; a constant tuned for the mountain lets a genuinely hung ocean tile run for ten minutes.
When to Use This Pattern
- The job’s tiles differ widely in cost — almost always true for anything covering a real country rather than a test extent.
- You have seen timeouts that correlate with geography rather than with infrastructure: the same tiles fail every night, and they are the interesting ones.
- GDAL runs as a subprocess, which is the normal arrangement, so the timeout has to be applied by the caller rather than by a library setting.
- A timeout budget already exists, and this is the step that derives the subprocess layer’s share of it.
Complete Working Example
The estimator below predicts a runtime from the operation’s shape, then applies a generous multiplier. The prediction does not need to be accurate — it needs to be proportional, so that a tile ten times more expensive gets roughly ten times the allowance.
from __future__ import annotations
import math
from dataclasses import dataclass
# Measured on the reference worker: seconds per megapixel of OUTPUT, by resampling
# method. Re-measure after any change to the image, the instance type or GDAL.
SECONDS_PER_MEGAPIXEL = {
"nearest": 0.35,
"bilinear": 0.55,
"cubic": 0.95,
"cubicspline": 1.60,
"lanczos": 2.10,
"average": 0.70,
}
# Remote sources add latency per block read; /vsis3/ and /vsicurl/ are far from free.
SOURCE_PENALTY = {"local": 1.0, "vsis3": 2.4, "vsicurl": 3.2, "vsigs": 2.4}
@dataclass(frozen=True)
class WarpShape:
"""Everything about a warp that predicts its cost."""
width: int
height: int
band_count: int
resampling: str = "bilinear"
source_kind: str = "local"
compress: str = "DEFLATE"
@property
def megapixels(self) -> float:
return (self.width * self.height * self.band_count) / 1_000_000
def estimate_seconds(shape: WarpShape) -> float:
"""A proportional cost estimate — deliberately rough, never a promise."""
base = shape.megapixels * SECONDS_PER_MEGAPIXEL.get(shape.resampling, 0.55)
base *= SOURCE_PENALTY.get(shape.source_kind, 1.0)
if shape.compress in ("DEFLATE", "ZSTD"):
base *= 1.25 # compression is real work on the write side
elif shape.compress in ("LZW",):
base *= 1.15
# A floor: process start-up, PROJ database load and file opening cost ~2 s
# regardless of how little there is to warp.
return max(2.0, base)
def timeout_for(shape: WarpShape, multiplier: float = 6.0, ceiling: float = 1800.0) -> float:
"""The limit to hand to the subprocess runner.
The multiplier is wide on purpose. The timeout's job is to catch a HANG —
an operation that will never finish — not to enforce a performance target.
Anything tight enough to catch a slow tile will kill a busy worker.
"""
return min(ceiling, math.ceil(estimate_seconds(shape) * multiplier))
Reading the shape from the source, rather than assuming it, is what makes the estimate track reality:
import rasterio
from rasterio.warp import calculate_default_transform
def shape_for_tile(source_uri: str, dst_crs: str, resampling: str) -> WarpShape:
"""Inspect the source cheaply — this opens headers, not pixels."""
with rasterio.open(source_uri) as src:
transform, width, height = calculate_default_transform(
src.crs, dst_crs, src.width, src.height, *src.bounds
)
kind = (
"vsis3" if source_uri.startswith("/vsis3/")
else "vsicurl" if source_uri.startswith("/vsicurl/")
else "local"
)
return WarpShape(
width=width, height=height, band_count=src.count,
resampling=resampling, source_kind=kind,
)
def warp_tile(source_uri: str, dest: str, budget, dst_crs: str = "EPSG:3857") -> None:
shape = shape_for_tile(source_uri, dst_crs, resampling="bilinear")
run_gdal(
[
"gdalwarp",
"-t_srs", dst_crs,
"-r", shape.resampling,
"-dstnodata", "-9999", # never let GDAL pick this for you
"-co", "COMPRESS=DEFLATE",
"-co", "TILED=YES",
"-wm", "512", # bound GDAL's own warp memory
source_uri, dest,
],
budget=budget,
want_seconds=timeout_for(shape),
)
The multiplier is where most of the judgement sits, and it is worth understanding what it is absorbing. It covers the difference between a quiet worker and a worker running eight warps at once; it covers a cold page cache; it covers the object store having a slow minute. None of those are anomalies — they are Tuesday. What it must not absorb is a genuine hang, which is why the multiplier is a fixed factor rather than a growing one: six times an estimate that itself scales with the work keeps the detection window proportional, so a hung ocean tile is caught in twelve seconds and a hung mountain tile in twelve minutes, each roughly as soon as it is unambiguous.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
SECONDS_PER_MEGAPIXEL |
dict | measured | Per resampling method, measured on your worker. lanczos is roughly six times nearest; guessing this ratio is where estimates go wrong. |
SOURCE_PENALTY |
dict | 1.0–3.2 | /vsis3/ reads pay per-block latency. A warp from object storage can be three times a local one for identical pixels. |
multiplier |
float |
6.0 |
Wide on purpose. The timeout catches hangs, not slowness. Below about 3× it will kill healthy tiles on a busy worker. |
ceiling |
float |
1800.0 |
An absolute cap so a mis-estimated shape cannot ask for hours. |
| floor | float |
2.0 s |
Process start-up plus PROJ database load. Even a no-op warp costs this. |
-wm |
MB | 512 |
Bounds GDAL’s warp memory. Without it a large warp will happily use most of the machine and turn a timeout into an OOM. |
Verification & Testing
The estimator is worth testing for proportionality rather than accuracy — that a bigger job gets a bigger allowance, and that nothing produces an absurd number.
def test_estimate_scales_with_pixels() -> None:
small = WarpShape(width=1024, height=1024, band_count=1)
large = WarpShape(width=4096, height=4096, band_count=1)
# 16x the pixels should be roughly 16x the estimate, not 2x and not 200x.
ratio = estimate_seconds(large) / estimate_seconds(small)
assert 12 < ratio < 20
def test_resampling_and_source_both_matter() -> None:
base = WarpShape(width=2048, height=2048, band_count=3, resampling="nearest")
fancy = WarpShape(width=2048, height=2048, band_count=3, resampling="lanczos")
remote = WarpShape(width=2048, height=2048, band_count=3, source_kind="vsis3")
assert estimate_seconds(fancy) > estimate_seconds(base) * 4
assert estimate_seconds(remote) > estimate_seconds(base)
def test_timeout_never_exceeds_the_ceiling() -> None:
absurd = WarpShape(width=200_000, height=200_000, band_count=4)
assert timeout_for(absurd) == 1800.0
Against real runs, the number that tells you the multiplier is right is the ratio of actual runtime to estimated timeout. Log both, and the distribution answers the question directly — if the p99 of actual / limit is 0.15, the multiplier is far too generous and a genuine hang will run for many minutes before being noticed; if it is 0.9, healthy tiles are being killed on any busy night.
-- One row per warp. The interesting column is the last one.
SELECT tile_key, megapixels, resampling, limit_seconds, actual_seconds,
round(actual_seconds / NULLIF(limit_seconds, 0), 3) AS used_fraction
FROM warp_runs
WHERE started_at > now() - interval '7 days'
ORDER BY used_fraction DESC
LIMIT 20;
Common Pitfalls
- Estimating from the source size instead of the output. A warp’s cost is dominated by the pixels it writes. Reprojecting a small source into a much larger grid is expensive; downsampling a huge source into a thumbnail is cheap.
calculate_default_transformgives you the output shape for the price of reading headers. - Forgetting
-wm. Without a warp-memory bound, GDAL sizes its buffers from what is available, so a warp on an idle machine behaves differently from the same warp on a busy one. That variance defeats any estimate, and it turns some timeouts into OOM kills. - Ignoring the source scheme. The identical warp reading
/vsis3/instead of a local path can take three times as long because of per-block latency. Estimates that omit this kill exactly the tiles whose source moved to object storage. - Using the timeout as a performance budget. A tight timeout does not make GDAL faster; it converts slow tiles into failed tiles that are retried, tripling the work. Performance belongs in tiling and worker sizing — see right-sizing workers for raster mosaic jobs.
- Not re-measuring after a GDAL upgrade. Resampling kernels get optimised between releases, sometimes by a factor of two. The per-megapixel table is a measurement of a specific image; treat it as something the upgrade rollout has to refresh.
Frequently Asked Questions
Is a machine-learning model worth it for the estimate?
No. The estimate needs to be proportional, and a linear model on output megapixels with a handful of multipliers gets you there for a few lines of code that anyone can debug. A learned model adds a training pipeline, a drift problem and an unexplainable timeout, in exchange for accuracy the six-times multiplier throws away anyway.
What about `gdal_translate`, `gdaldem` and the rest?
The same shape works, with different constants. gdal_translate without reprojection is close to a copy plus compression, so its per-megapixel cost is much lower; gdaldem hillshade is arithmetic-heavy and higher. Measure each utility you actually invoke, and keep the constants in one table so they can be re-measured together.
Should the timeout be part of the ledger's key?
No — it is a property of the attempt, not of the work. Including it would change the idempotency key every time you re-tuned a constant, invalidating a ledger that describes work whose output has not changed at all.
How do I handle the one tile that legitimately takes an hour?
Give it its own path. If the estimate says a tile needs more than the ceiling, that is a signal to split it rather than to raise the ceiling — the same conclusion choosing tile sizes for raster partitioning reaches from the other direction. A single unsplittable hour-long operation is a design problem the timeout is merely reporting.
Related
- Timeout budgets and cancellation for geotasks — the budget this timeout is a slice of
- Cancelling in-flight tile jobs cleanly — what has to happen when this limit fires
- Right-sizing workers for raster mosaic jobs — the machine the per-megapixel constants describe
- Choosing tile sizes for raster partitioning — keeping the cost distribution narrow in the first place