Architecture¶
Components¶
flowchart TB
subgraph src/mssql_cdc
DS[source.MssqlCdcDataSource<br/>schema, streamReader]
RD[source.MssqlCdcStreamReader<br/>offsets, partitions, read]
SN[source.MssqlCdcSnapshotReader<br/>key ranges, read]
CL[client.CdcClient]
SQL[client.SqlCdcClient<br/>T-SQL]
BE1[MssqlPythonBackend<br/>cursor.arrow_batch]
BE2[ArrowOdbcBackend<br/>read_arrow_batches]
FK[fake.FakeCdcClient<br/>files]
SK[sink.delta_sink<br/>foreachBatch]
FN[finalization<br/>advance / is_final]
PL[pipeline.CdcStream<br/>to_delta, snapshot, generations]
BF[pipeline.CdcStream.backfill<br/>chunked snapshot waves]
SV[silver.apply_changes<br/>MERGE, waves, rebuild]
RC[reconcile<br/>bucket counts, row hashes]
end
PL --> DS
PL --> SK
BF --> SN
RC --> SN
RC --> CL
DS --> RD --> CL
DS --> SN --> CL
CL --> SQL --> BE1 & BE2
CL --> FK
RD -. micro-batches .-> SK
SN -. snapshot rows .-> PL
SK -. after commit .-> FN
SK -. bronze .-> SV
BF -. chunk waves .-> SV
SV -. after MERGE .-> FN
SV -. silver .-> RC
- Source: Spark Python DataSource V2 (
pyspark.sql.datasource). One stream per source table: its capture instance, and the newer one it switches to (ADR 0023).mssql_cdc_snapshotis its batch sibling: the tracked table's current rows in the same schema, stamped with the LSN recorded before they are read, for the initial load (ADR 0016). A whole snapshot has one stamp; a chunked one (snapshotChunks) a stamp per wave, each at or after the snapshot's own LSN S, so its rows do not share one_start_lsn(ADR 0028). - Client: the only code that knows T-SQL. The reader depends on the
CdcClientinterface, so the fake can replace SQL Server in tests. - Backends: turn a query into Arrow record batches. Interchangeable because every
LSN is a hex string on the wire (invariant 6 in
CLAUDE.md). - Sink: an optional, Delta-specific
foreachBatchwriter. The source works with any sink. - Finalization: a control table with one row per target table.
- Events: the facts table's event rows (snapshots opened, planned, read in chunks and
completed; schema changes, capture instance switches, data skipped) are written and read
back through one module,
events: their names, the rows each writer appends, and the readers the pipeline, silver and reconcile share. A chunked snapshot's key values go to a plans table next to bronze through it too, and its readers complete the facts rows from it. - Backfill:
CdcStream.backfillreads a chunked snapshot thatto_deltaopened, in waves next to the running stream: one bronze commit and one facts row per chunk each (ADR 0028). - Silver:
apply_changesreads bronze (and the facts, for re-snapshots and chunked snapshots) and MERGEs the latest image per key into a current-state table; its position and verdict are its row in the control table (ADR 0019). - Reconcile:
reconcilecompares silver with its SQL Server table, by row count and key sum per key range and then row by row through the snapshot reader, and reports what differs.
Where code runs¶
| Method | Process | Notes |
|---|---|---|
DataSource.schema() |
driver-side Python worker | returns a DDL string; no JVM access |
initialOffset, latestOffset, getDefaultReadLimit, prepareForTriggerAvailableNow, reportLatestOffset, partitions, commit |
one long-lived driver-side Python worker per query | may keep state (_target, cached client) |
read(partition) |
executor Python workers, one call per partition | stateless; opens its own connection; the reader is pickled without _client |
snapshot partitions() |
driver-side Python worker, once per read | records the snapshot LSN unless snapshotLsn is set; each KeyRange carries the LSN, commit time, table and key bounds, so read needs no planning state |
One micro-batch¶
sequenceDiagram
participant E as Spark engine
participant R as Reader (driver)
participant X as read() (executor)
participant S as SQL Server
participant D as Delta sink
participant F as Finalization
E->>R: latestOffset(start, ReadMaxRows(n))
R->>S: fn_cdc_get_max_lsn() (or AvailableNow target)
R->>S: n-th start_lsn after start in lsn_time_mapping
R->>S: fn_cdc_map_lsn_to_time(end) -> commit_ts (UTC)
R-->>E: end = {lsn, commit_ts}
Note over E: offset log written (checkpoint)
E->>R: partitions(start, end)
R->>S: sp_cdc_help_change_data_capture: the table's instances, their start_lsn
R->>S: sp_cdc_get_ddl_history(ci): DDL in (start, end] (a type change: SchemaChangedError)
R->>S: fn_cdc_increment_lsn(start), fn_cdc_get_min_lsn(ci) per instance read
R->>S: NTILE split points on cdc.ci_CT (numPartitions > 1)
R-->>E: [LsnRange(ci, from, to), ...], cut at a newer instance's start or DataLossError
Note over R: events (schema_change, capture_instance_switched, data_skipped) as files in metricsPath
E->>X: read(LsnRange)
X->>S: cdc.ci_CT WHERE start_lsn BETWEEN from AND to, JOIN lsn_time_mapping
S-->>X: Arrow record batches
X->>S: fn_cdc_get_min_lsn(ci) (cleanup during the read? DataLossError, or with failOnDataLoss=false a possible data_skipped in its metrics file)
X-->>E: batches cast to the Spark schema
E->>D: foreachBatch(df, batch_id)
D->>D: append (txnAppId, txnVersion=batch_id, userMetadata=facts, mergeSchema)
D->>D: facts: the batch's row and its event rows, one commit
Note over E: commit log written
E->>F: after the query (AvailableNow) or on progress
F->>F: MERGE finalized_until = GREATEST(old, trunc(end.commit_ts))
Offsets and the checkpoint¶
lsn: last processed commit LSN. The initial offset forstartingLsn=earliestisfn_cdc_decrement_lsn(min_lsn), so the first read starts atmin_lsn.commit_ts: commit time oflsnin UTC. It is stored even when the batch has no rows, becauseendcan be an idle "dummy" entry. This is what lets finalization advance on quiet tables.- Replays: Spark re-runs an uncommitted batch with the same
(start, end).read()is deterministic for a range as long as CDC cleanup has not purged it; if it has, the guard fails the query.
A second capture instance¶
A table can have two capture instances, the newer one usually capturing a column the older
does not (ADR 0023). The
newer one's start_lsn S is the commit LSN of its enable, and from S on every commit is in
both. partitions() lists the table's instances on every planning and cuts the batch at S:
start S - 1 | S end
|------ older: cdc.dbo_orders_CT ------|------ newer: cdc.dbo_orders_v2_CT ------|
Offsets stay database-wide LSNs, so the checkpoint needs nothing new, and a replay cuts at
the same place. Each range reads its own instance's columns, NULL for the ones it lacks;
load() infers the union of both. The first batch that reads the newer instance leaves a
capture_instance_switched event for the facts. Its facts row is written before Spark
commits that batch, and a replay still reads the older instance, so the DBA disables the
older one only after the stream's next batch (sql/switch_capture_instance.sql). A
declared column that no instance captures fails the first planning instead of reading NULL.
DDL inside a batch leaves a schema_change event;
a changed type fails the batch before it reads anything (SchemaChangedError).
Generations (to_delta)¶
to_delta reads <checkpoint>/_mssql_cdc_generation.json on every call to find the live
generation. Without the file it is generation 0: the checkpoint and app_id as given.
<checkpoint>/
offsets/ commits/ metadata ... generation 0 (Spark's files)
_mssql_cdc_metrics/ generation 0 metrics (default metricsPath)
_mssql_cdc_generation.json {"generation": n, "snapshot_lsn", "commit_ts", "at"},
plus "recovering" and "failed_at" while a recovery is open
_generations/<n>/ generation n: Spark checkpoint and metrics, app_id <app_id>.g<n>
With on_data_loss="resnapshot", before starting the query it takes the last processed
offset from the live checkpoint (line 3 of offsets/<n> for the highest committed n,
Spark's offset log format v1) and, when max_lsn is past it, applies the driver guard's
test. When the next range is purged it records that offset as recovering, snapshots the
target, records a 'resnapshot' facts row, writes the state file for n + 1 (atomically;
the commit point) and starts generation n + 1 from the snapshot's LSN. At most one
automatic re-snapshot, or failed one, per resnapshot_interval_days
(ADR 0018).
Output schema¶
Six metadata columns, then the captured columns; every column, the ordering key and the
default type mapping are in Output schema. Captured columns
come from the columns option (DDL) or, when it is omitted, from CDC metadata at load()
time on the driver: sys.sp_cdc_get_captured_columns, sorted by column_ordinal
(SqlCdcClient.captured_columns), for every capture instance of the table, joined by name,
older instance first. It needs only the permissions of the CDC query functions. The fake
infers them too when its capture instances record their columns
(FakeCdcDatabase(columns=...)), and needs columns otherwise.
Tables written by the sink, finalization, silver and reconcile¶
| Table | Grain | Written by | Notes |
|---|---|---|---|
bronze (e.g. bronze_orders) |
one row per change, plus snapshot rows (_operation = 0) |
delta_sink, to_delta, backfill |
append-only; _batch_id added, and on snapshot rows _snapshot (the snapshot's LSN S) and _chunk (the chunk of a chunked one, whose rows carry its wave's stamp in _start_lsn, at or after S); commit userMetadata holds the batch facts; appends with mergeSchema, so a column a newer capture instance captures joins the table (ADR 0023) |
| facts (optional) | one row per micro-batch, including batches that read no change rows (rows = 0, no target commit: the offset moved past idle entries or other tables' commits), one per snapshot to_delta takes (bootstrap or re-snapshot), one per schema change or capture instance switch a batch read past, one per range of purged changes a batch skipped with failOnDataLoss=false (and one per range a task found cleanup may have reached while it read), one when a snapshot opens, and for a chunked snapshot one with its plan and one per chunk |
delta_sink, write_event, backfill |
snapshot rows have event 'bootstrap' or 'resnapshot' (NULL for batches), no batch_id, and on a re-snapshot the purged gap in lost_from_ts/lost_to_ts (ADR 0018); a snapshot writes 'snapshot_open' before it reads the table (its LSN S and its mode in detail), a chunked one also 'snapshot_plan' (planned once, its chunk count) and 'snapshot_chunk' rows (the chunk's stamp and rows) before its completion row, their key values in the snapshot plans table (ADR 0028); 'schema_change' and 'capture_instance_switched' rows have the batch's batch_id, rows = 0, the change's LSN and what changed in detail, written in one commit with the batch's row (ADR 0023), and a 'data_skipped' row has the batch's batch_id, rows = 0, the LSNs skipped and whether the loss is certain in detail and the gap in lost_from_ts/lost_to_ts (ADR 0018 amendment 3): rebuild only from 'bootstrap' and 'resnapshot' rows; durable copy of the facts (Delta checkpoints drop commitInfo), plus started_at/duration_ms (source read + target write), written_at, and optional network and read metrics (source_rtt_ms, read_seconds, read_mb, network_wait_ms; ADR 0014), the batch's end offset (end_lsn, end_commit_ts), and, measured from it, the retention headroom (retention_watermark_ts, retention_headroom_hours; ADR 0017) and the ingestion lag, with the capture lag (source_max_commit_ts, capture_lag_seconds, ingestion_lag_seconds; ADR 0020) |
snapshot plans (<bronze>_snapshot_plans) |
one row per chunked snapshot opened and planned, and per chunk read | to_delta, backfill |
a chunked snapshot's key values (the key's extent, every chunk's bounds), each row the detail of its facts row whole, written before it; bronze's schema or a sibling directory, for bronze's access; a snapshot opened before 0.7.0 keeps them in its facts rows instead (ADR 0028 Amendment 4) |
table_finalization |
one row per target table | finalization.advance, apply_changes |
finalized_until, end_lsn, end_commit_ts, updated_at; applied_lsn and snapshot_lsn for silver tables: how far bronze is applied, and the snapshot last rebuilt from; open_snapshot_lsn and snapshot_wave: the open chunked snapshot and the last of its waves applied |
silver (e.g. silver_orders) |
one row per source key | apply_changes |
current state: captured columns plus _start_lsn and _commit_ts of the row's image; deletes remove rows; rebuilt from the newest snapshot after a re-snapshot, or once a chunked one completes (ADR 0019) |
| reconcile report (optional) | one row per bucket, differing key and chunk failure of each run | reconcile (report_table) |
run_id groups a run; the counts and key sums per bucket, MISSING_TARGET, MISSING_SOURCE, RECORD_DIFF and IN_FLIGHT keys, CHUNK_ checks (Tables) |
All six are created on first use with DeltaTable.createIfNotExists: explicit types, and a
comment on the table and on every control, facts, plans and report column and every bronze
and silver metadata column
(DESCRIBE TABLE shows them), and stamped with the table property
mssql_cdc.schema_version. Existing tables get the schema migrations of their kind that
they have not had yet (ADR 0012,
ADR 0013).
Extension points¶
- New backend: a class with
batches,scalarandclose, theBackendprotocol (subclassing it inheritsscalarandclose, ADR 0030), added tomake_client. - Other sinks: the source is sink-agnostic; any
writeStreamtarget works. Idempotency and facts are then the sink's job. - Continuous mode:
finalization.trackadvances the verdict from aStreamingQueryListener(ADR 0026); where a listener cannot run, callfinalization.advancefrom a separate job reading the checkpoint's committed offsets.