Building a zoning change notification pipeline in Python
You wire a change-detection job to a webhook, ship it, and for a week it is perfect. Then on the first Sunday of the month Maricopa County re-publishes its entire zoning layer as part of a routine GIS refresh, your detector faithfully reports forty thousand “changes,” and every subscriber wakes up to hundreds of identical pages for parcels they have watched unchanged for years. A quieter version of the same bug is worse: an analyst never receives an alert for a genuine downzoning because a retry fired twice, the second delivery was deduplicated, and somewhere a race dropped the first. This guide builds the notifier that avoids both — one that watches change events and alerts only the right subscribers, exactly once, and stays silent when a county re-publishes a layer wholesale. It is the hands-on companion to the zoning change alerting topic and sits inside the broader Spatial Impact Analysis & Zoning Change Detection framework.
Diagnosis: tracing the duplicate storm and the silent miss jump to heading
Before writing a line of the fix, reproduce both failure modes and read their signatures, because they demand opposite remedies. A duplicate storm and a missed alert can both look like “the alerting is broken,” but one is over-delivery and the other is under-delivery.
The duplicate storm has a clear fingerprint in the logs — the same parcel and subscriber pair, delivered many times in a tight window, all carrying an identical old-to-new zoning transition:
delivered watcher=portfolio-7 parcel=301-44-118 R1->R1 sent_at=1720915200
delivered watcher=portfolio-7 parcel=301-44-118 R1->R1 sent_at=1720915201
delivered watcher=portfolio-7 parcel=301-44-118 R1->R1 sent_at=1720915203
Two tells here: R1->R1 is not a change at all (the classifier should have ranked it INFO and the detector arguably should not have emitted it), and the three sends are milliseconds apart. That pattern is a re-published layer flowing straight through with no deduplication and no reprocess suppression.
The silent miss is harder to see because its evidence is an absence. You confirm it by reconciling: take a change event you know occurred and grep the delivery ledger for its idempotency key. When the miss is a race, you find the key claimed and then an error, with no successful send:
claim key=alert:9f2c… ok=True
deliver watcher=study-area-3 parcel=118-06-042 attempt=0 error=ClientConnectorError
deliver watcher=study-area-3 parcel=118-06-042 attempt=1 error=ClientConnectorError
dead-letter watcher=study-area-3 parcel=118-06-042 reason=retries_exhausted
That is actually the correct behaviour — the event was dead-lettered, not lost. The genuine bug is the version where the key is claimed, delivery fails, and the key is never released or dead-lettered, so a retry of the whole batch finds the key already present and skips it forever. The pipeline below closes that gap by dead-lettering on exhaustion and never silently abandoning a claimed key.
Step-by-step implementation jump to heading
Each step owns one concern. Assemble them into the runner in the final step.
Step 1 — Build the subscription index jump to heading
A subscriber is an area of interest plus a delivery target and a minimum severity. Reproject every watcher polygon to the working CRS once, then build an STRtree so matching is a logarithmic-time envelope query rather than a linear scan. Keep the exact intersects test after the query, because the tree only filters by bounding box.
from dataclasses import dataclass
from shapely import STRtree
from shapely.geometry.base import BaseGeometry
@dataclass(frozen=True)
class Subscriber:
watcher_id: str
aoi: BaseGeometry # already reprojected to the working CRS
channel: str # "webhook" | "queue"
target: str
min_severity: int = 2 # NOTICE and above
class SubscriberIndex:
def __init__(self, subscribers):
self._subs = list(subscribers)
# STRtree is static; rebuild it when the registry changes, not per event
self._tree = STRtree([s.aoi for s in self._subs])
def watchers_for(self, parcel_geom: BaseGeometry):
if parcel_geom.is_empty:
return []
candidates = self._tree.query(parcel_geom) # indices of bbox overlaps
return [self._subs[i] for i in candidates
if self._subs[i].aoi.intersects(parcel_geom)]
Step 2 — Match the changed parcels and rank severity jump to heading
Turn each incoming change event into (subscriber, event, severity) triples. Rank by land-use impact and parcel area, not by whatever label the feed attached, and drop the pair immediately when the severity falls below the subscriber’s threshold so low-value noise never reaches the delivery stage.
from shapely.geometry import shape
def severity_of(old_code: str, new_code: str, parcel_geom) -> int:
if old_code == new_code:
return 1 # INFO: not a real land-use change
intensive = {"C3", "M1", "M2", "I1", "I2"}
residential = {"R1", "R2", "R3"}
upzoned = old_code in residential and new_code in intensive
try:
large = parcel_geom.area > 5_000.0
except Exception:
large = False
if upzoned and large:
return 4 # CRITICAL
return 3 if upzoned else 2 # MATERIAL else NOTICE
def match_events(index: SubscriberIndex, events):
for ev in events:
geom = shape(ev["geometry"])
sev = severity_of(ev["old_zoning"], ev["new_zoning"], geom)
for sub in index.watchers_for(geom):
if sev >= sub.min_severity:
yield sub, ev, sev
Step 3 — Dedupe with an idempotency key and TTL jump to heading
Compute a deterministic key over only the fields that give the alert meaning, and claim it atomically. The reprocess flag routes bulk re-publishes to prime, which records the key so a later genuine change still dedupes but sends nothing now. This is the single gate that prevents the duplicate storm.
import hashlib
def idem_key(watcher_id: str, ev: dict) -> str:
parts = "|".join([watcher_id, ev["parcel_id"], ev["jurisdiction"],
ev["old_zoning"], ev["new_zoning"], ev["effective_date"]])
return "alert:" + hashlib.sha256(parts.encode()).hexdigest()
class Deduper:
def __init__(self, redis, ttl=7 * 24 * 3600):
self._redis, self._ttl = redis, ttl
async def is_new(self, key: str) -> bool:
# SET NX EX is atomic: the loser of a race gets False and stops
return bool(await self._redis.set(key, "1", nx=True, ex=self._ttl))
async def prime(self, key: str) -> None:
# record the key WITHOUT signalling a send (suppression path)
await self._redis.set(key, "1", nx=True, ex=self._ttl)
Step 4 — Deliver with retry and a dead-letter fallback jump to heading
Delivery is at-least-once. Retry transient failures with capped exponential backoff and jitter; on exhaustion, write the payload and reason to a dead-letter store rather than dropping it. Bounding concurrency with a semaphore keeps a metro-wide change from opening thousands of sockets at once.
import asyncio, random, logging
import aiohttp
logger = logging.getLogger("notifier")
MAX_RETRIES, BASE_DELAY = 4, 0.5
async def deliver(session, sub, body, dead_letter):
for attempt in range(MAX_RETRIES):
try:
if sub.channel == "webhook":
async with session.post(sub.target, json=body, timeout=15) as r:
r.raise_for_status()
elif sub.channel == "queue":
await session.app_queue.put(sub.target, body) # durable enqueue
else:
raise ValueError(f"unknown channel {sub.channel}")
return True
except ValueError as exc: # misconfig: not retryable
await dead_letter(sub, body, str(exc))
return False
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
if attempt < MAX_RETRIES - 1:
await asyncio.sleep(BASE_DELAY * 2 ** attempt + random.uniform(0, 0.4))
continue
logger.error("dead-letter %s: %s", sub.watcher_id, exc)
await dead_letter(sub, body, str(exc))
return False
return False
Step 5 — Record an audit trail on every send jump to heading
Persist one immutable row per outcome — delivered, deduplicated, suppressed, or dead-lettered — carrying the idempotency key and the lineage_id that ties the alert back to its source feed. This is what makes a subscriber’s alert history reconstructable, and it is the same provenance discipline described under data lineage and provenance tracking.
import time, json
async def audit(db, *, key, watcher_id, parcel_id, outcome, lineage_id, detail=""):
await db.execute(
"INSERT INTO alert_audit "
"(idem_key, watcher_id, parcel_id, outcome, lineage_id, detail, ts) "
"VALUES ($1,$2,$3,$4,$5,$6,$7)",
key, watcher_id, parcel_id, outcome, lineage_id, detail, time.time(),
)
Now assemble the runner. It threads matching, deduplication, delivery, and audit into a single pass, honouring the suppression gate first so a reprocess batch is absorbed silently.
async def run_notifier(events, index, deduper, db, session):
stats = {"sent": 0, "dup": 0, "suppressed": 0, "dead": 0}
sem = asyncio.Semaphore(16)
async def dead_letter(sub, body, reason):
await audit(db, key=body["idem_key"], watcher_id=sub.watcher_id,
parcel_id=body["parcel_id"], outcome="dead_letter",
lineage_id=body["lineage_id"], detail=reason)
stats["dead"] += 1
async def handle(sub, ev, sev):
key = idem_key(sub.watcher_id, ev)
if ev.get("is_reprocess"):
await deduper.prime(key)
stats["suppressed"] += 1
return
if not await deduper.is_new(key):
await audit(db, key=key, watcher_id=sub.watcher_id,
parcel_id=ev["parcel_id"], outcome="duplicate",
lineage_id=ev["lineage_id"])
stats["dup"] += 1
return
body = {"idem_key": key, "parcel_id": ev["parcel_id"],
"lineage_id": ev["lineage_id"], "severity": sev,
"from": ev["old_zoning"], "to": ev["new_zoning"],
"sent_at": time.time()}
async with sem:
ok = await deliver(session, sub, body, dead_letter)
if ok:
await audit(db, key=key, watcher_id=sub.watcher_id,
parcel_id=ev["parcel_id"], outcome="sent",
lineage_id=ev["lineage_id"])
stats["sent"] += 1
await asyncio.gather(*(handle(sub, ev, sev)
for sub, ev, sev in match_events(index, events)),
return_exceptions=True)
logger.info("notifier run: %s", stats)
return stats
Verification and testing jump to heading
Prove the pipeline fixed both failure modes rather than trading one for the other.
- Replay the storm. Feed the same batch twice. The first pass sends N alerts; the second must send zero and count N duplicates. If the second pass sends anything, the idempotency key is including a volatile field — check that
sent_atis not inidem_key. - Assert exactly-once under a race. Dispatch the same event from two concurrent workers against the real dedupe store. Exactly one
is_newreturns True; the audit ledger must contain onesentrow for that key, never two. This is whatSET NX EXbuys you. - Confirm suppression primes without sending. Run a batch with
is_reprocess=True, assert zero sends, then run the same events without the flag and assert they are now deduplicated — proving the reprocess pass seeded the store rather than merely discarding events. - Check the miss cannot hide. Point a subscriber’s webhook at an endpoint that always fails, run one material event, and assert it appears in the dead-letter store with a
retries_exhaustedreason and a matching audit row. A missing dead-letter row means an event can vanish. - Spatial correctness. Use an L-shaped AOI and a parcel that sits in the bounding box notch but outside the polygon; the matcher must not select that subscriber. This confirms the exact
intersectsruns after the STRtree query.
Failure recovery jump to heading
When something breaks mid-run, the pipeline must stay reproducible and lossless.
- Drain and replay the dead-letter store. Replay re-enters
run_notifierthrough the same path, so a replayed event still deduplicates against anything delivered while the endpoint was down — you cannot double-send by replaying. Alert on dead-letter depth so a persistently broken subscriber is noticed, not silently accumulating. - Recover from a mid-batch crash. Because each send is gated by an atomic claim and audited on success, restarting the batch re-attempts only events whose key was never successfully claimed-and-sent; already-delivered events short-circuit at the gate. Persist a batch checkpoint so a restart resumes rather than re-scans the whole day.
- Handle a poisoned event. An event with invalid geometry that crashes matching should be quarantined and skipped, not allowed to abort the batch — the same dead-letter discipline used across error handling and retry logic in the ingestion layer applies here to the notifier.
- Reconcile against source. If a subscriber reports a missed change, join the audit ledger on
lineage_idback to the detection run and the source feed; every alert’s fate — sent, duplicate, suppressed, dead-lettered — is recorded, so “did we ever process this?” always has an answer.
Frequently asked questions jump to heading
Why does re-running the batch send duplicates when I already use SET NX?
Almost always because a volatile field leaked into the idempotency key. If sent_at, a request id, or a fetch timestamp is part of the hashed input, every re-emission produces a new key and nothing deduplicates. Hash only the fields that define the alert’s meaning — watcher, parcel, jurisdiction, old and new zoning, and effective date — and add volatile fields to the delivered body only.
How do I keep a full county re-publish from paging everyone?
Have the ingestion layer flag reprocess batches, and route those events to prime instead of is_new. Priming records the idempotency key so a later genuine change still deduplicates, but it sends nothing during the bulk re-crawl. Pair it with a rate-based circuit breaker that trips when a jurisdiction’s inbound event count spikes far above baseline.
Should I claim the idempotency key before or after delivering?
Before. Claim atomically with SET NX EX, then deliver. Claiming after delivery opens a race where two workers both send. The trade-off is that a delivery which then fails must be dead-lettered, never silently abandoned, or the claimed key would block all future retries of that event. The runner here dead-letters on exhaustion for exactly that reason.
What TTL should the idempotency key use?
Long enough to cover the longest interval over which a source might re-publish the same layer — a week is a reasonable default for monthly-or-faster municipal refreshes. Set it too short and a re-publish outside the window re-alerts; too long and a genuinely re-decided parcel inside the window is swallowed. Including the effective date in the key protects against the latter, so you can keep the TTL generous.
Related jump to heading
- Parent topic: Zoning Change Alerting
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Error Handling & Retry Logic — the retry and dead-letter patterns the delivery stage reuses
- Data Lineage & Provenance Tracking — the lineage handle stamped on every audit row