Debouncing Rapid Feature Edits
Key the debounce on the feature identifier, give it a maximum wait so a continuously edited feature still emits, and merge the burst so the event carries the geometry from before the first edit and after the last — keeping the most recent intermediate geometry leaves every tile the feature has left behind serving a stale shape.
This guide sits under Feature Change Triggers, within Core Event Fundamentals & Architecture. It handles the burst that Capturing PostGIS Changes with Logical Replication faithfully produces, one event per vertex drag.
When to use this pattern
- A human editor or a bulk process produces many edits to the same feature within seconds, and each one currently triggers downstream work.
- The downstream work is expensive relative to the edit — a tile rebuild, a spatial index update, a notification — so collapsing ten events into one is a real saving.
- Losing the intermediate states is acceptable to the consumer. If an audit trail needs every edit, debounce in front of the expensive consumer only, and leave the topic intact.
What a burst actually looks like
Complete runnable implementation
import asyncio
import time
from dataclasses import dataclass, field
from shapely.geometry import mapping, shape
from shapely.ops import unary_union
QUIET_SECONDS = 0.3 # emit this long after the last edit
MAX_WAIT_SECONDS = 5.0 # …but never hold an edit longer than this
@dataclass(slots=True)
class Pending:
"""State for one feature's in-flight burst."""
first_seen: float
event: dict
handle: asyncio.TimerHandle | None = None
merged: int = 1
class FeatureDebouncer:
def __init__(self, emit, quiet: float = QUIET_SECONDS,
max_wait: float = MAX_WAIT_SECONDS) -> None:
self._emit = emit
self._quiet = quiet
self._max_wait = max_wait
self._pending: dict[str, Pending] = {}
def submit(self, event: dict) -> None:
"""Accept one change event; emit later, merged."""
loop = asyncio.get_running_loop()
key = event["feature_id"]
now = loop.time()
current = self._pending.get(key)
if current is None:
self._pending[key] = current = Pending(first_seen=now, event=event)
else:
current.event = _merge(current.event, event)
current.merged += 1
if current.handle is not None:
current.handle.cancel()
# The maximum wait is measured from the FIRST edit of the burst, so a
# feature under continuous editing still emits. Without this line a
# four-minute reshaping session produces nothing for four minutes.
deadline = min(now + self._quiet, current.first_seen + self._max_wait)
current.handle = loop.call_at(deadline, self._fire, key)
def _fire(self, key: str) -> None:
pending = self._pending.pop(key, None)
if pending is None:
return
pending.event["merged_edits"] = pending.merged
pending.event["burst_seconds"] = round(
asyncio.get_running_loop().time() - pending.first_seen, 3
)
asyncio.create_task(self._emit(pending.event))
async def drain(self) -> None:
"""Flush every pending timer. MUST run on shutdown.
Without it, the last edit of every in-flight burst is dropped — the
one edit the operator most recently made, and the only one they will
look for.
"""
for key in list(self._pending):
handle = self._pending[key].handle
if handle is not None:
handle.cancel()
self._fire(key)
await asyncio.sleep(0)
def _merge(old: dict, new: dict) -> dict:
"""Combine two change events for the same feature.
The geometry is the newest one; the previous_geometry is the OLDEST,
because the merged event has to describe the whole burst. Taking the
newest previous_geometry describes only the final drag, leaving every
tile the feature occupied at the start of the burst uninvalidated.
"""
merged = dict(new)
merged["previous_geometry"] = old.get("previous_geometry")
merged["occurred_at"] = new["occurred_at"]
# An insert followed by updates is still an insert to anyone downstream.
if old["action"] == "insert":
merged["action"] = "insert"
# …and anything followed by a delete is a delete.
if new["action"] == "delete":
merged["action"] = "delete"
merged["geometry"] = None
return merged
def affected_area(event: dict):
"""Union of where the feature was and where it is — what to invalidate."""
parts = [shape(g) for g in (event.get("previous_geometry"), event.get("geometry")) if g]
return mapping(unary_union(parts)) if parts else None
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
QUIET_SECONDS |
float |
Above the interval between vertex drags (~150–250 ms for a human) | 0.3 |
MAX_WAIT_SECONDS |
float |
Below the freshness objective for the affected tiles | 5.0 |
| Debounce key | str |
The feature id — never the stream or the tile, or unrelated edits collapse | — |
previous_geometry |
GeoJSON | Must be the burst’s first, so the invalidation area is complete | — |
merged_edits |
int |
Emitted for observability; a value of 1 means the debounce did nothing | 1 |
drain() |
coroutine | Must run on shutdown, or the last edit of every burst is lost | — |
Gotchas and spatial edge cases
-
Debouncing on the stream instead of the feature collapses unrelated edits. With one timer for the whole stream, an operator editing a parcel in Hamburg suppresses an unrelated edit in Munich, and the second feature’s event carries the first feature’s geometry. The key must be the feature identifier, and the memory cost of one timer per in-flight feature is the price of correctness.
-
A delete inside a burst wins, and it must null the geometry. An insert-then-delete pair inside the quiet window collapses to a delete of something no consumer ever saw. That is correct, and it means consumers must tolerate a delete for an unknown feature rather than treating it as an error.
-
MAX_WAIT_SECONDShas to sit below the freshness objective. Debouncing deliberately delays events, which spends the freshness budget defined in SLOs & Alerting for Spatial Webhook Pipelines. A five-second maximum against a sixty-second objective is comfortable; a sixty-second maximum against the same objective consumes the entire budget before the pipeline has done any work. -
The debouncer is in-memory state, so it changes the delivery guarantee. A process killed with pending timers loses those bursts unless
drain()runs. If that is unacceptable, keep the pending state in Redis with a TTL and accept the round trip — but do not pretend an in-memory dictionary survives a deploy. -
Union of the before and after geometry is not always the right invalidation area. For a feature that moved a long way, the union’s bounding box covers everything in between, which can be thousands of untouched tiles. Invalidate against the two geometries separately rather than their combined envelope, as Scoping Tile Invalidation to the Zoom Levels That Changed describes.
Verification
import asyncio
import pytest
SQUARE = {"type": "Polygon", "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}
MOVED = {"type": "Polygon", "coordinates": [[[5, 5], [5, 6], [6, 6], [6, 5], [5, 5]]]}
@pytest.mark.asyncio
async def test_burst_collapses_to_one_event():
out = []
d = FeatureDebouncer(emit=lambda e: out.append(e) or asyncio.sleep(0), quiet=0.05)
for i in range(11):
d.submit({"feature_id": "f-1", "action": "update", "occurred_at": str(i),
"geometry": SQUARE, "previous_geometry": SQUARE})
await asyncio.sleep(0.01)
await asyncio.sleep(0.1)
assert len(out) == 1 and out[0]["merged_edits"] == 11
@pytest.mark.asyncio
async def test_max_wait_fires_during_continuous_editing():
"""The failure a plain debounce has: never emitting at all."""
out = []
d = FeatureDebouncer(emit=lambda e: out.append(e) or asyncio.sleep(0),
quiet=0.05, max_wait=0.2)
for _ in range(40): # 0.4 s of unbroken editing
d.submit({"feature_id": "f-1", "action": "update", "occurred_at": "t",
"geometry": SQUARE, "previous_geometry": SQUARE})
await asyncio.sleep(0.01)
assert out, "a continuously edited feature must still emit"
def test_merge_keeps_the_first_previous_geometry():
"""The rule that keeps tiles from being left behind."""
first = {"feature_id": "f-1", "action": "update", "occurred_at": "t1",
"geometry": SQUARE, "previous_geometry": SQUARE}
last = {"feature_id": "f-1", "action": "update", "occurred_at": "t9",
"geometry": MOVED, "previous_geometry": MOVED}
merged = _merge(first, last)
assert merged["previous_geometry"] == SQUARE
assert merged["geometry"] == MOVED
The second test is worth running with a real clock rather than a mocked one. A mocked loop makes the maximum-wait bug invisible, because the reset chain that causes it only appears when the edits genuinely arrive faster than the quiet period.
Related
- Feature Change Triggers — the topic this guide belongs to
- Capturing PostGIS Changes with Logical Replication — the capture stage that produces the burst
- Tile Update Event Pipelines — the expensive consumer the debounce protects
- SLOs & Alerting for Spatial Webhook Pipelines — the freshness budget the maximum wait spends