Parameterizing Prefect Flows for Multi-Tile Runs
A flow that processes one tile becomes a flow that processes three hundred by taking the tile list as a typed parameter and fanning out:
from prefect import flow
@flow(name="tile-indices")
def process_tiles(tiles: list[str], start: str, end: str, indices: list[str] = ["ndvi"]):
for tile in tiles:
process_one_tile(tile=tile, start=start, end=end, indices=indices)
Parameterisation is what turns the scheduled flows in Orchestrating Pipelines with Prefect into something that can be re-run for one tile, one date, or one failed subset.
Why This Arises in Remote Sensing Workflows
Raster pipelines are naturally parameterised: a tile, a date window, a set of indices, an output prefix. When those values are hard-coded, every variation becomes a code change, and re-running a single failed tile means editing a file, committing, and redeploying.
The operational cost shows up on the bad days. A run of three hundred tiles fails on eleven of them because of transient storage errors. With a parameterised flow, the recovery is one run with eleven tile identifiers. Without it, the choice is between re-running everything — expensive, and re-writing two hundred and eighty-nine good outputs — and hand-editing a script under time pressure.
Parameterisation also makes the run legible. A UI showing three hundred runs called process-tiles is useless; one showing ndvi-36MYF-2023-07 next to ndvi-36NYG-2023-07 lets an operator see at a glance which tiles are slow, which failed, and which have not started.
Environment & Setup
| Package | Version | Why |
|---|---|---|
prefect |
≥2.14 | Flows, subflows, .map, tags, run-name templates |
pydantic |
≥2.0 | Typed parameter validation |
pystac-client |
≥0.7 | Resolving the items each tile needs |
rioxarray |
≥0.15 | The per-tile work itself |
pip install "prefect>=2.14" "pydantic>=2.0" "pystac-client>=0.7" "rioxarray>=0.15"
Complete Working Example
A parent flow that validates parameters, fans out one subflow per tile with bounded concurrency, and returns a summary that names the failures.
from datetime import date
from prefect import flow, task, get_run_logger
from prefect.task_runners import ConcurrentTaskRunner
from pydantic import BaseModel, Field, field_validator
class RunConfig(BaseModel):
"""Typed parameters — validation happens before any work starts."""
tiles: list[str] = Field(..., min_length=1)
start: date
end: date
indices: list[str] = Field(default_factory=lambda: ["ndvi"])
max_cloud: int = Field(20, ge=0, le=100)
overwrite: bool = False
@field_validator("tiles")
@classmethod
def tiles_look_like_mgrs(cls, v: list[str]) -> list[str]:
bad = [t for t in v if not (4 <= len(t) <= 6)]
if bad:
raise ValueError(f"these do not look like MGRS tiles: {bad}")
return v
@field_validator("end")
@classmethod
def end_after_start(cls, v: date, info) -> date:
if "start" in info.data and v < info.data["start"]:
raise ValueError("end is before start")
return v
@task(retries=3, retry_delay_seconds=[10, 30, 90], tags=["stac-read"])
def search_tile(tile: str, start: date, end: date, max_cloud: int) -> list[str]:
"""Item ids for one tile and window. Tagged so its concurrency can be capped."""
...
return item_ids
@task(retries=2, retry_delay_seconds=[20, 60], tags=["raster-io"])
def compute_index(item_id: str, index: str, overwrite: bool) -> str:
"""One item, one index, deterministic output path."""
...
return output_uri
@flow(name="tile-index", flow_run_name="{tile}-{index_label}")
def process_tile(tile: str, cfg: RunConfig, index_label: str = "ndvi") -> dict:
"""Everything for one tile. Its own state, retries and logs."""
logger = get_run_logger()
item_ids = search_tile(tile, cfg.start, cfg.end, cfg.max_cloud)
logger.info("tile %s: %d items in window", tile, len(item_ids))
futures = [compute_index.submit(i, ix, cfg.overwrite)
for i in item_ids for ix in cfg.indices]
written, failed = [], []
for fut in futures:
try:
written.append(fut.result())
except Exception as exc:
failed.append(repr(exc))
return {"tile": tile, "items": len(item_ids), "written": len(written),
"failed": len(failed), "errors": failed[:3]}
@flow(name="multi-tile-indices", task_runner=ConcurrentTaskRunner())
def process_tiles(
tiles: list[str],
start: date,
end: date,
indices: list[str] | None = None,
max_cloud: int = 20,
overwrite: bool = False,
) -> dict:
"""Parent flow: validate once, fan out per tile, summarise."""
cfg = RunConfig(tiles=tiles, start=start, end=end,
indices=indices or ["ndvi"], max_cloud=max_cloud, overwrite=overwrite)
logger = get_run_logger()
logger.info("processing %d tile(s) from %s to %s", len(cfg.tiles), cfg.start, cfg.end)
results = []
for tile in cfg.tiles:
# A subflow per tile: failures are isolated and visible per tile in the UI
results.append(process_tile(tile=tile, cfg=cfg, index_label="-".join(cfg.indices)))
failed_tiles = [r["tile"] for r in results if r["failed"]]
summary = {
"tiles": len(results),
"items": sum(r["items"] for r in results),
"written": sum(r["written"] for r in results),
"failed_tiles": failed_tiles,
}
if failed_tiles:
logger.warning("re-run with tiles=%s", failed_tiles)
return summary
if __name__ == "__main__":
print(process_tiles(tiles=["36MYF", "36NYG"], start=date(2023, 7, 1), end=date(2023, 7, 31)))
Two details make this usable at scale. flow_run_name="{tile}-{index_label}" turns the run list into something an operator can scan. And returning failed_tiles from the parent means the recovery command writes itself — the log line literally contains the parameter for the retry run.
Variant Patterns
1. Bounding concurrency with tags
prefect concurrency-limit create raster-io 16
prefect concurrency-limit create stac-read 8
Because the tasks above already carry tags=["raster-io"] and tags=["stac-read"], no code change is needed to apply the caps — and the caps hold across concurrent runs, which per-flow settings do not. This composes with the backoff policy in Retrying Failed Raster Tasks in a Prefect Pipeline: the cap prevents most throttling, and the backoff handles what gets through.
2. Mapped tasks instead of subflows
For small, uniform units, mapping is lighter than a subflow per tile.
@flow
def process_tiles_mapped(tiles: list[str], start: date, end: date):
futures = compute_tile.map(tile=tiles, start=start, end=end)
return [f.result() for f in futures]
The trade is observability: mapped task runs are visible but they do not have their own flow-level state, retries or logs, which makes debugging a specific tile harder. As a rule, if a tile takes minutes, use a subflow; if it takes seconds, map it.
3. Deriving the tile list rather than passing it
Sometimes the caller knows an area, not a tile list. Resolving the list inside the flow keeps the interface simple and the resolution auditable.
@task
def tiles_for_aoi(bbox: tuple[float, float, float, float]) -> list[str]:
"""MGRS tiles intersecting a bounding box, from the catalogue's own footprints."""
items = catalog.search(collections=["sentinel-2-l2a"], bbox=bbox, limit=100).items()
return sorted({i.properties["s2:mgrs_tile"] for i in items})
Log the resolved list. A run that silently processes a different set of tiles than expected is much harder to debug than one whose first log line names them.
Making a Large Run Observable
Three practices make the difference between a run you can supervise and one you can only wait for.
Name every run from its parameters. Both the parent and the subflows should carry the tile and the window in their names, so the UI is a work list rather than a wall of identical entries.
Return structured summaries, not None. A parent flow that returns counts and failed identifiers gives you a machine-readable artefact for the retry and a human-readable one for the status report. It also makes the flow testable without a scheduler.
Log the decision points, not the progress. “tile 36MYF: 14 items in window, 3 skipped as existing” is worth logging; a line per item is noise that hides it. The per-item detail belongs in the status table described in Batch Computing Indices Across a STAC Collection.
Common Errors
The run fails immediately with a validation error
Working as intended: the typed parameters rejected the input before any compute was spent. Fix the parameter rather than loosening the validator.
Subflow runs do not appear in the UI
They were called from inside a task rather than from the flow. Subflows must be invoked from flow context; a flow called inside a task runs but is not tracked as a subflow.
A large run overwhelms the storage backend
The concurrency limits are not applied, or the tasks are missing their tags. Check that the tag names in the code match the limits registered on the server.
Frequently Asked Questions
Q: Subflow per tile or one mapped task? Subflows when each tile is substantial and you want per-tile retries, states and logs that can be inspected independently. Mapped tasks when tiles are small and uniform, because thousands of subflows add scheduling overhead and clutter the UI.
Q: How do I stop a big run from throttling the bucket? Use a tag-based concurrency limit. Tagging the read tasks and capping that tag at, say, sixteen bounds simultaneous requests regardless of how many tiles are in flight.
Q: Can I re-run only the failed tiles?
Yes, if the flow is parameterised by tile and each tile writes a deterministic output. Collect the failed tile identifiers from the run and pass them as the tiles parameter of a new run.
Related
- Orchestrating Pipelines with Prefect — the parent topic covering flows, tasks and deployments.
- Retrying Failed Raster Tasks in a Prefect Pipeline — the retry policy these tasks carry.
- Scheduling Sentinel-2 Downloads with Prefect Flows — the scheduled counterpart of this on-demand run.
- Batch Computing Indices Across a STAC Collection — the per-item work these flows orchestrate.