Orchestrating Seasonal Pipelines with Airflow

A precision-agriculture pipeline does almost nothing for six months and then does everything at once. Planting compresses a season of application data into three weeks; harvest does the same in four; the satellite archive delivers on its own schedule regardless; and a grower who has been silent since March suddenly authorises four hundred fields the week before they need prescriptions. The output of this topic is a scheduling design that survives that shape: DAGs whose cadence follows the source rather than the calendar, tasks isolated per grower, derived products triggered by arrival rather than by the clock, and retries that are safe because the ingestion manifest makes them no-ops. It sits under farm data platform engineering.

The examples use Airflow because it is the most common in this space, but every pattern here translates to Prefect, Dagster or a well-disciplined set of systemd timers. What does not translate is the discipline itself: if a task is not idempotent, no scheduler will save you.

Prerequisites

  • Python 3.11+, apache-airflow 2.9.* with the standard providers, or an equivalent orchestrator
  • A metadata database for the orchestrator, separate from the farm data PostGIS instance
  • Ingestion code that is already idempotent — see the manifest pattern in the section overview
  • Working integrations to schedule: machine data APIs and satellite imagery APIs
  • Per-grower credentials in a secrets backend, not in DAG code or environment variables

1. Concept: Cadence Follows the Source

Three clocks, not one

Ingestion cadence should match how the source behaves. Satellite archives publish on revisit — polling more than daily wastes requests, polling weekly loses the one clear scene in a wet fortnight. Management-system data appears when a display next has connectivity, which during operations means several times a day and out of season means weekly. Gridded weather updates on a fixed schedule and is the only genuinely daily source.

Derived products follow a fourth rule: they should not be scheduled at all. An index raster should be computed when its inputs land, a zone map when the index series is long enough, a prescription when an agronomist asks. Scheduling derived work on the clock guarantees you either recompute unchanged inputs or wait hours for outputs that could have been ready in minutes.

Cadence by source, derivation by arrival Three ingestion DAGs are shown with different cadences: satellite twice daily, machine data four times daily in season, weather daily. All three write to the store and the manifest. A derivation DAG is triggered by data arrival rather than by a schedule, and an export DAG runs on demand when an agronomist requests a prescription. ingest_satellite 2×/day · catchup off · pool: stac ingest_machine_data 4×/day in season · 1×/week out ingest_weather daily · the only true daily source store + manifest publishes a dataset event derive_indices no schedule — triggered by the dataset it consumes minutes, not hours, behind arrival export_prescription on demand · never on a timer Every ingestion task is idempotent, so any run can be retried or re-triggered without producing a second copy.

Idempotency is what makes scheduling boring

A scheduler’s job is to decide when, and it is only safe to be casual about when if running twice costs nothing. With a manifest in place, the awkward questions — did the 06:00 run overlap the 12:00 one, did the retry re-download the scene, did the backfill duplicate March — all have the same answer: it does not matter. Without one, every scheduling decision becomes a correctness decision, which is how teams end up with a single daily cron nobody dares touch.

Per-grower isolation

Generate one task per grower rather than looping inside a task. A revoked token then fails exactly one task instance; retries re-process only that grower; and the failure surface in the UI matches the unit a human would act on. Airflow’s dynamic task mapping expresses this directly:

PYTHON
from airflow.decorators import dag, task
from datetime import datetime, timedelta

@dag(
    dag_id="ingest_machine_data",
    schedule="0 2,8,14,20 * * *",     # four times daily; see the seasonal note below
    start_date=datetime(2026, 1, 1),
    catchup=False,
    max_active_runs=1,
    default_args={"retries": 3, "retry_delay": timedelta(minutes=10),
                  "retry_exponential_backoff": True, "pool": "fmis_api"},
    tags=["ingest", "machine-data"],
)
def ingest_machine_data():

    @task
    def active_growers() -> list[str]:
        """Growers with a valid authorisation, newest connection first."""
        from platform_db import list_active_growers
        growers = list_active_growers()
        assert growers, "no active grower connections — check the token store"
        return growers

    @task(max_active_tis_per_dag=8)
    def sync_grower(grower_id: str) -> dict:
        from platform_sync import sync_one_grower
        return sync_one_grower(grower_id)     # idempotent: manifest-guarded

    @task
    def check_freshness(results: list[dict]) -> None:
        from platform_qa import assert_sources_fresh
        assert_sources_fresh(source="machine_api")

    check_freshness(sync_grower.expand(grower_id=active_growers()))

ingest_machine_data()

max_active_tis_per_dag=8 bounds concurrency at the task level, and the fmis_api pool bounds it across every DAG that touches the same provider — including backfills, which is the point.

2. Step-by-Step Implementation

One schedule, modulated by the operating window A season timeline showing polling cadence by period: weekly polls through the dormant months, four polls a day through planting, application and harvest, and back to weekly for close-out. A single frequent schedule runs year-round and a short-circuit task exits early outside the windows. Dormant weekly polls Jan–Mar Planting 4 polls a day Apr–Jun Application 4 polls a day May–Aug Harvest 4 polls a day Aug–Nov Close-out weekly polls Dec The same DAG runs all year; a short-circuit task decides whether the rest of the run happens Using the run's logical date rather than the wall clock keeps that decision reproducible when someone re-triggers November's DAG in February.

