py-store

Async ETL and batch writes

The problem

You have a recurring ingest job: rows land in one database (a staging table, an operational replica, a landing collection) and must be normalised and written into a second database that your read API and reports query. The source may be far larger than memory, the two sides may not even be the same engine, and the job will be killed and restarted — so a re-run must not duplicate rows or leave parent documents without their children.

Why py-store

Walkthrough

from py_store import init, store
from py_store import executors

# Read side ("oltp") and write side ("dwh"); each may be SQL or Mongo.
await init({
    "oltp": executors.create_connection("postgres", oltp_pool),
    "dwh": executors.create_connection("postgres", dwh_pool),
})

# One definition of the written shape. The unique index on the natural key is
# what makes `mutation` upsert instead of duplicate on a re-run.
store.register({
    "name": "Order",
    "collection": "orders",
    "idPrefix": "OD",
    "datasource": "dwh",
    "namespace": "analytics",
    "fields": {
        "_id": "string",
        "externalId": {"type": "string", "default": ""},
        "customerId": {"type": "string", "default": ""},
        "amount": {"type": "float", "default": 0},
        "status": {"type": "string", "default": "open"},
    },
    "indexes": [{"keys": {"externalId": 1}, "options": {"unique": True}}],
})

GQL = "Order($condition:@c0,$sort:@s0){ _id, externalId, customerId, amount, status }"
PAGE, CHUNK = 1000, 250

async def run_etl():
    page = 0
    while True:
        # Read one page from staging: the route override re-targets the read
        # without touching the schema's registered binding.
        res = await store.query_with_count(
            GQL,
            {"c0": {"status": "open"}, "s0": {"_id": 1}, "page": page, "pageSize": PAGE},
            {"source": "oltp", "namespace": "public"},
        )
        rows = res["items"]
        if not rows:
            return

        for start in range(0, len(rows), CHUNK):
            chunk = rows[start:start + CHUNK]
            payload = [{"externalId": r["externalId"], "customerId": r["customerId"],
                        "amount": r["amount"], "status": r["status"]} for r in chunk]
            # Batch write; the job runs as an internal task (no request context).
            await store.run_as_internal(lambda p=payload: store.mutation("Order", p))

        if not res["hasMore"]:
            return
        page += 1

The read and the write target different sources, so they can never be one transaction (see Transaction boundary). The job is therefore built to be re-runnable: mutation upserts on the externalId unique index, so a chunk that fails part-way — the exception propagates out of run_etl — is simply reprocessed on the next run, and rows already written are updated rather than duplicated. If you want to resume from the middle instead of re-scanning from page 0, track the highest _id you completed yourself; there is no library-side checkpoint.

Pitfalls

See also