Automating CI/CD Sync for Spatial Enrichment

Continuous geocoding pipelines drift. Providers update their reference data, your normalization rules change, a dependency bump alters parsing, and match rates quietly slip a point at a time until a downstream routing engine starts sending drivers to the wrong block. This guide, part of Accuracy Validation & CI/CD Sync, treats the geocoding and normalization pipeline as versioned software: reference data and config are committed artifacts, every merge is gated by an accuracy-regression check that geocodes a fixed sample and compares it to a stored baseline, and the enriched spatial table is kept current by idempotent incremental jobs that swap in new data without exposing a half-built state.

The goal is a pipeline where an accuracy regression fails a build the same way a failing unit test does — loudly, before it reaches production — and where the enriched dataset stays synchronized with source addresses on a schedule you can reason about.


CI/CD sync pipeline for spatial enrichment A pull request runs a CI accuracy-regression check that geocodes a fixed sample and compares match-rate and precision to a stored baseline; a regression fails the build. On merge, a scheduled incremental refresh selects only stale or changed rows, geocodes them, builds a shadow enrichment table, and swaps it atomically into the serving table. Metrics flow from every stage to an observability sink. Pull Request code + config Accuracy Gate (CI) geocode fixed sample compare vs baseline match-rate · precision exit non-zero on drop baseline.json (artifact) versioned · reviewed pass Incremental Refresh select stale / changed watermark + hash CDC geocode changed rows idempotent · resumable Shadow Table built · validated Serving Table blue-green swap atomic rename Observability match-rate · precision · refresh lag · cost GATE ON MERGE SYNC AFTER MERGE

Prerequisites

Step 1 — Version reference data and config as artifacts

Everything that influences a geocoding result must be pinned. That means provider endpoints and API versions, the per-region weight tables from dynamic provider selection, normalization dictionaries, precision thresholds, and the accuracy baseline itself. Commit small config to the repo; store larger reference datasets in an object store addressed by a content hash that the repo records. The rule is simple: given a commit SHA, you can reconstruct the exact configuration that produced any result.

The baseline is the most important artifact. It records, for your reference sample, the match rate and the precision breakdown that the current code achieves. A CI run compares fresh results to this file. Because the baseline defines “acceptable,” it must be reviewed like code — never regenerated silently by a failing job.

Step 2 — Build a CI accuracy-regression check

The check loads the baseline, geocodes the fixed sample, computes match rate and precision, and exits non-zero if any metric falls below its threshold. Keep it dependency-light and deterministic so it runs the same way on a laptop and in CI.

from __future__ import annotations

import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional

# A geocode function: address -> (lat, lon, precision) or None on no-match.
GeocodeFn = Callable[[str], Optional[tuple]]

_PRECISION_RANK = {"rooftop": 3, "range_interpolation": 2, "centroid": 1, "postal": 0}


@dataclass
class Sample:
    """One ground-truth reference record."""

    address: str
    expected_precision: str


@dataclass
class Report:
    """Aggregate metrics for a regression run."""

    n: int
    match_rate: float
    precision_ok_rate: float

    def as_dict(self) -> dict:
        return {
            "n": self.n,
            "match_rate": round(self.match_rate, 4),
            "precision_ok_rate": round(self.precision_ok_rate, 4),
        }


def evaluate(samples: list[Sample], geocode: GeocodeFn) -> Report:
    """Geocode every sample and aggregate match-rate and precision-OK rate.

    match_rate is the fraction that returned any coordinate. precision_ok_rate
    is the fraction whose returned precision met the expected precision tier.
    """
    matched = 0
    precision_ok = 0
    for s in samples:
        result = geocode(s.address)
        if result is None:
            continue
        matched += 1
        _, _, precision = result
        if _PRECISION_RANK.get(precision, 0) >= _PRECISION_RANK.get(s.expected_precision, 0):
            precision_ok += 1
    n = len(samples)
    return Report(
        n=n,
        match_rate=matched / n if n else 0.0,
        precision_ok_rate=precision_ok / n if n else 0.0,
    )


