Backfilling a Season of Field Imagery Without Duplicates
One-sentence answer: enumerate the backfill as small field-by-month units, subtract what the manifest already holds, run them through a parameterised DAG that shares the provider’s pool at a lower priority weight, and let idempotent tasks make any overlap with live ingestion a no-op.
Context
Backfills happen for good reasons: a new grower brings three seasons of history, a cloud-mask fix means last year’s scenes should be reprocessed, a field was added mid-season and its imagery was never fetched. They also have a reputation for taking down the daily pipeline — a six-month backfill firing thousands of requests will exhaust a provider’s quota, and the live ingestion that shares that quota fails for the rest of the day.
Everything needed to avoid that already exists if the ingestion task is idempotent, as in writing idempotent Airflow tasks for imagery ingestion. What this guide adds is the shape of the run: unit size, ordering, and the two scheduler settings that keep live work in front.
Prerequisites
Beyond the parent topic’s stack: an idempotent ingestion function, a populated ingest_manifest, and an Airflow pool named for the provider with a slot count below the provider’s concurrency limit.
Step-by-step
1. Enumerate in small units. Field-by-month is the right granularity: small enough that losing one costs minutes, large enough that scheduling overhead is negligible.
2. Subtract what exists by querying the manifest before scheduling, so the run size reflects real work.
3. Parameterise the DAG with the date range and an optional field list.
4. Share the pool, lower the weight.
5. Report coverage — a backfill that silently skipped a fortnight is worse than one that failed.
from datetime import date, timedelta
from airflow.decorators import dag, task
from sqlalchemy import create_engine, text
def month_starts(start: date, end: date) -> list[date]:
months, cur = [], start.replace(day=1)
while cur <= end:
months.append(cur)
cur = (cur.replace(day=28) + timedelta(days=4)).replace(day=1)
return months
def outstanding_units(dsn: str, field_ids: list[int], start: date, end: date) -> list[dict]:
"""Field-months with fewer published scenes than the season's expectation."""
engine = create_engine(dsn, future=True)
with engine.connect() as conn:
have = {(r.field_id, r.month): r.n for r in conn.execute(text("""
SELECT field_id, date_trunc('month', acquired_at)::date AS month, count(*) AS n
FROM raster_artifact
WHERE kind = 's2_window' AND acquired_at BETWEEN :s AND :e
AND field_id = ANY(:ids)
GROUP BY 1, 2
"""), {"s": start, "e": end, "ids": field_ids})}
units = []
for fid in field_ids:
for m in month_starts(start, end):
if have.get((fid, m), 0) < 2: # fewer than two usable scenes: worth trying
units.append({"field_id": fid, "month": m.isoformat()})
print(f"{len(units)} outstanding field-month unit(s) of "
f"{len(field_ids) * len(month_starts(start, end))} possible")
return units
@dag(dag_id="backfill_field_imagery", schedule=None, catchup=False,
start_date=date(2026, 1, 1), max_active_runs=1,
params={"start": "2026-04-01", "end": "2026-09-30", "field_ids": []},
tags=["backfill", "satellite"])
def backfill_field_imagery():
@task
def plan(**context) -> list[dict]:
p = context["params"]
ids = p["field_ids"] or all_active_field_ids()
units = outstanding_units(DSN, ids, date.fromisoformat(p["start"]),
date.fromisoformat(p["end"]))
assert units, "nothing outstanding — the backfill would do no work"
return units
@task(pool="stac_api", priority_weight=1, max_active_tis_per_dag=4,
retries=3, retry_exponential_backoff=True)
def ingest_unit(unit: dict) -> dict:
from platform_stac import sync_field_month
return sync_field_month(unit["field_id"], unit["month"]) # manifest-guarded
@task
def report(results: list[dict], **context) -> None:
published = sum(r["published"] for r in results if r)
skipped = sum(1 for r in results if r and r["published"] == 0)
failed = sum(1 for r in results if not r)
print(f"backfill: {published} scene(s) published, {skipped} unit(s) already complete, "
f"{failed} unit(s) failed")
assert failed == 0, f"{failed} unit(s) failed — re-trigger the DAG for those fields"
report(ingest_unit.expand(unit=plan()))
backfill_field_imagery()
Inline verification — prove the backfill overlapping live ingestion changes nothing:
from sqlalchemy import create_engine, text
engine = create_engine(DSN, future=True)
with engine.connect() as conn:
before = conn.execute(text("SELECT count(*) FROM ingest_manifest")).scalar()
sync_field_month(field_id=4471, month="2026-06-01") # the live DAG's unit
sync_field_month(field_id=4471, month="2026-06-01") # the backfill's unit, same ground
with engine.connect() as conn:
after = conn.execute(text("SELECT count(*) FROM ingest_manifest")).scalar()
dupes = conn.execute(text("""
SELECT count(*) FROM (
SELECT source, external_id, checksum FROM ingest_manifest
GROUP BY 1,2,3 HAVING count(*) > 1
) d""")).scalar()
print(f"manifest grew by {after - before} row(s); {dupes} duplicate group(s)")
assert dupes == 0, "duplicate manifest rows — the unique constraint is missing"
Settings worth being deliberate about
| Setting | Default here | Why it matters |
|---|---|---|
| Unit granularity | field × month | Small enough that losing one costs minutes, large enough that 2,400 units is a comfortable run. Field × day would be 73,000 task instances and the scheduler becomes the bottleneck |
priority_weight |
1, below the live DAG | The single setting that keeps today’s ingestion in front of last season’s. Equal weights let the backfill claim slots as they free |
| Pool slots | shared with live ingestion | Two separate pools means the provider sees the sum of both, which is the problem the pool was meant to solve |
max_active_runs |
1 | Two concurrent backfill runs over overlapping ranges do the same work twice; idempotency makes it harmless and it is still waste |
| Completeness threshold | fewer than 2 scenes/month | What counts as an outstanding unit. Requiring a fixed count makes genuinely cloudy months retry forever |
| Retries | 3, exponential | A unit that fails three times has a real problem — a missing asset, a revoked credential — and should surface rather than retry all night |
Record attempted-but-empty units somewhere. Without that record a month with no clear scene looks outstanding to every future backfill, and each run spends its quota rediscovering that the weather was bad.
Gotchas and edge cases
- A backfill runs today’s code against last season’s data. For ingestion that is usually correct. For derived products it is not: the output belongs to the current algorithm version and must be written under that version’s prefix, not over the original. Version derived outputs as described in the section overview.
-
max_active_tis_per_dagbounds this DAG; the pool bounds everything. Both are needed. Without the pool, the backfill’s four concurrent tasks add to the live DAG’s eight and the provider sees twelve. -
Empty field-months are legitimate. A month with no clear scene produces nothing, and the unit will look outstanding forever, so every subsequent backfill retries it. Record attempted-and-empty units in the manifest with a null object key, or keep a separate
no_datatable — otherwise the backfill never converges. -
Enumerating fields at plan time, not task time. Reading the field list inside every task means four hundred identical queries and a plan that can change mid-run. Compute it once and pass it through.
-
Mapping over tens of thousands of units strains the scheduler. Six months × 400 fields is 2,400 units, which is comfortable. A daily granularity would be 73,000, which is not — the metadata database becomes the bottleneck long before the provider does.
-
Report coverage explicitly. The failure that costs the most is a backfill that ran, succeeded, and covered 60% of what was asked, because a filter silently excluded the rest. Print what was planned, what was published and what was already complete, and assert the failure count is zero.
This guide is part of Orchestrating Seasonal Pipelines with Airflow — see there for cadence, catch-up semantics and freshness alerting.
Related
- Writing Idempotent Airflow Tasks for Imagery Ingestion — the guarantee that makes an overlapping backfill harmless
- Paginating and Rate-Limiting Management-System API Pulls — the same quota problem from the request side
- Searching Sentinel-2 Scenes with pystac-client — how each unit finds the scenes it should have