Fallback Routing Logic
Modern agricultural automation runs across connectivity that is anything but reliable: cellular dead zones behind a tree line, RF interference from irrigation pivots, and multi-second satellite latency during a storm all routinely stall the primary telemetry link. When an upstream path degrades, routing logic must transition deterministically to a secondary path — and then to durable local storage — without losing a single spray event, misordering a sequence, or breaking an audit trail. This subsystem sits inside the Agricultural Automation System Architecture & Compliance framework, which mandates state-preserving failover that keeps field-level operational context intact through network loss and replays it cleanly on reconnect.
Problem Framing: Why Silent Path Loss Is Expensive
The sub-problem this subsystem owns is narrow but unforgiving: given a telemetry payload and a set of egress paths of varying quality, choose a path deterministically, prove delivery or persist locally, and record exactly what happened — every time, under partial failure. The failure mode is not a crash. A dropped connection that silently discards a payload produces no error the operator sees in the cab; the record simply never arrives, and the gap surfaces months later during an inspection.
The cost is asymmetric. A lost restricted-use pesticide application record is not a missing data point — it is a compliance gap that can invalidate a certification or a crop-insurance claim, because the operator can no longer prove the application stayed inside its label rate and re-entry interval. Worse, naive retry logic that fires a payload at two paths concurrently can produce a duplicate application record, which reads as an over-application during audit. Deterministic fallback routing exists to make both classes of error structurally impossible: exactly-once semantics on the happy path, guaranteed local durability when every network path is down, and an immutable per-payload audit log that reconstructs the full routing history of any event.
Prerequisites and Dependencies
The router is a coordination layer. It assumes payloads are already typed and validated, and that concrete transport clients and a local store are injected into it; it does not parse raw hardware frames or open sockets itself.
- Normalized, validated telemetry. Payloads must already conform to the identifiers and physical-range constraints defined by the Field Schema Design specification — a stable
device_id, a canonical field-zone reference, and typed metrics. Ingestion and parsing of raw ISOBUS or MQTT frames is owned upstream by the Equipment Telemetry Parsing service in the Farm Data Ingestion & Field Boundary Synchronization framework. - A compliance outcome to route. High-priority payloads carry the compliance state produced by the EPA/USDA Rule Mapping engine. A
NON_COMPLIANTor degraded outcome changes routing priority — those events must never be dropped in favor of routine telemetry. - Transport clients and a durable local store. Two async transport clients (primary and secondary — e.g. cellular MQTT and an Iridium short-burst gateway) plus a local persistence layer with fsync-backed durability (SQLite in WAL mode or an on-disk append log). The store must survive a hard power cycle of the field gateway.
- Python runtime and libraries. The engine targets Python 3.10+ so it can use
match/casefor outcome routing. It relies only on the standard library:asynciofor non-blocking timeout enforcement (see the official asyncio task reference), pluslogging,dataclasses,enum, anddatetime.
| Dependency | Minimum version | Role in the subsystem |
|---|---|---|
python |
3.10 | match/case directive routing, structural typing |
asyncio (stdlib) |
— | Non-blocking wait_for timeout enforcement per path |
logging (stdlib) |
— | Structured, one-object-per-line audit emission |
| Local store (SQLite WAL / append log) | — | fsync-durable buffering that survives power loss |
Architecture of This Subsystem
The router is a chain-of-responsibility evaluated inside a single async task. Each link is a gate: a payload either exits successfully at that gate or falls through to the next, and every transition writes an audit entry onto the payload itself before control moves on. Because the audit log travels with the payload, a record that is eventually buffered locally still carries the full history of the paths it tried and why each failed.
Four gates compose the chain:
- Schema and skew gate — rejects structurally invalid payloads or ones with clock skew beyond tolerance, quarantining them so a bad frame never consumes scarce satellite bandwidth.
- Primary egress — the low-latency path, with a strict short timeout. Most traffic exits here.
- Secondary egress — the resilient path, with a relaxed timeout, tried only after the primary fails.
- Local buffer — durable persistence for deferred sync, the terminal guarantee that no payload is ever lost when every network path is down.
Integration points with sibling subsystems:
- Inputs: validated
TelemetryPayloadobjects with an optional compliance flag set, plus injected primary/secondary transport clients and a local store. - Outputs: a single
RouteStatusper payload, an immutable per-payload audit log, and — when buffered — a deferred-sync obligation replayed in chronological order on reconnect. - Cross-references: the agronomic sibling Fallback Application Chains applies the same conservative-default philosophy to decision fallback, where this subsystem handles transport fallback; polling cadence and reconnect detection are governed by the Async Polling Strategies service.
Step-by-Step Implementation
The dispatch runs in four deterministic stages. Each stage either terminates the chain with a RouteStatus or records a failover and advances.
- Validate structure and clock skew. Reject empty or malformed payloads and any timestamp drifting more than the skew tolerance into the future. A failure here quarantines the payload — it is never retried against the network.
- Attempt the primary path under a strict timeout. Wrap the send in
asyncio.wait_forso a stalled link cannot pin the task. On success, return immediately; on timeout or connection error, audit and fall through. - Attempt the secondary path under a relaxed timeout. The resilient path tolerates higher latency. On success, return; on failure, audit and fall through.
- Persist to the local buffer. Durably store the payload for deferred sync. Only a hard storage failure escapes as a
RoutingError— a fail-loud terminal condition, because at that point data really is at risk.
The parameters that tune this behavior:
| Name | Type | Default | Purpose |
|---|---|---|---|
primary_timeout_s |
float |
3.0 |
Latency ceiling for the primary path before failover |
secondary_timeout_s |
float |
5.0 |
Relaxed ceiling for the resilient secondary path |
clock_skew_tolerance_s |
int |
300 |
Max future drift (seconds) tolerated before quarantine |
primary_client |
transport | injected | Low-latency egress client (e.g. cellular MQTT) |
secondary_client |
transport | injected | Resilient egress client (e.g. satellite short-burst) |
local_store |
store | injected | fsync-durable buffer for deferred sync |
The following module is production-shaped: strict validation, per-path timeouts, an immutable per-payload audit log, match/case directive routing, and a fail-loud terminal condition.
import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("farm_telemetry_router")
class RouteStatus(str, Enum):
PRIMARY = "primary"
SECONDARY = "secondary"
LOCAL_BUFFER = "local_buffer"
QUARANTINED = "quarantined"
@dataclass
class RouterConfig:
primary_timeout_s: float = 3.0
secondary_timeout_s: float = 5.0
clock_skew_tolerance_s: int = 300
@dataclass
class TelemetryPayload:
device_id: str
timestamp: float
metrics: dict[str, Any]
compliance_flags: list[str] = field(default_factory=list)
routing_attempts: int = 0
audit_log: list[dict[str, Any]] = field(default_factory=list)
class RoutingError(Exception):
"""Raised when the fallback chain is exhausted and local persistence fails."""
class FallbackRouter:
def __init__(self, primary_client, secondary_client, local_store,
config: RouterConfig | None = None):
self.primary = primary_client
self.secondary = secondary_client
self.local_store = local_store
self.config = config or RouterConfig()
async def _audit(self, payload: TelemetryPayload, event: str, details: str) -> None:
"""Append an immutable audit entry to the payload and emit a structured log."""
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"device_id": payload.device_id,
"event": event,
"details": details,
"routing_attempts": payload.routing_attempts,
"compliance_flags": payload.compliance_flags,
}
payload.audit_log.append(entry)
logger.info(json.dumps(entry, default=str, sort_keys=True))
def _validate(self, payload: TelemetryPayload) -> bool:
"""Strict structural and clock-skew validation before any network attempt."""
if not payload.device_id or not isinstance(payload.metrics, dict) or not payload.metrics:
return False
# Reject payloads whose timestamp drifts too far into the future (skew guard)
if payload.timestamp > time.time() + self.config.clock_skew_tolerance_s:
return False
return True
async def _try_path(self, client, payload: TelemetryPayload,
timeout: float, label: str) -> bool:
"""Attempt one egress path under a strict timeout. Returns True on confirmed send."""
try:
await asyncio.wait_for(client.send(payload), timeout=timeout)
await self._audit(payload, "ROUTE_SUCCESS", f"{label} egress confirmed")
return True
except asyncio.TimeoutError:
await self._audit(payload, f"{label}_TIMEOUT", f"Latency exceeded ({timeout}s)")
except ConnectionError as exc:
await self._audit(payload, f"{label}_CONN_FAIL", str(exc))
except Exception as exc: # never let an unexpected transport bug drop the payload
await self._audit(payload, f"{label}_UNEXPECTED",
f"{type(exc).__name__}: {exc}")
return False
async def route_payload(self, payload: TelemetryPayload) -> RouteStatus:
await self._audit(payload, "ROUTE_INIT", "Dispatch initiated")
# Gate 1: schema + clock-skew validation
if not self._validate(payload):
await self._audit(payload, "SCHEMA_FAIL",
"Quarantined: invalid structure or clock skew")
return RouteStatus.QUARANTINED
# Gate 2: primary path, strict timeout
if await self._try_path(self.primary, payload,
self.config.primary_timeout_s, "PRIMARY"):
return RouteStatus.PRIMARY
payload.routing_attempts += 1
await self._audit(payload, "FAILOVER_TRIGGER", "Transitioning to secondary path")
# Gate 3: secondary path, relaxed timeout
if await self._try_path(self.secondary, payload,
self.config.secondary_timeout_s, "SECONDARY"):
return RouteStatus.SECONDARY
payload.routing_attempts += 1
await self._audit(payload, "FAILOVER_TRIGGER", "All upstream failed; persisting locally")
# Gate 4: durable local buffer (terminal guarantee)
try:
self.local_store.persist(payload)
await self._audit(payload, "LOCAL_PERSIST", "Payload buffered for deferred sync")
return RouteStatus.LOCAL_BUFFER
except Exception as exc:
await self._audit(payload, "LOCAL_FAIL", f"Critical storage failure: {exc}")
raise RoutingError("Fallback chain exhausted; data at risk") from exc
def actuator_directive(status: RouteStatus) -> str:
"""Map a routing outcome to a downstream tracking directive using match/case."""
match status:
case RouteStatus.PRIMARY | RouteStatus.SECONDARY:
return "mark_delivered"
case RouteStatus.LOCAL_BUFFER:
return "mark_offline_sync_pending"
case RouteStatus.QUARANTINED:
return "route_to_dead_letter"
On the happy path the router emits one self-describing JSON audit line per transition — an init, then a success:
{"compliance_flags": [], "details": "Dispatch initiated", "device_id": "pivot-07", "event": "ROUTE_INIT", "routing_attempts": 0}
{"compliance_flags": [], "details": "PRIMARY egress confirmed", "device_id": "pivot-07", "event": "ROUTE_SUCCESS", "routing_attempts": 0}
A degraded field gateway that fails the primary path but recovers on the secondary produces a timeout, a failover, then a success:
{"details": "Latency exceeded (3.0s)", "device_id": "pivot-07", "event": "PRIMARY_TIMEOUT", "routing_attempts": 0}
{"details": "Transitioning to secondary path", "device_id": "pivot-07", "event": "FAILOVER_TRIGGER", "routing_attempts": 1}
{"details": "SECONDARY egress confirmed", "device_id": "pivot-07", "event": "ROUTE_SUCCESS", "routing_attempts": 1}
Edge Cases and Known Failure Modes
Most production incidents in this subsystem trace back to a handful of recurring conditions. Each has a deterministic remediation.
| Condition | Symptom | Fix |
|---|---|---|
| Both paths slow but not dead | Every payload waits the full 3s + 5s before buffering, backing up the queue | Tune primary_timeout_s down to the p99 of the healthy link; do not raise it to “help” a bad link |
| Clock skew on an edge RTC module | Bursts of SCHEMA_FAIL quarantine near midnight or after a cold boot |
Sync gateway time via NTP on boot; keep clock_skew_tolerance_s at 300, never widen it to mask drift |
Transport client raises a non-ConnectionError |
Payload silently lost if the exception is uncaught | The broad except Exception in _try_path audits and falls through — never narrow it to only ConnectionError |
| Local store fails (full disk / read-only mount) | RoutingError raised, dispatch task stops |
Fail loud by design; alert on-call, free disk, and let the caller re-enqueue — do not swallow this error |
| Duplicate delivery after a false timeout | Primary actually delivered but timed out; secondary re-sends → duplicate record | Make client.send idempotent using the payload’s device_id + timestamp as a delivery key at the sink |
| Buffered payloads replayed out of order | Deferred sync reorders events, corrupting the sequence downstream | Replay the local buffer strictly by timestamp ascending on reconnect (see Compliance section) |
Compliance and Audit Integration
Every routing decision is recoverable because the audit log lives on the payload and is append-only in spirit — the router only ever appends entries, it never rewrites or removes them. When a payload finally reaches a sink (immediately, or after deferred sync), its complete routing history travels with it and is written into the immutable ledger owned by the compliance layer. An inspector can therefore reconstruct not just that a spray event was recorded, but the exact path it took, how many attempts it cost, and whether it spent time buffered offline.
The buffered path is where compliance integrity is most at risk, so two guarantees are non-negotiable. First, ordering: deferred payloads are replayed strictly in ascending timestamp order on reconnect, so the downstream tracking layer reconstructs the true field sequence rather than the order the network happened to recover. Second, state honesty: when the router buffers, the actuator_directive maps the outcome to mark_offline_sync_pending rather than mark_delivered, so the tracking layer flags a device as offline-sync-pending, not offline-failed — a distinction that matters for EPA 40 CFR recordkeeping and USDA NRCS reporting cycles, where an apparent data gap can trigger penalties. Downstream, the Security & Access Boundaries subsystem governs who may read those audit trails and replay buffered events, and Python’s logging documentation covers the structured-handler setup that keeps every audit line machine-ingestible.
Verification
Confirm correct operation in staging with a reproducible injected-fault scenario before any routing change reaches production. Stub the primary client to always time out and the secondary to succeed, then assert the exact expected status and audit shape.
class _StubTimeout:
async def send(self, payload):
await asyncio.sleep(10) # forces the wait_for timeout
class _StubOk:
async def send(self, payload):
return None
class _StubStore:
def __init__(self):
self.buffered = []
def persist(self, payload):
self.buffered.append(payload)
def test_primary_timeout_falls_over_to_secondary():
router = FallbackRouter(_StubTimeout(), _StubOk(), _StubStore(),
RouterConfig(primary_timeout_s=0.1, secondary_timeout_s=0.1))
payload = TelemetryPayload(device_id="pivot-07", timestamp=time.time(),
metrics={"soil_moisture_pct": 22.4})
status = asyncio.run(router.route_payload(payload))
assert status is RouteStatus.SECONDARY
assert actuator_directive(status) == "mark_delivered"
events = [e["event"] for e in payload.audit_log]
assert "PRIMARY_TIMEOUT" in events
assert events[-1] == "ROUTE_SUCCESS"
The run must emit a PRIMARY_TIMEOUT, a FAILOVER_TRIGGER, and a final ROUTE_SUCCESS — and no LOCAL_PERSIST. Then swap the secondary for a second _StubTimeout and confirm the status becomes LOCAL_BUFFER, the directive becomes mark_offline_sync_pending, and exactly one payload lands in the stub store. A staging run that quarantines a valid payload, drops a payload without buffering it, or reorders the buffered replay is a release blocker.
Frequently Asked Questions
Why not send to the primary and secondary paths concurrently for speed?
Concurrent fan-out risks duplicate delivery: if both paths succeed, the sink records the application twice, which reads as an over-application during an audit. The chain is deliberately sequential so each payload has exactly one confirmed egress. If latency is the concern, tune primary_timeout_s down to the healthy link’s p99 rather than parallelizing.
What happens to a payload when every network path is down?
It is persisted to the fsync-durable local buffer and the router returns LOCAL_BUFFER, never a silent drop. The payload carries its full audit history into storage and is replayed in strict timestamp order on reconnect. Only a hard local-storage failure escalates — as a fail-loud RoutingError — so on-call can intervene before data is genuinely at risk.
How does the router avoid corrupting the event sequence during deferred sync?
Buffered payloads are replayed in ascending timestamp order, not in the order the network recovered, and each carries an idempotency key (device_id + timestamp) so a re-send after a false timeout collapses to a single record at the sink. The downstream tracking layer therefore reconstructs the true field sequence regardless of how connectivity came back.
Related
- Fallback Application Chains — the agronomic-decision counterpart to this transport-level failover
- EPA/USDA Rule Mapping — produces the compliance outcomes this router prioritizes
- Field Schema Design — the validated data contract every routed payload must satisfy
- Security & Access Boundaries — governs who may read audit trails and replay buffered events
- Async Polling Strategies — cadence and reconnect detection that drive deferred sync
- Equipment Telemetry Parsing — upstream ingestion that produces the payloads routed here
Up: Agricultural Automation System Architecture & Compliance