Skip to content

Streaming POS Data with Change Data Capture

Batch extracts of point-of-sale data arrive hours late, miss same-day voids and price corrections, and re-import rows the reconciliation engine has already accrued against — every one of those gaps is a rebate variance waiting to surface at month-end. Log-based change data capture (CDC) closes the gap by tailing the POS database’s write-ahead log and emitting every insert, update, and delete as an ordered event the moment it commits, so the claim validation rule engine evaluates transactions in near-real time instead of against a stale snapshot. This page is the implementation-level companion to the streaming and CDC ingestion topic area, which sits inside data ingestion normalization pipelines; here we build one concrete flow — a Debezium-style connector emitting to a partitioned event-log topic, consumed idempotently so replays and duplicate deliveries change nothing.

Log-based CDC data flow from POS source to the reconciliation pipeline A POS source database's write-ahead log feeds a Debezium-style CDC connector, which emits change events to an event-log topic partitioned per transaction key. An idempotent consumer reads that topic in strict offset order per partition, upserts each row keyed on a deterministic record_hash so replays and duplicates become no-ops, applies tombstone deletes, and gates late events behind a watermark before handing normalized transactions to the reconciliation accrual pipeline. A schema registry supplies the change-event contract to both the connector and the consumer. An offset store below the consumer records committed offsets and drives seek-based replay. Schema registry op · before/after contract POS source WAL · binlog CDC connector Debezium-style Event-log topic partitioned per key Idempotent consumer record_hash upsert Reconciliation accrual pipeline emit in offset order watermark gates late events Offset store commit · seek/replay replay from offset Per-key partitioning keeps a transaction's create → update → delete in a single ordered lane. The consumer is the safety boundary: offset ordering, record_hash dedup, and the watermark make redelivery and replay harmless.

Prerequisites

Before wiring CDC into the reconciliation pipeline, confirm the following are in place:

  • A log-based CDC connector with source access. A Debezium-style connector needs replication access to the POS database — logical replication on PostgreSQL (REPLICATION role plus a replication slot), or REPLICATION CLIENT/REPLICATION SLAVE and row-based binlog on MySQL. Trigger-based or query-based capture is out of scope; only the write-ahead log gives you complete, ordered before/after images including deletes.
  • A partitioned event-log topic and an offset store. Provision the Kafka topic pos.public.transactions partitioned so that a transaction’s whole lifecycle lands on one partition, and a durable consumer-offset store (the broker’s __consumer_offsets or an external table) that survives restarts. Per-key ordering and replay both depend on this.
  • A schema registry holding the change-event contract. The op, before, and after field types are registered once so the connector and every consumer serialize and validate against one definition instead of drifting per service.
  • Python 3.11+ with pydantic>=2.5. All validation snippets use Pydantic v2 models. Every monetary field is parsed via decimal.Decimal; never float.
  • Scoped roles. The connector runs under a read-only replication role on the source; the consumer runs under a reconciliation_etl role with write access to the materialized transaction table and its own offsets, and read-only access to the schema registry. Neither role can post accruals — that boundary belongs downstream in settlement and financial close.

Step-by-Step Implementation

Step 1 — Model the change event

A CDC message is not a row; it is a change — an operation code plus the row’s before and after images and the source coordinates that place it in the log. Encode that contract as a typed Pydantic v2 ChangeEvent so a malformed event fails at deserialization instead of corrupting the materialized table. Amounts are parsed from strings into decimal.Decimal, and a validator rejects any float outright.

python
from decimal import Decimal
from enum import Enum
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, field_validator, model_validator


class Op(str, Enum):
    c = "c"   # create / insert
    u = "u"   # update
    d = "d"   # delete


class PosRow(BaseModel):
    txn_id: str
    store_id: str
    register_id: str
    sku: str
    units: int
    net_amount: Decimal
    txn_ts: datetime

    @field_validator("net_amount", mode="before")
    @classmethod
    def _no_float(cls, v):
        if isinstance(v, float):
            raise ValueError("net_amount must be a decimal string, not a float")
        return v


class Source(BaseModel):
    topic: str
    partition: int
    offset: int
    lsn: int              # log sequence number from the source WAL


class ChangeEvent(BaseModel):
    op: Op
    before: Optional[PosRow] = None
    after: Optional[PosRow] = None
    source: Source
    ingest_ts: datetime   # when the connector emitted the event

    @model_validator(mode="after")
    def _shape(self) -> "ChangeEvent":
        if self.op in (Op.c, Op.u) and self.after is None:
            raise ValueError("create/update events require an 'after' image")
        if self.op is Op.d and self.before is None:
            raise ValueError("delete events require a 'before' image")
        return self

A representative update event on the wire — a cashier corrected the unit count on an already-captured line:

json
{
  "op": "u",
  "before": {
    "txn_id": "T-99118", "store_id": "S-4471", "register_id": "R-08",
    "sku": "0004900000634", "units": 6, "net_amount": "23.94",
    "txn_ts": "2026-07-15T18:42:11Z"
  },
  "after": {
    "txn_id": "T-99118", "store_id": "S-4471", "register_id": "R-08",
    "sku": "0004900000634", "units": 4, "net_amount": "15.96",
    "txn_ts": "2026-07-15T18:42:11Z"
  },
  "source": {"topic": "pos.public.transactions", "partition": 3, "offset": 88214, "lsn": 27713994},
  "ingest_ts": "2026-07-15T18:42:12.104Z"
}