def check_regression(
    samples: list[Sample],
    geocode: GeocodeFn,
    baseline_path: Path,
    match_tolerance: float = 0.01,
    precision_tolerance: float = 0.01,
) -> int:
    """Compare a fresh run to the stored baseline. Return an exit code.

    A drop beyond the tolerance in either metric fails the build (return 1).
    Tolerances absorb provider-side non-determinism without masking real drift.
    """
    baseline = json.loads(baseline_path.read_text())
    report = evaluate(samples, geocode)

    failures: list[str] = []
    if report.match_rate < baseline["match_rate"] - match_tolerance:
        failures.append(
            f"match_rate {report.match_rate:.4f} below baseline "
            f"{baseline['match_rate']:.4f} (tol {match_tolerance})"
        )
    if report.precision_ok_rate < baseline["precision_ok_rate"] - precision_tolerance:
        failures.append(
            f"precision_ok_rate {report.precision_ok_rate:.4f} below baseline "
            f"{baseline['precision_ok_rate']:.4f} (tol {precision_tolerance})"
        )

    print(json.dumps({"current": report.as_dict(), "baseline": baseline}, indent=2))
    if failures:
        for f in failures:
            print(f"REGRESSION: {f}", file=sys.stderr)
        return 1
    print("OK: no accuracy regression detected")
    return 0


if __name__ == "__main__":
    # Wiring is illustrative; load_samples and build_geocoder are project-specific.
    from my_pipeline import build_geocoder, load_samples  # type: ignore

    code = check_regression(
        samples=load_samples(Path("fixtures/reference_sample.jsonl")),
        geocode=build_geocoder(),
        baseline_path=Path("fixtures/baseline.json"),
    )
    sys.exit(code)

Vectorized pandas variant

For larger samples, evaluate the whole set as a DataFrame and reduce to the same two metrics. This runs the geocoder once per row but keeps aggregation vectorized and easy to slice by stratum.

import pandas as pd

_PRECISION_RANK = {"rooftop": 3, "range_interpolation": 2, "centroid": 1, "postal": 0}


def evaluate_frame(df: pd.DataFrame, geocode) -> dict:
    """Evaluate a DataFrame of samples and return match-rate and precision-OK rate.

    df must have columns 'address' and 'expected_precision'.
    """
    results = df["address"].map(geocode)  # -> Series of (lat, lon, precision) | None
    df = df.assign(
        matched=results.notna(),
        got_precision=results.map(lambda r: r[2] if r is not None else None),
    )
    df["precision_ok"] = df.apply(
        lambda row: row["matched"]
        and _PRECISION_RANK.get(row["got_precision"], 0)
        >= _PRECISION_RANK.get(row["expected_precision"], 0),
        axis=1,
    )
    n = len(df)
    return {
        "n": int(n),
        "match_rate": round(float(df["matched"].mean()) if n else 0.0, 4),
        "precision_ok_rate": round(float(df["precision_ok"].mean()) if n else 0.0, 4),
    }

Step 3 — Gate merges and deploys on the check

Wire the check so its exit code decides the build. On a pull request, run the fast variant against cached fixtures — the geocoder reads from a recorded response cache instead of hitting live providers, which makes the gate deterministic and fast. On a schedule, run a fuller variant that refreshes fixtures from live providers to catch provider-side drift. A concrete workflow that installs dependencies, restores the geocode cache, runs the check, and uploads a report is covered in the GitHub Actions workflow for geocoding pipelines.

Step 4 — Run idempotent incremental refresh

Once a change merges, the enriched table must catch up without re-geocoding rows that did not change. Select only rows whose source address changed (detected by a content hash) or whose cache TTL expired, bounded by a persisted watermark. The job must be idempotent: running it twice produces the same table, and a crash mid-run leaves no partial commit. The scheduling, watermark, and change-data-capture query are detailed in scheduling incremental geocode refresh with cron. Throughput of the geocoding step itself is governed by optimizing batch geocoding throughput.

