Caching Topology Snapshots for Degraded-Mode Operation
The moment a utility most needs its network model is the moment the model is most likely to be unreachable: a storm has taken out connectivity to the data centre, the enterprise geodatabase is saturated, or the trace service is queueing behind a bulk job. A degraded mode that returns a cached answer is therefore not a nicety — it is the difference between a dispatcher with an approximate isolation set and a dispatcher with nothing. What makes it safe is not the cache; it is the label. An answer that says “computed from a graph four hours old and device states from nine minutes ago” can be judged; the same answer unlabelled is indistinguishable from a live one and will be acted on as though it were. This guide builds that cache and that label, extending the tiered degradation model in fallback routing logic in legacy systems.
Environment Prerequisites
- Python 3.11 with
networkx>=3.0for the cached graph, and a local store — an embedded database or a file cache — that survives a process restart. - A scheduled export of the network graph from a reconciled snapshot, following the isolation pattern in version versus snapshot isolation.
- A device-state feed that can be cached independently of the graph, because it changes on a different timescale.
- Per-layer freshness policy: the maximum age at which each cached layer may be used, and for which class of question.
- A caller contract that carries the age label through to whoever reads the answer, including the user interface.
- Timeouts on the live path short enough that failover happens before a dispatcher gives up.
Schema-Aware Validation Protocol — Run Before Relying on the Cache
- Confirm each layer records its own as-of time. A cache whose layers share a single timestamp cannot express the case that matters: a fresh graph with a stale device overlay.
- Check the cache survives a restart. A cache held only in memory disappears exactly when the process is restarted, which during an incident is likely.
- Verify the graph export succeeded recently. A failed nightly export leaves yesterday’s graph in place with no signal, so the export’s own health is part of the cache’s validity.
- Test the failover path deliberately. Failover that has never been exercised does not work; include it in the regular test cycle rather than discovering it during an event.
- Confirm the labels reach the user. A response that carries an age the interface discards provides none of the safety the design depends on.
Minimal Reproducible Implementation
from __future__ import annotations
import logging
import pickle
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
import networkx as nx
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
LOG = logging.getLogger("degraded-cache")
MAX_AGE = {
"graph": timedelta(hours=12),
"device_state": timedelta(minutes=30),
"reference": timedelta(days=7),
}
@dataclass
class CachedLayer:
name: str
payload: object
as_of: datetime
@property
def age(self) -> timedelta:
return datetime.now(timezone.utc) - self.as_of
@property
def usable(self) -> bool:
return self.age <= MAX_AGE.get(self.name, timedelta(hours=1))
@dataclass
class DegradedAnswer:
result: object
layer_ages: dict[str, float] = field(default_factory=dict) # minutes
safe_to_act: bool = False
caveat: str = ""
class TopologyCache:
"""A restart-surviving cache of the layers a degraded answer is built from."""
def __init__(self, directory: str) -> None:
self.dir = Path(directory)
self.dir.mkdir(parents=True, exist_ok=True)
def put(self, layer: CachedLayer) -> None:
with (self.dir / f"{layer.name}.pkl").open("wb") as fh:
pickle.dump(layer, fh)
LOG.info("cached %s as of %s", layer.name, layer.as_of.isoformat())
def get(self, name: str) -> CachedLayer | None:
path = self.dir / f"{name}.pkl"
if not path.exists():
return None
with path.open("rb") as fh:
return pickle.load(fh)
def isolation(self, origin: str) -> DegradedAnswer:
"""Answer an isolation question from cache, labelled with what it was built from."""
graph_layer = self.get("graph")
state_layer = self.get("device_state")
if graph_layer is None:
return DegradedAnswer(result=None, caveat="no cached graph available")
graph: nx.DiGraph = graph_layer.payload
states: dict[str, str] = state_layer.payload if state_layer else {}
reachable, frontier = set(), [origin]
while frontier:
node = frontier.pop()
if node in reachable:
continue
reachable.add(node)
if states.get(node, "OPEN") == "CLOSED":
continue # a barrier stops the walk
frontier.extend(graph.successors(node))
ages = {"graph": graph_layer.age.total_seconds() / 60}
if state_layer:
ages["device_state"] = state_layer.age.total_seconds() / 60
state_ok = bool(state_layer and state_layer.usable)
caveat = ("device state is stale — plan only, do not switch"
if not state_ok else "")
return DegradedAnswer(result=sorted(reachable), layer_ages=ages,
safe_to_act=state_ok and graph_layer.usable, caveat=caveat)
safe_to_act is deliberately conservative and deliberately separate from the result. The answer
is still returned when the device overlay is stale, because knowing the approximate extent is
useful; what changes is that the caller is told, in a field it cannot ignore, that this one must
not drive a switching order.
Production Deployment Pattern
- Refresh each layer on its own schedule. The graph after each topology validation, device state every few minutes, reference data nightly. One schedule for all three wastes bandwidth and leaves the layer that matters stale.
- Fail over on a short timeout, not on an error. A live service that is slow is, during an incident, the same as one that is down.
- Surface the age in the interface. Not in a log, not in a header — where the dispatcher looks. The label is the whole safety mechanism.
- Exercise degraded mode on a schedule. A monthly drill that runs the dispatch console against the cache finds the defects that an incident would otherwise find for you.
- Cache per site, not centrally. A cache that lives in the same data centre as the service it backs up shares its failure modes.
- Log every degraded answer served, with the layer ages and whether it was acted on. That log is what justifies the investment and what shows where the freshness policy is wrong.
Conclusion
Degraded mode is a labelling problem as much as a caching one. Splitting the cache into layers with their own refresh cadences lets the fast-moving one be judged separately from the slow ones; returning the age of every layer with the answer lets a caller decide what it may be used for; and refusing to mark an answer safe to act on when the device overlay is stale keeps the one dangerous use off the table. Built that way, a degraded answer is a useful, honest approximation rather than a live answer with a hidden expiry date.
Related
- Up to the parent topic: Fallback Routing Logic in Legacy Systems
- Up to the section: Core Utility GIS Fundamentals & Network Models
- Implementing Fallback Routing When Primary Topology Fails
- Version vs Snapshot Isolation for Batch Jobs
- Syncing SCADA Barriers to Valve State in Real Time
For authoritative reference, consult the NetworkX documentation and the Python pickle module.