Validation check: parse the payload with ChangeEvent.model_validate_json(raw) and assert event.after.net_amount == Decimal("15.96"). Then feed the same payload with "net_amount": 15.96 (a JSON number) and assert it raises ValidationError. A float that reaches the accrual table is a rounding defect you cannot reproduce later.

Step 2 — Consume in offset order per key

Correctness hinges on ordering: for one transaction, its create must be applied before its update, and its update before its delete. A log-based connector guarantees this only within a partition, so the routing key must place every event for a transaction on the same partition. Derive a deterministic key and process events strictly in ascending offset, tracking the last applied offset per partition so an out-of-order or already-seen event is dropped rather than re-applied.

python
def key_of(evt: ChangeEvent) -> str:
    row = evt.after or evt.before          # 'after' for c/u, 'before' for d
    return f"{row.store_id}:{row.register_id}:{row.txn_id}"


class OffsetGuard:
    def __init__(self):
        self._applied: dict[int, int] = {}   # partition -> last applied offset

    def accept(self, evt: ChangeEvent) -> bool:
        part, off = evt.source.partition, evt.source.offset
        if off <= self._applied.get(part, -1):
            return False                     # stale / duplicate by offset
        self._applied[part] = off
        return True

Because the key hashes to a single partition and the broker preserves per-partition offset order, iterating a partition in offset order replays the transaction’s lifecycle in commit order. The guard makes redelivery of an already-processed offset a no-op.

Validation check: submit events for the same key_of(...) at offsets 10, 11, then a redelivered 11, and assert accept returns True, True, False. Then submit offset 9 after 11 and assert it returns False — a late, out-of-order frame must not overwrite newer state.

Step 3 — Idempotent upsert keyed on a deterministic record_hash

Offset guarding stops same-partition redelivery, but a full topic replay resets offsets and re-delivers everything. The durable defense is content-addressed idempotency: fingerprint each row into a deterministic record_hash and make the write a no-op when the incoming hash equals the stored one. Serialize with sorted keys and the amount as a string so the hash is stable across processes and machines.

python
import hashlib
import json


def record_hash(row: PosRow) -> str:
    payload = {
        "txn_id": row.txn_id,
        "store_id": row.store_id,
        "register_id": row.register_id,
        "sku": row.sku,
        "units": row.units,
        "net_amount": str(row.net_amount),   # decimal string, never float repr
        "txn_ts": row.txn_ts.isoformat(),
    }
    blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode()).hexdigest()


class IdempotentSink:
    def __init__(self):
        self._rows: dict[str, dict] = {}     # txn key -> {record_hash, row}

    def upsert(self, evt: ChangeEvent) -> str:
        row = evt.after
        key, rhash = key_of(evt), record_hash(row)
        current = self._rows.get(key)
        if current and current["record_hash"] == rhash:
            return "noop_duplicate"          # replay of identical content
        self._rows[key] = {"record_hash": rhash, "row": row}
        return "inserted" if current is None else "updated"

In a relational sink this is one statement: INSERT ... ON CONFLICT (txn_key) DO UPDATE SET ... WHERE pos.record_hash <> excluded.record_hash. A replayed create writes identical content, the hashes match, and the row is untouched — the accrual downstream never double-counts.

Validation check: apply a create, then apply the same create again and assert the second call returns "noop_duplicate". Apply the Step 1 update event and assert it returns "updated" and that record_hash changed with units. Idempotency means N deliveries of one event leave exactly one materialized row.

Step 4 — Handle tombstone deletes

A voided or reversed sale must remove the row, or the reconciliation engine accrues rebate against a transaction that no longer exists. A log-based connector represents a delete as an op: "d" event carrying the before image, immediately followed by a Kafka tombstone — a message with the same key and a null value that lets log compaction physically retire the key. The consumer must act on the delete and treat the tombstone as an idempotent confirmation, not an error.

python
def apply(sink: IdempotentSink, guard: OffsetGuard, evt: ChangeEvent | None, key: str) -> str:
    if evt is None:                          # raw Kafka tombstone: key set, value null
        sink._rows.pop(key, None)
        return "tombstone_compacted"
    if not guard.accept(evt):
        return "skipped_stale_offset"
    if evt.op is Op.d:
        sink._rows.pop(key_of(evt), None)    # remove using the 'before' image key
        return "deleted"
    return sink.upsert(evt)                  # c / u -> idempotent upsert

Deleting an absent key with pop(key, None) is deliberately safe: whether the delete arrives once or the tombstone re-arrives after a replay, the end state is the same — no row. That is what keeps deletes idempotent alongside upserts.

Validation check: insert a row, apply its op: "d" event, and assert key_of is gone from the sink. Then deliver the tombstone (evt is None) for the same key and assert it returns "tombstone_compacted" without raising. A missed tombstone is the classic cause of phantom accruals on reversed sales.