Step 5 — Swap the enriched table blue-green

Build the refreshed enrichment in a shadow table, validate it (row counts, coordinate ranges, precision distribution), then swap it into the serving position atomically. In PostGIS a transactional rename works cleanly:

BEGIN;
-- Sanity gate: refuse to swap if the shadow lost more than 0.5% of rows.
DO $$
DECLARE
    live_count  bigint;
    shadow_count bigint;
BEGIN
    SELECT count(*) INTO live_count   FROM geocode_enriched;
    SELECT count(*) INTO shadow_count FROM geocode_enriched_shadow;
    IF shadow_count < live_count * 0.995 THEN
        RAISE EXCEPTION 'shadow row count % below live % - aborting swap',
            shadow_count, live_count;
    END IF;
END $$;

ALTER TABLE geocode_enriched         RENAME TO geocode_enriched_old;
ALTER TABLE geocode_enriched_shadow  RENAME TO geocode_enriched;
ALTER TABLE geocode_enriched_old     RENAME TO geocode_enriched_shadow;
COMMIT;

-- Old data now sits in the shadow table, ready for the next refresh or rollback.
TRUNCATE geocode_enriched_shadow;

Readers always see a complete table. Rollback is a single rename back. If consumers query through a view, repoint the view inside the same transaction instead of renaming tables.

Step 6 — Instrument the sync for observability

Emit the metrics that let you spot drift before consumers do: match rate and precision-OK rate from every gate run, refresh lag (how far behind source the enriched table is), rows refreshed per run, provider error rate, and cost per thousand geocodes. Store gate reports as build artifacts so you can chart the accuracy trend across commits, not just the pass/fail of the latest run.

Gate & Sync Reference

Gate / control Trigger Metric checked Failure action
Fast accuracy gate Pull request Match-rate, precision vs baseline (cached fixtures) Block merge (exit non-zero)
Full accuracy gate Nightly schedule Match-rate, precision vs baseline (live providers) Open ticket / alert; do not block merges
Coordinate-range check Post-geocode, per row lat ∈ [-90,90], lon ∈ [-180,180] Drop row to DLQ, do not commit
Shadow row-count gate Before swap Shadow rows ≥ 99.5% of live Abort swap, keep current table
Refresh-lag alert Continuous Max source updated_at minus enriched geocoded_at Alert if lag exceeds SLA window
Cost ceiling Per refresh run Geocodes issued × unit cost Halt run, alert on budget breach

Edge Cases

Flaky provider during a CI run

A live provider that returns intermittent 5xx errors will make an unpinned gate fail randomly, and a flaky gate is quickly ignored. Keep the pull-request gate on recorded fixtures — a cache of provider responses keyed by the SHA-256 of the normalized address — so it never touches the network. Refresh those fixtures only in the scheduled job, where a transient failure retries or alerts instead of blocking a contributor. This mirrors the cached-fixture pattern used across resilient pipelines in implementing fallback chains for failed lookups.

Partial refresh failure

If the incremental job dies after geocoding half the changed rows, it must not advance the watermark and must not leave a partially populated serving table. Write results to the shadow table, advance the watermark only after a successful swap, and make row writes upserts keyed by record id so a re-run overwrites rather than duplicates. A crash simply means the next run reprocesses the same stale set.

Baseline ratcheting downward

If a job overwrites the baseline whenever the current run is worse, accuracy silently erodes. The baseline moves in one direction only under human review: up when a change improves it, or down with an explicit, documented justification. Store the baseline as a reviewed artifact and diff it in the pull request.

Non-deterministic geocoder in CI

If the same address yields different coordinates across runs — because of provider load balancing or timestamped tie-breaking — the gate flaps. Pin to cached fixtures for the gate, and where the pipeline itself has non-determinism (for example, choosing among equal-weight providers), seed the selection so a given commit is reproducible.

