Deduplicating Polled Inventory Deltas
Polling re-reads the whole availability snapshot every cycle, but most of that snapshot is identical to last time — a property might change two dates an hour while you poll five hundred rows a minute. If every polled row becomes a downstream write, you burn OTA quota re-pushing prices nobody changed and drown the reconciliation logs in noise. The fix is to emit only what actually changed: fingerprint each row, diff the current snapshot against the last-seen one, and forward only the cells that moved. This guide implements that content-hash deduplication, the efficiency layer in front of async polling for inventory updates that turns a full snapshot into a minimal change set.
The core idea is a per-row SHA-256 fingerprint over the fields that define state. An unchanged row hashes to the same value it did last cycle, so a set difference against the previous snapshot’s fingerprints yields exactly the changed rows — and an unchanged poll cycle yields an empty set and zero writes.
Prerequisites and environment
- Python 3.11+ — for
hashliband modern type hints. polars1.x — the diff is a vectorized join over the full snapshot; a per-row Python loop over hundreds of thousands of rows is the bottleneck this avoids.redis5.x (or any key-value store) — the last-seen fingerprint per triplet is persisted between cycles so a restart does not re-emit the entire snapshot as “changed”.structlog24.x — the change count per cycle is logged so a suspiciously large or zero delta is visible.- A stable polled snapshot — the source must return the full current state per cycle; content-hash dedup assumes you can see every row, not just a vendor-provided delta feed.
The fingerprint must cover exactly the fields that constitute state — available_units, min_stay, stop_sell, and any restriction that a downstream write would carry — and must exclude volatile metadata like fetched_at, or every row looks changed every cycle.
Step-by-step implementation
Step 1 — Fingerprint each row over its state fields only
Reduce every polled row to a stable SHA-256 hash of the fields that define its state. Serialize those fields in a fixed order so the hash is reproducible, and exclude any volatile metadata.
import hashlib
STATE_FIELDS = ("available_units", "min_stay", "stop_sell", "closed_to_arrival")
def fingerprint(row: dict) -> str:
# Fixed field order → reproducible hash. Volatile fields (fetched_at) excluded
# so an unchanged row hashes identically across cycles.
basis = "|".join(str(row[f]) for f in STATE_FIELDS)
return hashlib.sha256(basis.encode()).hexdigest()
def triplet_key(row: dict) -> str:
return f"{row['property_id']}:{row['room_type_code']}:{row['rate_plan_code']}:{row['stay_date']}"
Hashing only STATE_FIELDS in a fixed order is the load-bearing choice: include fetched_at or reorder the fields and every row’s fingerprint changes every cycle, which defeats the dedup entirely and re-emits the whole snapshot as spurious deltas.
Step 2 — Diff the current snapshot against last-seen with Polars
Build the current snapshot as a Polars frame with a fingerprint column, join it against the last-seen fingerprints keyed by triplet, and keep only rows where the hash differs (or the triplet is new). This is one vectorized pass over the whole snapshot.
import polars as pl
def compute_deltas(current: pl.DataFrame, last_seen: pl.DataFrame) -> pl.DataFrame:
# current : columns [triplet, fingerprint, ...state...]
# last_seen: columns [triplet, fingerprint]
joined = current.join(last_seen, on="triplet", how="left", suffix="_prev")
# A row is a real delta when the hash changed OR the triplet is brand new (null prev).
return joined.filter(
(pl.col("fingerprint") != pl.col("fingerprint_prev"))
| pl.col("fingerprint_prev").is_null()
).drop("fingerprint_prev")
Using a left join with a null check means a triplet appearing for the first time (no previous fingerprint) is correctly treated as a change and emitted, rather than being silently dropped because it has nothing to compare against.
Step 3 — Persist the new fingerprints atomically after emit
Once the deltas are handed downstream, update the last-seen store to the current fingerprints so the next cycle diffs against this one. Do it after a successful emit, not before, or a crash mid-cycle would advance the baseline past deltas that were never forwarded.
import redis.asyncio as redis
import structlog
log = structlog.get_logger()
async def persist_fingerprints(r: redis.Redis, property_id: str,
current: pl.DataFrame) -> None:
mapping = dict(zip(current["triplet"].to_list(),
current["fingerprint"].to_list()))
# HSET the whole property's fingerprints in one round trip.
await r.hset(f"fp:{property_id}", mapping=mapping)
log.info("fingerprints.persisted", property_id=property_id, rows=len(mapping))
Persisting after the downstream emit (not before) preserves at-least-once semantics for the change set: if the process dies between diff and persist, the next cycle re-detects the same deltas rather than losing them, and the idempotent downstream apply absorbs the duplicate.
Step 4 — Short-circuit an entirely unchanged cycle
The common case is that nothing changed. Detect an empty delta set and skip all downstream work, logging the no-op so an operator can see the poller is alive but idle rather than stalled.
async def run_cycle(r: redis.Redis, property_id: str,
current: pl.DataFrame, last_seen: pl.DataFrame) -> int:
deltas = compute_deltas(current, last_seen)
if deltas.height == 0:
log.info("poll.no_change", property_id=property_id) # alive, nothing to do
return 0
for row in deltas.iter_rows(named=True):
await forward_delta(row) # to the stream / apply path
await persist_fingerprints(r, property_id, current)
log.info("poll.deltas_emitted", property_id=property_id, count=deltas.height)
return deltas.height
Emitting a poll.no_change event on an empty cycle distinguishes a healthy idle poller from a hung one — without it, “no downstream writes” is ambiguous between “nothing changed” and “the poller died,” and only one of those should page someone.
Gotchas and production notes
- Exclude timezone-volatile and derived fields from the fingerprint. A
fetched_attimestamp, a server-sideupdated_at, or a rate re-expressed in a different currency will differ every cycle and make every row look changed. Fingerprint only the canonical state fields, and normalize gross-versus-net before hashing so a net rate and its gross equivalent do not fingerprint differently. - A cold last-seen store re-emits everything once. On first run or after a Redis flush, every triplet is “new” and the whole snapshot is emitted. That is correct but expensive; pace the first cycle through the OTA rate-limit budget so a cold start does not trip a
429storm, and treat a full re-emit after a warm period as an alert. - Deletions need explicit handling. A triplet present last cycle but absent now (a rate plan withdrawn) will not appear in the current snapshot, so a naive diff misses it. Take the set difference of last-seen triplets minus current triplets to emit explicit stop-sell or removal deltas, or a withdrawn plan stays live on the channel forever.
- Fingerprint collisions are a non-issue, truncation is not. Full SHA-256 makes accidental collisions negligible, but if you truncate the hash to save space you reintroduce collision risk — a changed row that happens to share a truncated prefix is silently dropped. Store the full digest.
Verification snippet
Confirm the two behaviours that define correct dedup — an unchanged snapshot emits nothing, and a single changed cell emits exactly one delta.
import polars as pl
def _snap(units_twin: int) -> pl.DataFrame:
rows = [
{"triplet": "LON-STJ-01:DLX_KING:BAR:2026-08-14", "available_units": 5,
"min_stay": 1, "stop_sell": False, "closed_to_arrival": False},
{"triplet": "LON-STJ-01:STD_TWIN:BAR:2026-08-14", "available_units": units_twin,
"min_stay": 1, "stop_sell": units_twin == 0, "closed_to_arrival": False},
]
return pl.DataFrame(rows).with_columns(
pl.struct(["available_units", "min_stay", "stop_sell", "closed_to_arrival"])
.map_elements(lambda s: fingerprint(dict(s)), return_dtype=pl.Utf8)
.alias("fingerprint"))
def test_unchanged_snapshot_emits_nothing():
snap = _snap(3)
last_seen = snap.select("triplet", "fingerprint")
assert compute_deltas(snap, last_seen).height == 0 # identical → no deltas
def test_single_change_emits_one_delta():
prev = _snap(3).select("triplet", "fingerprint")
now = _snap(0) # STD_TWIN 3 → 0 (stop-sell)
deltas = compute_deltas(now, prev)
assert deltas.height == 1
assert deltas["triplet"][0].startswith("LON-STJ-01:STD_TWIN")
test_unchanged_snapshot_emits_nothing()
test_single_change_emits_one_delta()
The zero-delta assertion is the one that pays for the whole approach: it proves an identical poll cycle produces no downstream writes at all, so quota and reconciliation noise scale with the rate of change, not the size of the snapshot. The single-delta case confirms a real change — a stop-sell — is still caught and emitted alone.
Related
- Async Polling for Inventory Updates — the parent workflow: the polling client whose snapshots this dedup layer trims.
- Implementing Redis Streams for Inventory Polling — buffering the emitted deltas for durable, at-least-once fan-out.
- Webhook vs Polling Trade-offs — why dedup makes polling cheap enough to serve as the reconciliation floor.
- Handling OTA API Rate Limits — pacing a cold-start full re-emit so it does not exhaust the quota budget.
- Batch Reconciliation Workflows — the nightly diff that catches drift below what per-cycle dedup surfaces.
← Back to Async Polling for Inventory Updates