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),
    )
Why a constant timeout cannot fitTile runtimes across one job span from half a second to one hundred and forty seconds. A sixty-second constant timeout kills the expensive tiles while giving hung cheap tiles sixty seconds of grace.140 s70 s0tiles of one job, ordered by costconstant 60 s — kills 11 healthy tilesA per-tile estimate draws a line that follows the bars instead of cutting across them.
The tiles above the line are not slow, they are big. Killing them and retrying them produces the same result twice, then a dead letter.

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.

How the limit is composedOutput megapixels multiplied by the per-method cost, multiplied by the source penalty, gives an estimate. The estimate multiplied by a fixed safety factor of six gives the timeout.output megapixelswidth × height × bandsresampling cost0.35 – 2.10 s/Mpxsource penaltylocal 1.0 · vsis3 2.4estimate — 38 sproportional, not exact× 6 → limit 228 scapped at 1800 sEvery input is read from the source headers — nothing here needs a human to maintain it per tile.
Because the safety factor is applied to a proportional estimate, the detection window scales with the work rather than being generous for small tiles and tight for large ones.

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;
How much of its allowance a healthy warp usesMost warps use between ten and thirty per cent of their allowance. A thin tail reaches sixty per cent. Nothing healthy reaches the limit, which is what leaves headroom to detect a genuine hang.00.51.0 = the limitactual runtime ÷ timeout limitempty space = hang detectionhealthy work lives left of here
The gap between the tail and the limit is the whole point of the multiplier. Close it and you get false kills; widen it too far and a hang runs for half an hour before anyone notices.

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_transform gives 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.

Timeout Budgets and Cancellation for Geotasks