Performance & Scaling

Technique Effect Notes
Cached response fixtures for the gate Sub-minute PR checks Key by SHA-256 of normalized address; refresh nightly
Stratified sample of 300–1,000 Detects 1–2 pt regressions cheaply Stratify by country, type, precision tier
Change-data-capture refresh (hash + watermark) Re-geocodes only changed rows Turns a full re-run into a delta of thousands
Blue-green swap via rename Zero-downtime, atomic cutover Rollback is one rename; readers never see partial state
Parallel geocoding of the delta Linear speedup on the refresh step See batch throughput guidance below
Provider-matrix gate Catches per-provider regressions Run the check per provider in a CI matrix

For the geocoding step inside the refresh, the concurrency and connection-pooling patterns in optimizing batch geocoding throughput apply directly — the CI/CD layer decides what to geocode; the throughput layer decides how fast.

Troubleshooting

The gate passes locally but fails in CI

The two environments are geocoding against different fixtures or config. Confirm the fixture cache and baseline are committed (or restored from the same cache key) and that the pipeline reads config from the pinned artifact, not from an environment default that differs between your laptop and the runner.

Match rate improved but the gate still failed

The gate compares each metric independently. A jump in match rate can coincide with a precision drop — more addresses resolved, but to coarser centroids. Read the printed report: if match_rate rose while precision_ok_rate fell below tolerance, the change traded precision for coverage, which is exactly the regression the gate exists to catch.

The swap succeeded but consumers still read stale data

A connection pool or view is pinned to the old table by name resolved at prepare time, or a materialized view downstream was not refreshed. Repoint views inside the swap transaction, and signal dependent materialized views to refresh after the swap commits.

The incremental job re-geocodes everything each run

The watermark is not being persisted, or the change-hash is computed over a field that changes every run (a timestamp, a formatting artifact). Compute the hash over the normalized, canonical address only, and persist the watermark transactionally with the swap so a crash does not reset it.

Costs spiked after enabling the nightly full gate

The scheduled gate hits live providers, and a large or un-stratified sample multiplies calls. Cap the live sample size, cache its responses for the day, and track spend against a ceiling as described in API quota tracking and cost management.

FAQ

Should the accuracy-regression check run on every commit or only on a schedule?

Run a fast check against cached fixtures on every pull request so contributors get sub-minute feedback, and run a fuller check that hits live providers on a nightly schedule. Live-provider checks are slower and flaky, so they belong on a schedule where a transient failure does not block a merge. Reserve the pull-request gate for deterministic, cached-fixture comparisons that fail only on a genuine code or config regression.

How large should the CI accuracy sample be?

A stratified sample of 300 to 1,000 addresses is usually enough to detect a one to two percentage-point match-rate regression with acceptable variance, while staying under a minute of wall-clock time when fixtures are cached. Stratify by country, address type, and precision tier so the sample exercises the same distribution as production traffic rather than an easy subset.

What is the safest way to update the stored baseline?

Treat the baseline as a reviewed artifact: regenerate it in a dedicated commit, attach the run that produced it, and require a human to approve the diff. Never let the CI job overwrite the baseline automatically on failure, because that silently ratchets accuracy downward. Bump the baseline only when a metric improves or when a deliberate, explained trade-off lowers it.

How do I keep the incremental refresh from re-geocoding everything?

Select only rows whose source address hash changed since the last run or whose cache TTL expired, using a persisted watermark timestamp. A change-data-capture predicate on updated_at plus a content hash lets you skip untouched rows entirely, so a nightly refresh touches thousands of rows instead of millions and stays within provider quota.

Why use a blue-green table swap instead of updating rows in place?

In-place updates expose consumers to a half-refreshed dataset and hold write locks on hot rows. Building the refreshed enrichment in a shadow table and swapping it atomically with a rename or a view repoint gives readers a consistent snapshot at all times and makes rollback a single rename back to the previous table.