Orchestrating Annotation Pipelines with Airflow

An annotation pipeline that works when a person runs four scripts in order stops working the first night nobody runs them. The steps are not hard — harvest yesterday’s completed annotations, validate the geometry, export a training artifact, version it — but they need a schedule, credentials, retries that distinguish a timeout from a rejection, and a backfill story for the week the platform was down. That is the job Airflow does, and doing it badly produces the specific failure this topic exists to prevent: a DAG that, when rerun, exports today’s data into yesterday’s partition and silently doubles a class.

The design rule that makes everything else work is that each task reads only from its own data interval and writes to a path derived from it. Get that right and reruns are free, backfills reconstruct history correctly, and a failed task can be cleared without anyone reasoning about what it already did.

Prerequisites & Toolchain Alignment

bash
pip install "apache-airflow==2.9.3" \
            "apache-airflow-providers-amazon==8.25.0" \
            geopandas==0.14.4 shapely==2.0.6 pyproj==3.6.1 dvc[s3]==3.51.2

Three assumptions the DAG below makes:

  • The annotation platform can be queried by a closed time window. Label Studio, CVAT and most hosted platforms support filtering by update timestamp and review state. If yours only offers “changed since my last poll”, wrap it in a store that records what each interval saw, because that endpoint cannot be backfilled.
  • The validators already exist as a library. Airflow should call the same checks the CI/CD gate runs on pull requests, not a second copy that drifts from it.
  • Dataset versioning is content-addressed. The final task pushes a manifest; if the content is unchanged the version must not advance, which is the property tracking annotation changes with SHA hashing provides.
Four tasks, each keyed on the data interval Harvest reads the annotation platform for one closed interval and writes raw GeoJSON to a path containing that interval. Validate reads that path and writes a report. Export reads the validated features and writes a training artifact. Version hashes the artifact and pushes it. Because every path contains the interval, rerunning any task overwrites its own output and nothing else. harvest platform API, closed window validate geometry · CRS · schema export GeoParquet + COCO version hash, then push if changed raw/{ds}/features.geojson reports/{ds}/validation.json export/{ds}/labels.parquet manifests/{ds}.json every path contains the interval, so every task is safe to rerun clearing a failed task overwrites exactly its own output; a backfill of last month writes last month's paths drop the interval from one path and that task becomes the one nobody dares rerun

Building the DAG

Step 1 — Harvest a Closed Window

The single most important line in the DAG is the query bound. data_interval_start and data_interval_end are supplied by Airflow for the interval being processed, not for now, which is what makes a backfill mean anything.

python
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path

import httpx
from airflow.decorators import dag, task

PLATFORM = "https://labels.internal/api"

@task(retries=3, retry_delay=timedelta(minutes=2), retry_exponential_backoff=True)
def harvest(data_interval_start: datetime, data_interval_end: datetime, ds: str) -> str:
    """Pull annotations REVIEWED within this interval; write one file keyed on it."""
    out = Path(f"/data/raw/{ds}/features.geojson")
    out.parent.mkdir(parents=True, exist_ok=True)
    params = {
        "reviewed_after": data_interval_start.isoformat(),
        "reviewed_before": data_interval_end.isoformat(),
        "state": "approved",          # never work in progress
        "page_size": 500,
    }
    features: list[dict] = []
    with httpx.Client(timeout=60.0) as client:
        url = f"{PLATFORM}/annotations"
        while url:
            resp = client.get(url, params=params)
            if 400 <= resp.status_code < 500 and resp.status_code != 429:
                raise RuntimeError(f"platform rejected the query: {resp.status_code} {resp.text[:200]}")
            resp.raise_for_status()
            body = resp.json()
            features.extend(body["results"])
            url, params = body.get("next"), None
    out.write_text(json.dumps(
        {"type": "FeatureCollection", "features": features},
        indent=2, sort_keys=True) + "\n")
    return str(out)

The state filter is not a detail. Harvesting in-progress annotations means a tile half-drawn at midnight enters the dataset, gets exported, and is then re-harvested the next night in its finished form — two versions of one feature, both real, with nothing to reconcile them.

Retry the transport, never the semantics A timeout, a 502 and a 429 are transport failures: the request never reached a verdict, so retrying with exponential backoff is correct. A 400 or 422 means the server has already judged the payload invalid, and a retry repeats a request that cannot succeed — worse, on a partially applied batch it can duplicate features. Those fail the task at once so a human reads the message. no verdict reached — retry connection timeout 502 / 503 / 504 429 too many requests 3 attempts, exponential backoff the same request can still succeed already judged — fail now 400 malformed query 422 rejected payload 403 wrong credentials 0 attempts — the message is the fix retrying a partial batch duplicates features a blanket retry policy is the reason a broken filter shows up as three identical failures an hour apart instead of one clear error

