Recipe

Backfill a warehouse after downtime

Problem

You're standing up a new datastore — a warehouse, a search index, a fresh service — or your consumer was offline long enough that replaying webhooks isn't practical. You want a complete, correct snapshot of every CDM entity, and you want the script to be safe to re-run if it dies halfway.

Code

Page each entity from the start, ordered by created_at:asc, and upsert each record. Re-running is a no-op because you key on the record's identity, not on insert order.

backfill.py / backfill.mjs
import os

import requests

BASE = "https://api.propsocket.io/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['PROPSOCKET_API_KEY']}"}
ENTITIES = ["properties", "units", "residents", "leases"]


def page_all(entity):
    offset, limit = 0, 100  # max page size
    while True:
        resp = requests.get(
            f"{BASE}/{entity}",
            headers=HEADERS,
            params={"limit": limit, "offset": offset, "order-by": "created_at:asc"},
        )
        if resp.status_code == 429:
            # Honor the server's backoff instead of guessing.
            import time
            time.sleep(int(resp.headers.get("Retry-After", "1")))
            continue
        resp.raise_for_status()
        body = resp.json()
        yield from body["results"]
        if not body["meta"]["hasMore"]:
            return
        offset += limit


def backfill():
    for entity in ENTITIES:  # order matters: parents before children
        for record in page_all(entity):
            # Upsert keyed on (x_id, integration_id) — re-running is a no-op.
            upsert(entity, record)


if __name__ == "__main__":
    backfill()

Why this works

  • Stable ordering. Sorting by created_at:asc means new records land at the end of the sequence. A record you've already paged past never shifts back into an earlier page, so you don't skip or double-read as the sync engine writes during your run. The contract is in Pagination.
  • Idempotent upserts. Each CDM record's natural key is itsx_id within an integration (PropSocket enforces a uniqueness constraint on(x_id, integration_id)). The stable id is the simplest upsert key. Either way, the second run overwrites with identical data — no duplicates.
  • Parents before children. Loading properties, then units, then residents, then leases means foreign-key targets exist before the rows that reference them — no deferred-FK gymnastics on your side.
  • Honoring Retry-After. A full backfill is the most likely thing to hit the 150 req/min per-Organization limit. Sleeping for the server-suppliedRetry-After keeps you correct without manual tuning.

What to watch out for

  • This snapshot excludes soft-deletes. By default you get only live records. If you're reconciling — and need to learn what was removed while you were down — add?include_deleted=true and read each record's deleted_at. That's the job of Reconcile after an outage.
  • Don't parallelize with pre-computed offsets. Firing many pages at once with guessed offsets races against inserts and corrupts the window. Page sequentially; the 100-row max page size keeps even six-figure datasets to a manageable request count.
  • Leases nest their residents. A lease response includes itsresidents as a relation array (LeaseResident is relation-only, never a top-level collection). You can populate your join table straight from the lease payload rather than cross-referencing separately.
  • Money stays integer. Persist amount as the integer minor-unit value and keep currency alongside it. Converting to a float during backfill is how rounding bugs get baked into a warehouse permanently.

Next

Once the snapshot is loaded, keep it fresh with webhooks (Listen for new leases; webhooks are on theScale plan and above) and close any gap from your downtime window with Reconcile after an outage.