Step 1 — Choose the schedule from the source, and let the season modulate it

A single cron expression cannot express “four times a day in season, weekly outside it”. Two workable approaches: run the frequent schedule year-round and let a short-circuit task exit early outside the operating window, or maintain two DAGs and enable the seasonal one by a variable. The first is simpler and costs a few seconds a day:

PYTHON
from airflow.decorators import task
from datetime import date

SEASON_WINDOWS = {          # (start_month_day, end_month_day) in the operating hemisphere
    "plant":   ((4, 1),  (6, 15)),
    "apply":   ((5, 1),  (8, 31)),
    "harvest": ((8, 15), (11, 30)),
}


def in_any_window(today: date) -> bool:
    md = (today.month, today.day)
    return any(start <= md <= end for start, end in SEASON_WINDOWS.values())


@task.short_circuit
def only_in_season(**context) -> bool:
    """Skip the rest of the run outside the operating windows — cheaply and visibly."""
    today = context["logical_date"].date()
    active = in_any_window(today)
    if not active:
        print(f"{today} falls outside every operating window — skipping")
    return active

Using logical_date rather than date.today() keeps the decision reproducible when a run is re-triggered, which matters when someone re-runs November’s DAG in February to debug it.

Step 2 — Trigger derivation on arrival

Airflow datasets turn “compute the index when the scene lands” into a declaration rather than a sensor loop:

PYTHON
from airflow import Dataset

SCENE_WINDOWS = Dataset("s3://farm-platform/published/s2_windows")

@dag(dag_id="ingest_satellite", schedule="0 5,17 * * *", start_date=datetime(2026, 1, 1),
     catchup=False, tags=["ingest", "satellite"])
def ingest_satellite():
    @task(outlets=[SCENE_WINDOWS], pool="stac_api")
    def fetch_new_scenes() -> int:
        from platform_stac import sync_new_scenes
        return sync_new_scenes()          # returns count published
    fetch_new_scenes()

@dag(dag_id="derive_indices", schedule=[SCENE_WINDOWS], start_date=datetime(2026, 1, 1),
     catchup=False, tags=["derive"])
def derive_indices():
    @task
    def compute_pending_indices() -> int:
        from platform_indices import compute_missing
        return compute_missing()          # driven by the manifest, not by the trigger payload
    compute_pending_indices()

Note that the derivation task recomputes what is missing according to the manifest, not “the thing that just arrived”. The trigger is a hint that work may exist; the manifest is the truth about which work. That indirection is what makes a missed trigger self-healing rather than a permanent gap.

Step 3 — Decide catch-up deliberately

Catch-up is right when each run owns a distinct, reproducible slice of work — a daily weather grid, a monthly aggregate. It is wrong when the run’s scope comes from mutable state such as a high-water mark, because a dozen catch-up runs then all read the same mark and race. For high-water-mark ingestion, set catchup=False and write a separate, explicitly parameterised backfill DAG:

PYTHON
@dag(dag_id="backfill_satellite", schedule=None, start_date=datetime(2026, 1, 1),
     catchup=False, params={"start": "2026-03-01", "end": "2026-06-30", "field_ids": []},
     tags=["backfill"])
def backfill_satellite():
    @task(pool="stac_api", priority_weight=1)     # lower than the daily DAG's default
    def backfill_window(**context) -> int:
        p = context["params"]
        from platform_stac import sync_window
        return sync_window(p["start"], p["end"], p["field_ids"] or None)
    backfill_window()

The lower priority_weight inside a shared pool is what stops a six-month backfill from starving today’s ingestion — the scheduler drains live work first and fills spare slots with history.

Step 4 — Retries that mean something

Retry transport failures and rate limits; do not retry a revoked token or a schema violation. Airflow retries everything by default, so the distinction has to live in the code: raise AirflowFailException for terminal conditions so the task fails immediately rather than burning three retries over half an hour.

PYTHON
from airflow.exceptions import AirflowFailException

def sync_one_grower(grower_id: str) -> dict:
    try:
        return _sync(grower_id)
    except TokenRevoked as exc:
        mark_needs_reauthorisation(grower_id)
        raise AirflowFailException(f"grower {grower_id} must re-authorise") from exc

3. Key Parameters and Tuning

Parameter Type Default Agronomic effect
Ingestion cadence, in season cron 4×/day Prescriptions are built from the latest operation; a daily poll can leave an agronomist working from data 24 hours stale during planting
Ingestion cadence, out of season cron weekly Same code, a twentieth of the requests; the quota saved is what makes a mid-season burst possible
max_active_runs int 1 Above 1, two runs of a high-water-mark DAG race on the same mark; the manifest keeps it correct but the work is wasted
Pool slots per provider int 4–8 Sized below the provider’s concurrency limit; too high converts a backfill into a retry storm that finishes later than a serial run
retries / backoff int 3, exponential Covers a rate-limit window without hammering; more retries hide a systematic failure behind hours of noise
Sensor timeout s 3600 Waiting for a drone upload that never comes should fail before the next scheduled run starts
Freshness alert budget d 3 (machine), 12 (satellite) The real alert; a silent pipeline with no failures is the most common production incident in this layer

