Scheduling Satellite-Driven MRV Runs with Sensor Triggers
A satellite MRV pipeline scheduled on a calendar spends most of its runs discovering that nothing new arrived, and occasionally discovers that something arrived and was missed. Neither outcome is expensive on its own; together they push teams toward running more often, which multiplies the wasted runs without closing the gap, because the underlying problem is that the pipeline’s clock and the satellite’s are unrelated. This guide covers replacing the clock with the data, within orchestrating MRV data pipelines in the pipeline orchestration and compliance reference stack.
The complication that makes satellite triggering different from ordinary event-driven work is that arrival is not a single event. A scene appears in a catalogue, then appears again reprocessed under a new baseline, then appears a third time when the provider reissues the collection. Meanwhile the pipeline’s unit of work is usually a tile or a project area rather than a scene, and a tile is ready only when several scenes covering it have all arrived. A trigger design that fires per scene either runs the same tile repeatedly or runs it before its inputs are complete.
Root Cause Analysis
Three properties of satellite data delivery drive the design, and each one breaks a naive trigger.
Arrival is asynchronous and repeated. A Sentinel-2 tile is typically available a few hours after acquisition, but the interval varies with ground station scheduling and processing load, and the same tile reappears when the processing baseline changes. A trigger that fires on first appearance and never again misses every reprocessing; one that fires on every appearance reprocesses the whole archive after a collection-wide reissue. The resolution is to trigger on a change in the tile’s content identity — its processing baseline plus its checksum — rather than on its appearance.
The unit of work is not the unit of arrival. A project area is covered by several scenes, possibly from several sensors, and a monthly composite needs all of them. Triggering per scene means either running the composite repeatedly as scenes trickle in or running it once and getting whichever scenes happened to have arrived. The fix is a readiness predicate: a work unit becomes eligible when a stated condition over its inputs is satisfied, and the trigger evaluates that predicate rather than counting arrivals.
Waiting forever is not an option, and neither is not waiting. Some scenes never arrive — a downlink failure, a sensor outage, an acquisition that was simply not scheduled. A readiness predicate demanding all expected inputs blocks indefinitely; one demanding none produces partial results. Every readiness condition therefore needs a deadline and a defined behaviour at that deadline, and the defined behaviour must be visible in the output rather than silently applied.
The common failure this produces is the pipeline that appears to work for months and then quietly stops producing for one tile, because a scene that never arrived left its readiness predicate permanently unsatisfied and nothing was watching for a work unit that had been waiting too long.
Diagnostic Pipeline / Pre-Flight Validation
The sensing layer polls a catalogue and decides what is new. Getting that decision right — new versus already seen versus changed — is most of the work, and it depends on recording identity rather than timestamps.
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
import structlog
log = structlog.get_logger()
@dataclass(frozen=True)
class SceneIdentity:
"""What makes two catalogue entries the same scene or different ones.
Deliberately excludes the catalogue's own updated timestamp, which
changes for reasons unrelated to content — a metadata correction, a
reindex — and would otherwise trigger a reprocess of everything.
"""
collection: str
scene_id: str
processing_baseline: str
checksum: str
def key(self) -> str:
return f"{self.collection}/{self.scene_id}"
def content_id(self) -> str:
return f"{self.key()}@{self.processing_baseline}:{self.checksum[:12]}"
@dataclass(frozen=True)
class Arrival:
identity: SceneIdentity
acquired_on: date
available_at: datetime
tiles_covered: frozenset[str]
@dataclass(frozen=True)
class WorkUnit:
"""A tile-period that the pipeline produces as one output."""
tile_id: str
period_start: date
period_end: date
expected_scenes: int
deadline: datetime
def classify_arrival(
arrival: Arrival, seen: dict[str, str]
) -> str:
"""new | reprocessed | duplicate — the only three outcomes that matter."""
key = arrival.identity.key()
if key not in seen:
return "new"
if seen[key] == arrival.identity.content_id():
return "duplicate"
return "reprocessed"
def readiness(
unit: WorkUnit,
arrivals: list[Arrival],
*,
now: datetime,
min_fraction: float = 0.8,
) -> tuple[bool, str]:
"""Is this work unit eligible to run, and on what basis?
Returns the basis alongside the decision because the basis belongs in
the output. A composite built from 80% of its expected scenes at the
deadline is a legitimate product and a different product from one built
from 100%, and only the recorded basis distinguishes them afterwards.
"""
covering = [
a for a in arrivals
if unit.tile_id in a.tiles_covered
and unit.period_start <= a.acquired_on <= unit.period_end
]
have = len({a.identity.key() for a in covering})
if have >= unit.expected_scenes:
return True, f"complete:{have}/{unit.expected_scenes}"
if now >= unit.deadline:
fraction = have / unit.expected_scenes if unit.expected_scenes else 0.0
if fraction >= min_fraction:
log.warning(
"readiness.deadline_partial",
tile=unit.tile_id, have=have,
expected=unit.expected_scenes, fraction=round(fraction, 2),
)
return True, f"deadline_partial:{have}/{unit.expected_scenes}"
log.error(
"readiness.deadline_insufficient",
tile=unit.tile_id, have=have,
expected=unit.expected_scenes,
note="not run — a composite from under the minimum fraction is "
"not a composite, it is a sample of clear days",
)
return False, f"deadline_insufficient:{have}/{unit.expected_scenes}"
return False, f"waiting:{have}/{unit.expected_scenes}"
def find_stalled_units(
units: list[WorkUnit], arrivals: list[Arrival], *, now: datetime
) -> list[tuple[WorkUnit, str]]:
"""Work units past their deadline that still have not run.
This is the monitor that catches the silent stop. A triggered pipeline
has no natural heartbeat — an absence of runs looks exactly like an
absence of data — so something must actively look for units that should
have fired and did not.
"""
stalled = []
for unit in units:
if now < unit.deadline:
continue
ready, basis = readiness(unit, arrivals, now=now)
if not ready:
stalled.append((unit, basis))
return stalled
The stalled-unit monitor is the piece most often missing. A cron pipeline announces its own health by running; a triggered one is silent by design, and the silence when a tile’s inputs stopped arriving is indistinguishable from the silence of a quiet week.
Deterministic Transformation Logic
A triggered pipeline is harder to reproduce than a scheduled one, because the set of inputs a run consumed depends on what had arrived at the moment it fired. The remedy is to freeze that set into an explicit, stored input manifest at trigger time, and to make the run consume the manifest rather than the catalogue.
import hashlib
import json
from dataclasses import dataclass, asdict
from datetime import datetime
@dataclass(frozen=True)
class RunManifest:
"""The frozen input set for one triggered run.
A run consumes this, never the live catalogue. That single rule is what
makes a triggered pipeline reproducible: rerunning the manifest a year
later gives the same answer even though the catalogue has moved on.
"""
run_id: str
tile_id: str
period_start: str
period_end: str
triggered_at: str
readiness_basis: str
inputs: tuple[str, ...] # content_id per scene, sorted
supersedes: str | None
def fingerprint(self) -> str:
payload = json.dumps(
{
"tile": self.tile_id,
"period": [self.period_start, self.period_end],
"inputs": sorted(self.inputs),
},
sort_keys=True, separators=(",", ":"),
).encode()
return hashlib.sha256(payload).hexdigest()[:16]
def build_manifest(
unit: WorkUnit,
arrivals: list[Arrival],
basis: str,
*,
now: datetime,
prior: RunManifest | None,
) -> RunManifest | None:
"""Freeze the input set, or decline if nothing has changed.
Returning None when the fingerprint matches the prior run is what stops
a reprocessed-scene notification from regenerating an identical output.
The check is on content, not on the notification.
"""
covering = sorted(
{
a.identity.content_id()
for a in arrivals
if unit.tile_id in a.tiles_covered
and unit.period_start <= a.acquired_on <= unit.period_end
}
)
candidate = RunManifest(
run_id=f"{unit.tile_id}-{unit.period_start:%Y%m%d}-{now:%Y%m%dT%H%M%S}",
tile_id=unit.tile_id,
period_start=unit.period_start.isoformat(),
period_end=unit.period_end.isoformat(),
triggered_at=now.isoformat(),
readiness_basis=basis,
inputs=tuple(covering),
supersedes=prior.run_id if prior else None,
)
if prior is not None and candidate.fingerprint() == prior.fingerprint():
log.info(
"trigger.skipped_identical",
tile=unit.tile_id, prior_run=prior.run_id,
fingerprint=candidate.fingerprint(),
)
return None
log.info(
"trigger.fired",
run_id=candidate.run_id, tile=unit.tile_id,
basis=basis, n_inputs=len(covering),
fingerprint=candidate.fingerprint(),
supersedes=candidate.supersedes,
)
return candidate
def debounce(
pending: dict[str, datetime], unit_key: str, *, now: datetime, window_s: int
) -> bool:
"""Collapse a burst of arrivals into one trigger.
A collection reissue delivers hundreds of scenes within minutes. Without
a debounce window each one fires the tiles it touches, and the pipeline
runs the same tile repeatedly on progressively larger input sets, of
which only the last is wanted.
"""
first_seen = pending.get(unit_key)
if first_seen is None:
pending[unit_key] = now
return False
return (now - first_seen).total_seconds() >= window_s
The fingerprint comparison earns its place during collection reissues, which are the single most disruptive event for a triggered pipeline. A provider republishing a year of scenes generates a flood of notifications, and without content-based deduplication the pipeline reprocesses everything — including the tiles whose actual pixel content did not change.
Compliance Gating & Audit Trail Generation
Four records make a triggered pipeline auditable, and the first is the one that makes the rest possible.
The run manifest, stored immutably per run. It states exactly which scene versions produced an output, which converts “why does this month’s composite differ from the one we saw in March?” into a manifest diff.
The readiness basis on every output. An output built at a deadline from 80% of its expected inputs is not the same product as a complete one, and downstream consumers — particularly anything feeding a reported figure — need to be able to filter on it.
The supersession chain. When a reprocessed scene regenerates an output, both the old and new run ids must remain, linked. This is the trigger-side equivalent of the restatement history that any recalculated figure needs.
Stalled-unit alerts and their resolution. A tile that stopped producing and was noticed six weeks later is an incident whose record matters, because the gap it leaves in the time series is a gap a verifier will find.
Production Integration
The practical architecture is a small, boring sensing service and an ordinary orchestrator. The sensor polls the STAC catalogue on a short interval, classifies arrivals, updates the readiness state per work unit, and emits a manifest when a unit becomes ready. The orchestrator — Prefect, Airflow, or Dagster, as compared in Prefect vs Airflow vs Dagster for MRV pipelines — consumes manifests and knows nothing about catalogues. Keeping that boundary sharp is what stops the sensing logic from ending up smeared across every task.
Two notes on operating one. Keep a low-frequency calendar sweep alongside the triggers, not to do work but to look for stalled units and manifests that never completed; it is the heartbeat a triggered pipeline otherwise lacks. And make the trigger path share code with the backfill path, since a manifest-driven run is exactly what building idempotent backfills for carbon pipelines needs — a backfill becomes the act of generating manifests for a past interval rather than a separate mechanism with its own bugs.
Frequently Asked Questions
Is polling a catalogue acceptable, or should notifications be used?
Polling is entirely acceptable and often preferable. A poll every few minutes against a STAC search is cheap, and it is self-healing in a way notifications are not: a missed notification is lost forever, while a missed poll is corrected by the next one. Where a provider offers a notification stream it is worth consuming as a latency optimisation, but the poll should remain as the source of truth rather than being switched off. Systems that rely solely on notifications discover their gaps months later.
How should the expected scene count per work unit be determined?
From the orbital geometry rather than from experience. The set of scenes covering a tile in a period is calculable from the sensor’s tiling grid and revisit cycle, and computing it gives an expectation that is correct from day one and that adapts when a new sensor is added. Deriving it from historical arrival counts bakes in whatever outages the history contained, which sets the expectation too low and makes the readiness predicate permanently satisfiable with incomplete input.
What is a sensible deadline for a work unit?
Long enough to absorb normal delivery variation and short enough that a report is not held hostage to one missing scene — commonly a few days past the end of the period for near-real-time work, and a couple of weeks for monthly products where completeness matters more than latency. The deadline should be a property of the work unit rather than a global setting, because a triage alert and a monitoring composite have genuinely different tolerances.
Should a reprocessed scene always regenerate downstream outputs?
Only when it changes them, which the fingerprint check determines. Reprocessing frequently changes metadata without changing pixel content, and regenerating on every reprocess makes a collection reissue an expensive event for no benefit. Where the content genuinely changed, regeneration should be automatic and should preserve the superseded output rather than overwriting it, since anything already reported was reported against the old version.
How does this interact with a pipeline that also has non-satellite inputs?
The readiness predicate generalises cleanly. A work unit needing satellite scenes, a field data upload, and a current emission factor table becomes ready when all three conditions hold, and the manifest freezes all three. The main practical difference is that non-satellite inputs often have no natural expectation count, so their readiness condition is usually a version or validity check rather than a count — present and valid for the period, rather than N of M arrived.
Does triggering complicate testing?
It simplifies it, once the manifest boundary is in place. A run is a pure function of its manifest, so testing the processing path needs no catalogue, no clock, and no network — just a manifest fixture. The sensing service is then tested separately against recorded catalogue responses. The pipelines that are hard to test are the ones where each task queries the catalogue itself, which is precisely what the boundary exists to prevent.
What happens when the catalogue itself is unavailable?
The sensing service stops advancing readiness state and nothing fires, which is correct — but it must be visible. An outage in the catalogue and a genuinely quiet period look identical from the outside, so the sensing service should track its own last successful poll and alert on staleness independently of whether any work unit became ready. This is the same silence problem as the stalled unit, one level up, and it needs its own monitor for the same reason.
Can two work units for the same tile be in flight at once?
They should not be, and preventing it is worth an explicit lock keyed on the work unit rather than on the run. The situation arises when a reprocessed scene triggers a regeneration while the original run is still executing, and the two then race to write the same output. Whichever finishes last wins, which is not necessarily the one with the more complete input set. Serialising per work unit costs a little throughput and removes an outcome that is both wrong and extremely hard to reproduce afterwards.
Where the throughput cost is genuinely unacceptable — a very large tile grid with slow runs — the alternative is to let both proceed and resolve on write by comparing manifests, publishing whichever has the superset of inputs. That is more code and it fails safe, which the naive race does not.
Related guides
- Orchestrating MRV Data Pipelines — the parent topic and the orchestration patterns this scheduling sits inside.
- Prefect vs Airflow vs Dagster for MRV Pipelines — choosing the orchestrator that consumes these manifests.
- Building Idempotent Backfills for Carbon Pipelines — the same manifest mechanism applied backwards in time.
- Failure Mode Catalog for Distributed Tile Processing — what happens inside a run once a manifest triggers it.