The explicit 4xx check before raise_for_status distinguishes the two failure kinds: a malformed query should stop the DAG immediately, while a 429 or a 502 falls through to Airflow’s retry policy.

Step 2 — Validate as Its Own Task

Validation is a separate task so that its failure is legible in the DAG graph and so a fix can be applied and only that task cleared.

python
@task
def validate(raw_path: str, ds: str) -> str:
    """Run the same checks the pull-request gate runs. Fail the task on any error."""
    import geopandas as gpd
    from annotation_gates import check_geometry, check_crs, check_schema   # shared library

    gdf = gpd.read_file(raw_path)
    errors: list[str] = []
    errors += check_geometry(gdf)
    errors += check_crs(gdf, expected="EPSG:4326")
    errors += check_schema(gdf, schema_path="/config/label_schema.json")

    report = Path(f"/data/reports/{ds}/validation.json")
    report.parent.mkdir(parents=True, exist_ok=True)
    report.write_text(json.dumps({"errors": errors, "n_features": len(gdf)}, indent=2) + "\n")
    if errors:
        raise ValueError(f"{len(errors)} validation error(s); first: {errors[0]}")
    return raw_path

Writing the report before raising is deliberate: the task fails, the DAG stops, and the artifact explaining why is already on disk rather than trapped in a log the on-call engineer has to page through.

Step 3 — Export, Then Version Only If Something Changed

python
@task
def export(validated_path: str, ds: str) -> str:
    import geopandas as gpd
    gdf = gpd.read_file(validated_path).to_crs("EPSG:4326")
    out = Path(f"/data/export/{ds}/labels.parquet")
    out.parent.mkdir(parents=True, exist_ok=True)
    gdf.to_parquet(out, index=False, compression="zstd")
    return str(out)

@task
def version(export_path: str, ds: str) -> str | None:
    """Hash the export; push a new dataset version only when the content moved."""
    import hashlib
    import subprocess

    digest = hashlib.sha256(Path(export_path).read_bytes()).hexdigest()
    marker = Path("/data/manifests/last_digest.txt")
    if marker.exists() and marker.read_text().strip() == digest:
        print(f"{ds}: content unchanged ({digest[:12]}) — no new version")
        return None
    subprocess.run(["dvc", "add", export_path], check=True)
    subprocess.run(["dvc", "push"], check=True)
    marker.parent.mkdir(parents=True, exist_ok=True)
    marker.write_text(digest + "\n")
    return digest

A nightly DAG that tags a version every night produces 365 versions a year and no way to find the ones that mattered. The digest comparison is what keeps the history readable.

Step 4 — Wire It Up

python
@dag(
    dag_id="annotation_harvest",
    schedule="0 2 * * *",                 # 02:00, after the review shift closes
    start_date=datetime(2026, 1, 1),
    catchup=False,                        # turn on deliberately for a backfill
    max_active_runs=1,                    # the export path is per interval, not per run
    default_args={"owner": "annotation-platform", "retries": 0},
    tags=["annotation", "geospatial"],
)
def annotation_harvest():
    raw = harvest()
    ok = validate(raw)
    art = export(ok)
    version(art)

annotation_harvest()

max_active_runs=1 matters more than it looks. Two concurrent runs of different intervals write different paths and are safe; two concurrent runs of the same interval — which a manual trigger during a scheduled run produces — race on one file.

What a backfill actually rewrites Five daily intervals are backfilled. Each run writes only the paths carrying its own date, so the five runs never touch each other's outputs. When the export task of the third interval fails and is cleared, only that interval's export and version tasks re-run. Nothing about the other four changes, and the versioning step produces at most one new version across the whole backfill. 2026-07-01 07-02 07-03 07-04 07-05 harvest validate export version ran once, wrote its own interval's path failed, cleared, re-ran — and touched nothing else a task that wrote to a path without the interval in it would have made this grid one shared mutable file

Pipeline Parameters & Configuration Reference

Setting Value Why
schedule after the review shift closes Harvesting mid-shift captures work in progress
catchup False by default A new DAG with catchup=True immediately backfills to start_date
max_active_runs 1 Two runs of one interval race on the same path
Harvest retries 3, exponential backoff Rate limits and gateway errors are transient
Export/version retries 0 These are local and deterministic; a failure is a bug, not a blip
execution_timeout 30 min on harvest A hung API call should fail the run, not hold the slot until morning
Pool for platform tasks dedicated, size 2 Keeps a backfill from opening fifty concurrent connections to the platform
depends_on_past False Each interval is independent by construction — if it is not, the paths are wrong

Edge Cases & Gotchas

A backfill that hammers the annotation platform. Fifty parallel interval runs each paginating the API is indistinguishable from an attack. Put the platform tasks in a pool of two, so a backfill is slow rather than disruptive.