4. Edge Cases and Failure Modes

The DAG that succeeds and does nothing. A token expires, the sync returns zero records, every task is green. This is why the freshness assertion runs as a task inside the DAG rather than as an external monitor — a source that has produced nothing for longer than its budget fails the run loudly.

Catch-up is right exactly when the run owns its own scope A decision diagram about backfilling. If each scheduled run owns a distinct, reproducible slice of work then catch-up can replay them safely. If the run's scope comes from a mutable high-water mark, catch-up produces runs that race over the same window, and an explicitly parameterised backfill DAG is the answer instead. A DAG needs history filled in does each run own a distinct slice? yes Enable catch-up each interval is reproducible and idempotent, so the scheduler can simply replay them no Write a parameterised backfill DAG runs scoped by a stored high-water mark all read the same mark and race; give the backfill explicit dates and a lower priority weight in the shared pool

Timezone and daylight saving in cron. A schedule of 0 2 * * * in a local timezone runs twice on one autumn night and not at all on one spring night. Schedule in UTC and convert only for display. Agronomic windows are local, but they are day-scale, so the mismatch is harmless in that direction.

Backfills that outlive their code. A backfill re-running March’s ingestion in September runs today’s code against March’s data. That is usually right for ingestion and usually wrong for derived products, where the algorithm version is part of the output’s identity. Version derived outputs by code version, as in the section overview, so a recomputation lands beside the original rather than silently replacing it.

Dynamic task mapping over an unbounded list. Mapping over four hundred growers creates four hundred task instances per run; mapping over fields creates tens of thousands and the scheduler’s metadata database becomes the bottleneck. Map over the unit a human would retry — the grower — and loop within it.

Sensors that block a worker slot. A sensor waiting six hours in the default mode occupies a worker the whole time. Use deferrable operators or reschedule mode so the slot is released between polls; during harvest those slots are the scarce resource.

Task logs that outgrow the disk. Imagery tasks log per-scene detail, and at four hundred fields a season the logs outgrow the metadata volume before the data does. Ship logs to object storage and set a retention policy on day one.

5. Verification and Output Validation

The pipeline’s own tests are the cheapest place to prove the scheduling assumptions hold.

PYTHON
from datetime import date

def test_season_windows_are_sane():
    assert in_any_window(date(2026, 5, 20)), "May must be in season"
    assert not in_any_window(date(2026, 1, 15)), "January must be out of season"


def test_sync_is_idempotent(tmp_platform):
    first = sync_one_grower("grower-1")
    second = sync_one_grower("grower-1")
    assert second["published"] == 0, (
        f"second sync published {second['published']} artefacts — manifest is not being consulted")
    assert first["published"] > 0, "first sync published nothing — fixture is empty"


def test_terminal_errors_do_not_retry(monkeypatch):
    monkeypatch.setattr("platform_sync._sync", _raise_token_revoked)
    with pytest.raises(AirflowFailException):
        sync_one_grower("grower-2")

The second test is the important one, and it is the test most teams do not have. Idempotency is asserted about once and then assumed forever; a refactor that moves the manifest insert outside the publishing transaction breaks it silently, and the only symptom is a slowly duplicating archive.

In production, verify the same property from the outside: once a week, count manifest rows against distinct published object keys. They must match exactly.

6. Integration with the Broader Pipeline

Orchestration is the layer that makes the rest of this section a system. It drives machine data APIs and satellite imagery APIs on their own cadences, writes through PostGIS schema design for farm data, and triggers the index and zone work that band math and raster algebra and management zone classification implement. Where an individual job is itself parallel — a multi-flight orthomosaic run, for instance — the internal concurrency belongs to the job, as in batch and async orthomosaic processing; the orchestrator’s role is to start it once and know whether it finished.

Frequently Asked Questions

Is Airflow overkill for one farm? Usually, yes. For a single operation, a cron entry calling an idempotent script and a freshness check that emails on staleness covers it. The threshold is roughly when you have more than a handful of sources, need per-grower isolation, or want a backfill you can start without SSH access.

Where should the credentials live? In a secrets backend the scheduler reads at task run time, keyed per grower. Not in Airflow Variables, which are visible to anyone with UI access, and not in the DAG file, which is in version control.

How do I test a DAG without a live provider? Split the DAG file from the work. Every task in the examples above is a thin wrapper around a function in an ordinary Python module, which is where the tests live. The DAG file itself only needs a structural test — that it imports, has no cycles, and every task has the pool and retry settings the policy requires.


This topic is part of Farm Data Platform Engineering: APIs, Storage & Orchestration — see there for the manifest and storage patterns the scheduling above depends on.