Step 5 — Apply a watermark for late events and support replay

Events can arrive out of wall-clock order across partitions — a register that buffered offline flushes an hour late. A watermark tracks the high boundary of event time seen so far; anything older than the watermark minus a bounded lag is late and is routed to a correction path instead of silently mutating a period that reconciliation may already be closing. Replay is the complement: because Steps 3 and 4 are idempotent, you can seek a partition back to any committed offset and re-consume with no double-counting.

python
from datetime import timedelta

WATERMARK_LAG = timedelta(minutes=30)       # tolerated out-of-orderness


class Watermark:
    def __init__(self):
        self._hi: datetime | None = None

    def observe(self, evt: ChangeEvent) -> None:
        ts = (evt.after or evt.before).txn_ts
        if self._hi is None or ts > self._hi:
            self._hi = ts

    def is_late(self, evt: ChangeEvent) -> bool:
        if self._hi is None:
            return False
        return (evt.after or evt.before).txn_ts < self._hi - WATERMARK_LAG


# Replay: reset a partition to a stored offset; idempotency makes it safe.
def replay_from(consumer, topic: str, partition: int, offset: int) -> None:
    from kafka import TopicPartition          # kafka-python style API
    tp = TopicPartition(topic, partition)
    consumer.seek(tp, offset)

Late events are not discarded — they carry an accrual delta against the original period, which is exactly the retroactive-adjustment behavior the date-window alignment checks layer expects. The watermark only decides which path the event takes.

Validation check: observe an event at txn_ts = 19:00, then submit one at 18:20 and assert is_late is True (older than 30 minutes behind the watermark) while one at 18:45 is False. After a replay_from(...), re-consume the range and assert the materialized row count is unchanged — replay is a no-op on already-applied content.

Common Failure Modes and Fixes

1. Out-of-order application within a key. Round-robin partitioning, or keying on sku instead of the transaction identity, scatters one transaction’s create/update/delete across partitions where offset order no longer holds — the update can land before the create. Fix: key strictly on the transaction identity (store_id:register_id:txn_id) so the whole lifecycle shares one partition, and enforce the per-partition OffsetGuard from Step 2.

2. Duplicate on redelivery or rebalance. At-least-once delivery re-sends the last uncommitted batch after a consumer rebalance or crash, double-applying events keyed only on offset. Fix: content-address every write with record_hash (Step 3) so an identical redelivery is a noop_duplicate; commit offsets only after the sink write succeeds.

3. Float amounts corrupting the hash and the accrual. Deserializing net_amount as a JSON number reintroduces double-precision drift and makes record_hash non-reproducible, because str(15.96) can differ from the source string. Fix: parse amounts as decimal.Decimal from strings, reject floats at the boundary (Step 1), and serialize with str(row.net_amount) in the hash payload.

4. Missed tombstone leaving a phantom row. Treating an op: "d" event or a null-value tombstone as malformed, or filtering it out because it has no after image, leaves the reversed sale materialized and accruing. Fix: handle op: "d" on the before key and accept the null-value tombstone as an idempotent delete (Step 4).

5. Offset gap or growing consumer lag. A dropped replication slot, an expired offset, or a slow consumer creates a gap where committed data was never applied, or an ever-growing lag that makes reconciliation stale. Fix: monitor consumer lag per partition, alert when it exceeds an SLA, and recover by replay_from the last known-good committed offset — safe precisely because Steps 3 and 4 are idempotent.

Operational Checklist

Frequently Asked Questions

Why use log-based CDC instead of a periodic batch extract of POS data? A batch extract captures a snapshot and loses intermediate states — a line that was created, corrected, and voided between two extracts may never appear, or appears in a final state that hides the correction the vendor was rebated against. Log-based CDC emits every insert, update, and delete in commit order the moment it happens, so voids and price corrections reconcile in near-real time. The trade-off in extract cadence versus completeness is covered in incremental versus full ERP sync strategies.

How does keying on record_hash make replays safe? The record_hash is a stable SHA-256 fingerprint of the row’s content, computed from sorted keys with the amount as a decimal string. An idempotent upsert compares the incoming hash against the stored one and skips the write when they match, so re-consuming a topic from an earlier offset re-applies identical content as a no-op. N deliveries of the same event always leave exactly one materialized row.

What is a tombstone and why does it matter for reconciliation? A tombstone is a Kafka message with a key and a null value that a log-based connector emits after a delete so log compaction can physically retire the key. If the consumer ignores it, the deleted transaction stays materialized and the engine keeps accruing rebate against a sale that was reversed. Handling both the op: "d" event and the null-value tombstone as idempotent deletes prevents that phantom accrual.

How do late-arriving events avoid corrupting a closed period? A watermark tracks the highest event time seen and flags anything older than a bounded lag as late. Late events are not applied blindly into the current window; they are routed to a correction path that posts a delta against the original period, keeping period-over-period accrual tie-out intact rather than silently mutating a period reconciliation may already be closing.

Up one level: Streaming & CDC Ingestion