Timezones on the interval bounds. Airflow’s intervals are timezone-aware; annotation platforms frequently return naive local timestamps. Comparing the two silently drops or duplicates an hour twice a year. Normalise to UTC at the boundary and store UTC in the harvested file.

Reviewers who reopen an approved annotation. A feature approved on Monday and corrected on Wednesday appears in two intervals. That is correct and the versioning layer handles it — the later export supersedes the earlier — but only if the export writes whole batches rather than appending.

Secrets in the DAG file. The platform token belongs in a connection or a secrets backend, not in the DAG. DAG files are parsed constantly and their contents end up in logs and in the UI’s code view.

A validation failure that blocks the schedule forever. If today’s harvest fails validation and the DAG has depends_on_past=True, every subsequent night fails too. Keep intervals independent and let the alert, not the scheduler, get the problem fixed.

Integration & Automation Hooks

Triggering training. The version task returns a digest or None. Feed that into a downstream DAG trigger so retraining starts only on a real change — the same condition triggering retraining from new annotations with DVC applies from the DVC side.

Sharing the validators with CI. Import the same annotation_gates package the pull-request gate uses. Two copies of the rules is the reliable way to get a batch that passes CI and fails the DAG.

Reporting to the annotation team. A short Slack or email summary — features harvested, validation errors, whether a version was created — closes the loop for the people whose work the DAG is consuming. Silence on success and noise on failure trains everyone to ignore it; a one-line daily summary does not.

Validation & Testing

DAGs deserve tests that do not need a scheduler.

python
from airflow.models import DagBag

def test_dag_imports_without_errors() -> None:
    bag = DagBag(include_examples=False)
    assert bag.import_errors == {}, bag.import_errors

def test_paths_are_interval_scoped() -> None:
    """Every artifact path must contain the run's date, or reruns overwrite the wrong thing."""
    import inspect
    from dags.annotation_harvest import harvest, validate, export
    for fn in (harvest, validate, export):
        src = inspect.getsource(fn.function)
        assert "{ds}" in src, f"{fn.function.__name__} writes a path with no interval in it"

def test_client_error_is_not_retried(monkeypatch) -> None:
    """A 422 must raise immediately rather than falling into the retry policy."""
    import httpx, pytest
    from dags.annotation_harvest import harvest

    class Rejecting(httpx.Client):
        def get(self, *a, **k):
            return httpx.Response(422, text="bad filter", request=httpx.Request("GET", "http://x"))

    monkeypatch.setattr(httpx, "Client", Rejecting)
    with pytest.raises(RuntimeError, match="platform rejected"):
        harvest.function(datetime(2026, 7, 1), datetime(2026, 7, 2), "2026-07-01")

The second test is the one worth keeping. It feeds the DAG’s own source to an assertion that would reject the most damaging mistake in this whole topic — an artifact path with no interval in it — rather than trusting that nobody will ever write one.

Frequently Asked Questions

# Does this work with Prefect or Dagster instead?

The mechanics translate directly: a closed data interval, interval-scoped output paths, transport-only retries and a content-hashed versioning step are properties of the pipeline, not of Airflow. Dagster’s asset model expresses the artifact keying more naturally; Prefect’s flow-run parameters need the interval passed explicitly. The failure this topic is about — a rerun overwriting the wrong partition — is available in all three.

# Where should the tile-serving and pre-labelling steps sit?

Pre-labelling belongs in its own DAG upstream of the annotation queue, because it runs when new imagery arrives rather than when annotations are completed. Tile serving is a service, not a task — see serving imagery tiles to annotation tools — and should never be started or stopped by a DAG.

# How do I handle a platform that only supports “changed since last poll”?

Wrap it. Keep a small table of (interval, cursor_before, cursor_after), have the harvest task advance the cursor and record which interval consumed which range, and serve backfills from that table. It is more code than a date filter and it is the only way to make an incremental endpoint repeatable.

# What belongs in the DAG versus in DVC?

Scheduling, credentials, retries, alerting and the platform API in Airflow; the data dependency graph and the cache in DVC. When the export step needs to know whether the tiling stage is stale, that is a DVC question, and the Airflow task should simply call dvc repro and let it decide — the pattern implementing DVC for geospatial training data sets out.

The Property Worth Protecting

Everything in this topic is downstream of a single property: a task reads only from its own data interval and writes only to a path derived from it. It is worth restating because it is the property that erodes first. A quick fix that writes to a shared “latest” path, a convenience symlink, an export step that appends rather than replaces — each is individually reasonable and each removes the guarantee that makes reruns free and backfills correct. The test in the validation section exists because that erosion is invisible in review and expensive to discover.

Orchestration is the connective tissue of the broader Labeling Workflows & Toolchain Integration pipeline, turning a sequence of scripts into something that runs unattended.