Data Extraction & Hashing Workflows › Incremental Extraction Strategies

Incremental Extraction Strategies

Incremental extraction is the discipline of reading only the rows that changed since the last reconciliation ran, instead of re-scanning an entire table every cycle. This reference sits inside the data extraction & hashing workflows stage and specialises it for the cost-and-freshness problem that dominates any pipeline past a certain size: a full extract that was fine at a million rows is ruinous at ten billion, and the only way to reconcile large tables frequently is to extract the delta. It is written for the data engineers, migration specialists, Python pipeline builders, and platform operations teams who need reconciliation to keep up with a growing table without a linearly growing bill.

The hard part is not reading changed rows — it is advancing the cursor correctly so that no changed row is ever missed and none is processed twice. A high-water mark advanced too eagerly skips rows committed just after it was read; advanced too conservatively re-processes rows already seen. Both are silent: the first loses data from the reconciliation, the second inflates it. This reference defines how to track the mark, choose an extraction strategy, and advance the cursor exactly once, so incremental extraction is a correctness win, not a source of the very drift reconciliation exists to catch.

High-water-mark incremental extraction cycle A source table's rows are ordered by a change column. The last high-water mark divides already-extracted rows from new rows. An extractor reads only rows above the mark, hashes them, and on a successful commit advances the mark to the maximum change value read. last high-water mark already extracted change value ≤ mark new rows to read change value > mark advance on commit Extract + hash Advance mark to max change value
Each cycle reads only rows whose change value exceeds the last high-water mark, hashes them, and advances the mark to the maximum value read once the batch commits — a safety margin guards against out-of-order commits.

Architectural Boundaries

This workload consumes a source table and the persisted high-water mark from the previous run, and produces the delta — the changed rows — plus an advanced mark for the next run. It feeds the column-level checksum generation and comparison stages exactly as a full extract would; only the set of rows differs. It owns cursor correctness and nothing else: it does not compare, hash-decide, or route. Keeping it that narrow is what lets it be tested for the one property that matters — no row missed, none double-read — in isolation from everything downstream.

Prerequisites

Step-by-Step Implementation

1. Read only rows above the mark, with a safety margin

Query rows whose change value exceeds the last mark minus a safety margin, so a row committed slightly out of order is not skipped. The margin is the price of correctness under concurrency.

python
import logging
from dataclasses import dataclass
from typing import Iterator, List, Dict

logging.basicConfig(
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", level=logging.INFO
)
logger = logging.getLogger("recon.extract.incremental")


@dataclass(frozen=True)
class Watermark:
    change_value: int          # last extracted max change value
    safety_margin: int         # re-scan window for out-of-order commits


def select_delta(rows: List[Dict], wm: Watermark, change_col: str) -> List[Dict]:
    """Return rows above (mark - margin); idempotent apply dedupes the overlap."""
    floor = wm.change_value - wm.safety_margin
    delta = [r for r in rows if r[change_col] > floor]
    logger.info("delta of %d row(s) above floor %d", len(delta), floor)
    return delta

2. Advance the mark only after a durable commit

The new mark is the maximum change value in the successfully processed batch, written transactionally with the batch’s completion so a crash never advances past unprocessed rows.

python
def next_watermark(delta: List[Dict], wm: Watermark, change_col: str) -> Watermark:
    """Advance to the max change value seen; never past what was committed."""
    if not delta:
        return wm
    new_value = max(r[change_col] for r in delta)
    logger.info("advancing watermark %d -> %d", wm.change_value, new_value)
    return Watermark(change_value=max(new_value, wm.change_value), safety_margin=wm.safety_margin)

3. Capture deletes explicitly

A change-column scan sees inserts and updates but not deletes; reconcile the current key set against the previous extraction’s keys, or consume tombstones, so a removed row is detected as missing rather than silently retained.

python
def detect_deletes(prev_keys: set, current_keys: set) -> List[str]:
    """Keys present last cycle and absent now are deletes the change scan cannot see."""
    deleted = sorted(prev_keys - current_keys)
    if deleted:
        logger.warning("%d delete(s) detected via key-set difference", len(deleted))
    return deleted

Strategy Trade-Off

The extraction strategy is chosen from what the source exposes and how deletes must be handled.

Strategy Freshness Delete capture Source load Compliance / regulatory
Change-column high-water mark Per run Needs key-set diff Low; indexed range Strong when the change column is trustworthy.
Log-based CDC Continuous Native (tombstones) Low; reads the log Strongest: complete change history, auditable.
Snapshot diff Per run Native (set diff) High; full scan Strong but expensive; a backstop, not a primary.
Trigger-based change table Per run Native Moderate; write amplification Strong; adds source-side coupling.

Scaling and Performance

Incremental extraction turns reconciliation cost from a function of table size into a function of change rate, which is the entire point — but only if the change column is indexed, or every run degrades to a full scan. Page the delta with keyset pagination rather than offset so deep ranges stay cheap, and shard by key range for parallel extraction, combining per-shard marks by taking the minimum so no shard’s lag advances the global mark past unprocessed rows. Size the safety margin to the measured out-of-order commit window: too small skips late commits, too large re-scans needlessly. For sources with a change log, prefer log-based CDC — it captures deletes natively and reads the log rather than the table — and fall back to a periodic snapshot diff as a backstop that catches anything the incremental path missed, exactly as batch reconciliation backstops streaming.

Failure Modes and Diagnostic Runbook

  • Skipped rows on eager advance. Cause: the mark advanced past rows that committed out of order below it. Detection signal: the periodic full backstop finds rows the incremental path never extracted. Remediation: widen the safety margin to the measured commit-lateness window.
  • Duplicate processing. Cause: the overlap re-scan reprocesses rows into a non-idempotent sink. Detection signal: counts inflate across runs. Remediation: make the downstream apply idempotent on a stable key.
  • Missed deletes. Cause: a change-column scan cannot see removed rows. Detection signal: deleted rows persist in the target. Remediation: add key-set-diff delete detection or consume CDC tombstones.
  • Full-scan regression. Cause: the change column lost its index. Detection signal: extraction time jumps to full-table scale. Remediation: restore the index; alert on extraction latency, not just row count.

Deep Dives