Cursor-Based Incremental Sync for DSP Reports
Choosing what a cursor is built from — a timestamp, a sequence ID, an etag, or a reporting-period string — determines whether a DSP integration silently loses rows during a restatement or reliably resumes exactly where it left off. This decision sits one level below the general durable-cursor pattern described under Incremental Sync and Change Data Capture for Royalty Data, and it is the part of the pipeline where an implementation detail — how the cursor is persisted, and what happens when a later-arriving row lands behind it — has a direct, measurable effect on payout accuracy. Late-arriving corrections and DSP-side restatements are not edge cases in royalty reporting; they are a routine monthly occurrence, and a cursor design that cannot represent “this reporting period changed after I already synced it” will eventually understate or overstate an artist’s statement.
Cursor Strategy Trade-offs
| Cursor Type | Ordering Guarantee | Handles Restatements? | Failure Mode | Royalty Impact |
|---|---|---|---|---|
Timestamp (updated_at) |
Weak — depends on source clock and write-time accuracy | Only if the DSP bumps updated_at on correction |
Clock skew or bulk backfills can duplicate or skip rows at the boundary | Boundary duplication double-counts a stream; skew-induced skips understate revenue for the affected window |
| Sequence ID / offset | Strong, if the source guarantees monotonic, non-reused IDs | No — a correction to an already-synced row has no new ID | Gaps from deleted or renumbered IDs are invisible to the consumer | Cannot detect retroactive corrections at all; must be paired with a separate restatement feed or it silently ships stale figures |
| Etag / version token | Strong per-resource, weak across resources | Yes — a changed etag on a resource signals it must be re-fetched | Etag churn from unrelated metadata changes triggers unnecessary re-syncs | Safest against silent staleness, but over-triggering wastes compute and can flap reconciliation_status if not debounced |
Reporting-period string (YYYY-MM) |
Coarse — no intra-period ordering | Yes — reprocess the whole period when the DSP reopens it | Cannot tell which rows within a reopened period actually changed without a full diff | Guarantees correctness for the whole period at the cost of re-validating unchanged rows every time the period reopens |
In practice, DSP integrations that must be audit-defensible pair a reporting-period cursor for restatement detection with a per-row etag or content hash for change detection within that period — the period cursor answers “do I need to look at May again,” and the etag answers “which rows in May actually changed.”
Prerequisites & Assumptions
This implementation assumes Python 3.11+, httpx>=0.27 for async HTTP, pydantic>=2.6 for validation, and a Postgres or DuckDB-backed cursor table reachable from the ingestion worker. It assumes the DSP reporting endpoint supports at minimum a reporting_period filter and returns either an etag header or a stable per-row content_hash field; if neither is available, a hash of the row’s own payload must be computed client-side as a substitute. right_share_pct and payee_id are assumed present on every line item, since the merge step needs them to detect a changed split independent of a changed revenue figure.
Step 1: Model the Cursor and Detect Reopened Periods
The cursor tracks the last reporting period synced and a per-period completion flag, so a period that the DSP reopens for correction can be distinguished from one still in progress.
from datetime import date
from pydantic import BaseModel, Field
class PeriodCursor(BaseModel):
source_id: str
reporting_period: str = Field(pattern=r"^\d{4}-\d{2}$")
period_closed: bool
last_synced_at: date
def needs_resync(cursor: PeriodCursor, dsp_period_status: str) -> bool:
"""DSP marks a period 'REOPENED' when it issues corrections after close."""
return dsp_period_status == "REOPENED" or not cursor.period_closed
Step 2: Fetch and Hash Rows for Change Detection
Within a reporting period flagged for resync, compare each row’s content_hash (or etag) against the value stored from the prior sync to isolate only the rows that actually changed.
import hashlib
import httpx
def row_hash(row: dict) -> str:
key = f"{row['isrc']}|{row['territory_code']}|{row['payee_id']}|{row['revenue_usd']}|{row['right_share_pct']}"
return hashlib.sha256(key.encode()).hexdigest()
async def fetch_period_rows(client: httpx.AsyncClient, reporting_period: str) -> list[dict]:
resp = await client.get("/reporting/period", params={"reporting_period": reporting_period})
resp.raise_for_status()
rows = resp.json()["records"]
for row in rows:
row["content_hash"] = row.get("etag") or row_hash(row)
return rows
Step 3: Exactly-Once Merge with Transactional Cursor Advance
The merge and the cursor update commit in the same transaction, so a crash between them cannot leave the pipeline believing a period synced when it did not, or vice versa.
import duckdb
def merge_and_advance(con: duckdb.DuckDBPyConnection, reporting_period: str,
source_id: str, period_closed: bool) -> None:
con.execute("BEGIN")
try:
con.execute("""
MERGE INTO royalty_facts AS target
USING staged_period_rows AS src
ON target.isrc = src.isrc
AND target.territory_code = src.territory_code
AND target.payee_id = src.payee_id
AND target.reporting_period = src.reporting_period
WHEN MATCHED AND target.content_hash != src.content_hash THEN
UPDATE SET revenue_usd = src.revenue_usd,
right_share_pct = src.right_share_pct,
content_hash = src.content_hash
WHEN NOT MATCHED THEN
INSERT (isrc, territory_code, payee_id, reporting_period,
revenue_usd, right_share_pct, content_hash)
VALUES (src.isrc, src.territory_code, src.payee_id, src.reporting_period,
src.revenue_usd, src.right_share_pct, src.content_hash)
""")
con.execute("""
UPDATE sync_cursors SET period_closed = ?, last_synced_at = CURRENT_DATE
WHERE source_id = ? AND reporting_period = ?
""", (period_closed, source_id, reporting_period))
con.execute("COMMIT")
except Exception:
con.execute("ROLLBACK")
raise
Verification & Validation
After each merge, a split-sum assertion confirms right_share_pct still totals 100% per isrc and territory — a changed-hash row that altered a split without a matching counter-adjustment on another payee’s share fails this check and routes to quarantine rather than posting. A row-count checksum comparing len(staged_period_rows) against the delta reported in the DSP’s own period-status metadata catches silent truncation from pagination bugs. The ISRC resolution rate — the percentage of synced rows that matched an existing catalog entry rather than falling to the unclaimed queue — should be logged per period and alerted on if it drops more than a few points period-over-period, since a sudden drop usually indicates an upstream ISRC formatting change rather than genuinely new, unmatched content.
Edge Cases & Gotchas
A reporting period can be reopened by the DSP weeks or months after it was first marked closed, so period_closed must never be treated as permanent — always re-check the DSP’s own period-status field rather than trusting a local flag indefinitely. Etags are sometimes recycled across unrelated resources by DSPs with careless cache implementations, so pair etag comparison with a payee_id and isrc scope rather than trusting a bare etag match. Territory-code mismatches (UK vs GB) between a reopened period’s corrected rows and the original rows can cause the merge key to miss, inserting a duplicate rather than updating in place — normalize territory codes to ISO 3166-1 alpha-2 before hashing. Finally, watch for NULL right_share_pct values on corrected rows where a DSP’s correction payload omits fields it considers unchanged; treating a NULL as zero rather than “carry forward the prior value” will silently zero out a payee’s share on the next statement.
Related
The exactly-once merge semantics here rely on the same idempotency guarantees covered in Building idempotent ingestion pipelines in Python, and the underlying cursor concepts are introduced more broadly under Incremental Sync and Change Data Capture for Royalty Data.