Syncing ArcGIS Online Edits to Local GeoPackage
At 09:14 a wildland branch updates a hazard perimeter in an ArcGIS Online (AGOL) hosted feature service. Forty minutes later a field tablet, offline since dawn, reconnects and pushes its own edit to the same structure, made against a locally cached GeoPackage. When the nightly sync pulls the AGOL delta down onto that tablet’s replica, two records arrive carrying the same globalid and last_edited_date values that overlap by seconds. The naive INSERT OR REPLACE that most field-built sync scripts rely on resolves this non-deterministically: depending on row order, one agency’s authoritative perimeter silently overwrites the other’s, and no one notices until a strike team is committed to geometry that was retired an hour earlier. This is the single failure mode this page solves — reconciling a bounded AGOL-to-GeoPackage delta so that concurrent edits never collide silently, and every resolution leaves an audit trail. It is a concrete instance of the precedence-weighted reconciliation defined in Conflict Resolution in Multi-Agency Edits, narrowed to the specific quirks of the AGOL replication path.
Root cause and operational impact
Three properties of the AGOL-to-GeoPackage path combine to make this dangerous rather than merely annoying:
- Dual GUID authorities. A detached GeoPackage edited in the field mints its own
globalidvalues; the hosted feature service mints its own on the server. Neither knows about the other until merge time, so two physically distinct features can legitimately share oneglobalid. A primary-key-keyed upsert treats them as the same row. - Coarse edit timestamps. AGOL’s
last_edited_datehas second-level resolution and is recorded against server clock skew. During a surge, dozens of edits land inside the same second, solast_edited_datealone cannot order them. Ties resolved by arrival order are effectively random. - Single-writer storage. A GeoPackage is a SQLite database. The default rollback journal takes a process-global write lock, so a second sync worker — or a field app holding the file open — turns the merge into
sqlite3.OperationalError: database is lockedpartway through, leaving a half-applied delta.
In an Incident Command System (ICS) environment the cost is not a stale map tile; it is a positional or status error propagating into resource assignment. A reopened evacuation zone, a duplicated medivac LZ, or a perimeter that snaps back to a retired line all flow from one silent overwrite. The resolution must therefore be deterministic and reversible, and it must record why each record won.
Tiered resolution strategy
Apply these in order. The earlier tiers are definitive fixes; the last is a safe default that never discards data and always flags itself for review.
- Bound the delta, never the whole layer. Query only records edited inside an explicit window (
last_edited_date >= cutoff) so a sync never re-imports the full table and re-litigates already-resolved rows. If the network degrades, back off exponentially and route the pending pull to an offline queue rather than failing the run. - Stage before you touch the live replica. Write the delta into an isolated in-memory SQLite workspace. The live GeoPackage is only opened once the full delta is in hand and validated, so a mid-stream drop can never leave it half-written.
- Resolve key collisions by lineage, not overwrite. When an incoming AGOL
globalidalready exists locally but refers to a different feature, treat the AGOL record as authoritative, re-key the local one with a jurisdiction prefix (LOCAL-…), and store the original inparent_globalidso nothing is lost. - Break timestamp ties with ICS authority. When two edits to the same merge key fall inside the same
last_edited_datesecond, defer to the higher ICSagency_typetier; only fall back to last-writer-wins between equal-authority agencies. - Commit in one transaction, in WAL mode. Apply all reconciled rows inside a single
BEGIN/COMMITwithjournal_mode=WALand abusy_timeout, so a lock contender waits instead of corrupting the merge, and any failure rolls the whole batch back. - Safe default: quarantine, don’t guess. Any record whose geometry fails validation or whose conflict can’t be resolved by rule is written to a quarantine table with an audit flag for supervisor adjudication — never auto-merged and never dropped.
Production Python implementation
The following resolver implements the full path: bounded extraction with backoff, isolated staging, collision and tie reconciliation, a transactional WAL commit, and an immutable audit row per resolution. It assumes the delta arrives already CRS-normalised — axis-order and datum normalisation are owned upstream by Real-Time Geocoding & Location Normalization, and feeding un-normalised coordinates in produces false overlap flags from projection drift.
import time
import json
import sqlite3
import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Callable, Optional
from arcgis.gis import GIS
from arcgis.features import FeatureLayer
from requests.exceptions import RequestException, Timeout, HTTPError
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("agol_gpkg_sync")
# ICS authority tiers: higher wins a same-second last_edited_date tie.
ICS_PRECEDENCE: dict[str, int] = {"FED": 3, "STATE": 2, "LOCAL": 1}
class AGOLToGeoPackageSync:
"""Deterministic, audited AGOL feature-service -> local GeoPackage sync."""
def __init__(
self,
agol_url: str,
gpkg_path: str,
max_retries: int = 3,
backoff_factor: float = 2.0,
) -> None:
self.gis = GIS(agol_url)
self.gpkg_path = gpkg_path
self.max_retries = max_retries
self.backoff_factor = backoff_factor
def _with_backoff(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Optional[Any]:
"""Run a network call with exponential backoff; queue offline on exhaustion."""
for attempt in range(self.max_retries):
try:
return func(*args, **kwargs)
except HTTPError as exc:
status = getattr(exc.response, "status_code", None)
if status != 429: # only retry rate-limit; re-raise hard errors
logger.error("Non-retryable HTTP %s from feature service: %s", status, exc)
raise
wait = self.backoff_factor ** attempt
logger.warning("Throttled (429); backing off %.1fs", wait)
time.sleep(wait)
except (Timeout, RequestException) as exc:
wait = self.backoff_factor ** attempt
logger.warning("Network degradation (attempt %d): %s; waiting %.1fs",
attempt + 1, exc, wait)
time.sleep(wait)
logger.critical("Retries exhausted; routing pull to offline delta queue.")
self._queue_offline()
return None
def _queue_offline(self) -> None:
"""Persist intent to re-pull on the next connected window (DLQ stub)."""
logger.info("Offline fallback engaged; this window will be retried.")
def sync(self, layer_url: str, window_hours: int = 2) -> None:
"""Pull a bounded delta and merge it into the GeoPackage transactionally."""
layer = FeatureLayer(layer_url, self.gis)
cutoff = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).strftime(
"%Y-%m-%d %H:%M:%S"
)
params: dict[str, Any] = {
"where": f"last_edited_date >= TIMESTAMP '{cutoff}'",
"out_fields": "globalid,last_edited_date,agency_type",
"return_geometry": True,
"f": "geojson",
}
result = self._with_backoff(layer.query, **params)
if not result:
return
# --- Tier 2: stage in an isolated workspace, never the live file ---
stage = sqlite3.connect(":memory:")
stage.execute(
"CREATE TABLE delta (globalid TEXT, agency_type TEXT, "
"last_edited_date TEXT, geometry TEXT)"
)
for feat in result.features:
stage.execute(
"INSERT INTO delta VALUES (?, ?, ?, ?)",
(
feat.attributes.get("globalid"),
feat.attributes.get("agency_type"),
feat.attributes.get("last_edited_date"),
json.dumps(feat.geometry),
),
)
stage.commit()
rows = stage.execute("SELECT * FROM delta").fetchall()
stage.close()
logger.info("Staged %d delta records for window starting %s", len(rows), cutoff)
self._commit(rows)
def _resolve(self, incoming: tuple, existing: Optional[tuple]) -> tuple[str, dict]:
"""Decide the winner for one merge key; return (action, audit_payload)."""
gid, agency, edited, geom = incoming
if existing is None:
return "insert", {"rule": "new_feature", "globalid": gid}
ex_gid, ex_agency, ex_edited, _ = existing
# Tier 3: same key, different physical feature -> re-key local, keep lineage.
if gid == ex_gid and geom_signature(geom) != geom_signature(existing[3]):
return "rekey", {"rule": "globalid_collision", "globalid": gid,
"relabelled": f"LOCAL-{ex_gid}", "parent_globalid": ex_gid}
# Tier 4: timestamp race -> ICS precedence, then last-writer-wins.
if edited == ex_edited:
inc_rank = ICS_PRECEDENCE.get(str(agency).split("-")[0], 0)
ex_rank = ICS_PRECEDENCE.get(str(ex_agency).split("-")[0], 0)
if inc_rank != ex_rank:
winner = "incoming" if inc_rank > ex_rank else "existing"
return ("update" if winner == "incoming" else "keep",
{"rule": "ics_precedence", "globalid": gid, "winner": winner})
return "update", {"rule": "tie_last_writer_wins", "globalid": gid}
return ("update" if edited > ex_edited else "keep",
{"rule": "recency", "globalid": gid})
def _commit(self, rows: list[tuple]) -> None:
"""Apply reconciled rows + audit in a single WAL transaction."""
conn = sqlite3.connect(self.gpkg_path)
# Tier 5: WAL + busy_timeout so a lock contender waits, never corrupts.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
try:
conn.execute("BEGIN")
for row in rows:
gid = row[0]
existing = conn.execute(
"SELECT globalid, agency_type, last_edited_date, geometry "
"FROM features WHERE globalid = ?",
(gid,),
).fetchone()
action, audit = self._resolve(row, existing)
self._apply(conn, action, row)
# Tier 6: immutable audit row per decision, before commit.
conn.execute(
"INSERT INTO sync_audit (ts, action, payload) VALUES (?, ?, ?)",
(datetime.now(timezone.utc).isoformat(), action, json.dumps(audit)),
)
logger.info("Resolved %s -> %s (%s)", gid, action, audit["rule"])
conn.commit()
logger.info("GeoPackage sync committed: %d records.", len(rows))
except sqlite3.OperationalError as exc:
conn.rollback()
logger.error("Lock/IO failure; rolled back, replica untouched: %s", exc)
self._queue_offline()
except Exception as exc: # noqa: BLE001 - never leave a half-applied merge
conn.rollback()
logger.exception("Merge failed; rolled back to prevent corruption: %s", exc)
raise
finally:
conn.close()
def _apply(self, conn: sqlite3.Connection, action: str, row: tuple) -> None:
"""Translate a resolution decision into a write (geometry write via pyogrio in prod)."""
# 'keep' is a no-op by design; 'rekey'/'insert'/'update' write here.
...
def geom_signature(geom_json: str) -> str:
"""Stable hash of geometry used to tell two same-key features apart."""
return str(hash(geom_json))
Validation checklist
Verify each item in a staging replica before this sync touches a live incident GeoPackage:
- The
last_edited_datefilter is built from a Python-side UTC cutoff, not server date arithmetic, and the window is bounded (no full-layer re-pulls). - Every network call routes through the backoff wrapper and a retry-exhausted pull lands in the offline queue, not a crash.
- The delta is fully staged in the in-memory workspace before the live GeoPackage is opened.
- A simulated duplicate
globalidfor a different feature re-keys the local record toLOCAL-…and populatesparent_globalid(nothing overwritten). - A same-second
last_edited_datetie resolves by ICS precedence, and only equal-tier ties fall through to last-writer-wins. -
PRAGMA journal_mode=WALandPRAGMA busy_timeoutare set, and a second concurrent writer waits rather than raisingdatabase is locked. - A forced exception inside the transaction rolls back cleanly and leaves the GeoPackage byte-identical to its pre-sync state.
- One
sync_auditrow exists per resolution, each naming the rule that decided the winner.
Edge cases and gotchas
- Axis-order inversion. GeoJSON from AGOL is lon/lat, but a GeoPackage layer registered against a CRS whose authority defines lat/lon order (some EPSG geographic codes) can silently transpose coordinates on read in older drivers. Confirm the staged geometry round-trips through a known control point before committing; a transposed perimeter passes every attribute check while sitting in the wrong hemisphere.
- Null-island drift. Field devices that lose a fix frequently emit
(0, 0). A(0, 0)point sails throughINSERTbut clusters every affected feature off the coast of West Africa. Reject coordinates at exact origin and at improbable distances from the incident bounding box, and quarantine rather than merge them. - Offline device clock skew. A tablet that has been disconnected may carry a drifted RTC, so its
last_edited_datecan be ahead of the server’s, falsely winning a recency comparison. Prefer the server-applied edit timestamp where AGOL provides it, and treat device-supplied times as advisory only. - Agency-specific datum anomalies. A partner agency exporting from a NAD27 legacy dataset can shift features tens of metres from the NAD83/WGS84 baseline the rest of the incident uses. Pin the GeoPackage’s declared CRS and reproject on ingest; never trust an unstated source datum.
- Empty deltas are not no-ops. A window that returns zero features should still emit an audit heartbeat, otherwise a silently failing query is indistinguishable from a genuinely quiet period during an after-action review (AAR).
Related
- Conflict Resolution in Multi-Agency Edits — the precedence-weighted reconciliation model this sync specialises.
- Real-Time Geocoding & Location Normalization — the upstream stage that guarantees deltas arrive CRS- and axis-normalised.
- Automated Attribute Validation Rules — schema and field-contract enforcement that keeps malformed records out of the merge.
Up one level: Conflict Resolution in Multi-Agency Edits.