Skip to content

Tables

The Delta tables the library writes. You name each one; a name such as ops.ingestion_facts is a catalog table, and anything with a / or a : is a path.

Table Grain Written by You pass it as
Bronze one row per change to_delta (delta_sink, snapshots) target
Facts one row per micro-batch, snapshot and source change to_delta (delta_sink) facts_table
Snapshot plans one row per chunked snapshot opened and planned, and per chunk read to_delta, backfill none: <target>_snapshot_plans
Control one row per table finalization.advance, apply_changes control_table
Silver one row per source key apply_changes its target
Reconcile report one row per bucket, differing key and chunk failure of each run reconcile report_table

Each is created on first use with the DeltaTable builder: explicit types, a comment on the table and on every column the library defines, which DESCRIBE TABLE shows (ADR 0012). The comments below are the ones the tables carry, copied from the code (mssql_cdc.sink, mssql_cdc.events, mssql_cdc.finalization, mssql_cdc.silver, mssql_cdc.reconcile and mssql_cdc.migrations), and tests/test_docs_tables.py fails when they drift. How the pieces fit: Architecture.

Bronze

The change log, append-only.

Append-only change rows from SQL Server CDC, written by mssql-cdc-pyspark's delta_sink. One row per change: an update is two rows (operation 3, the row before; 4, the row after). Order changes by (_start_lsn, _command_id, _seqval, _operation). Rows with operation 0 are a snapshot of the source table, the one _snapshot names: rebuild from it with its rows and the changes with a larger _start_lsn. A whole snapshot's rows share its _start_lsn; a chunked snapshot's (_chunk set) are stamped per chunk, at or after it. A column the source table gained through a newer capture instance is added when the stream first reads it (older rows read NULL); a column it lost stays, NULL from then on.

Column Type Comment
_capture_instance STRING CDC capture instance the change came from, e.g. dbo_orders: after the stream switched to a newer capture instance of the table (from that instance's start LSN on), the newer one. On snapshot rows, the instance the snapshot was taken for.
_start_lsn STRING Commit LSN of the source transaction (__$start_lsn) as 0x + 20 uppercase hex. All changes of one transaction share it; string order is commit order. On snapshot rows, the LSN recorded before the table (or the row's chunk) was read: the row is at least that recent.
_seqval STRING Position of the change in the transaction log (__$seqval), 0x + 20 hex. Tie-breaker only: order by _command_id first. NULL on snapshot rows.
_operation INT What happened to the row: 1 = delete, 2 = insert, 3 = update (row before), 4 = update (row after), 0 = snapshot (the row as read from the source table).
_command_id INT Order of the statement within its transaction (__$command_id). Numbered per capture instance: another instance of the table numbers the same change differently, so it orders rows only within one _start_lsn (all rows of a commit come from one instance); (_start_lsn, _seqval, _operation) identifies a change across instances. NULL on snapshot rows.
_commit_ts TIMESTAMP_NTZ Commit time of the source transaction, UTC (from cdc.lsn_time_mapping); on snapshot rows, the commit time of their _start_lsn.
captured columns see Output schema none
_batch_id INT Micro-batch that wrote the row; with the sink's app_id, the key of its row in the ingestion facts table. NULL on snapshot rows.
_snapshot STRING On snapshot rows, the snapshot they belong to: the LSN (0x + 20 hex) recorded before any of its rows was read, where its stream generation starts; on a whole snapshot, its _start_lsn. NULL on change rows, and on snapshot rows written before this column existed, whose _start_lsn is their snapshot's.
_chunk INT On rows of a chunked snapshot (stream().backfill()), the chunk of the key space they were read in; with _snapshot, the key of its 'snapshot_chunk' row in the ingestion facts table. NULL on change rows and whole snapshots.

_command_id is absent with includeCommandId=false. A column a newer capture instance captures is added after the existing ones.

How it is written:

  • One append per micro-batch that read rows, with txnAppId = app_id and txnVersion = the batch id, so a replayed batch is skipped. Its userMetadata holds the batch's facts as JSON: rows, min_lsn, max_lsn, min_commit_ts, max_commit_ts, deletes, inserts, updates, batch_id and app_id. A batch that read no rows writes no commit. Where the platform refuses to cache the batch (Databricks serverless), the append reads it, so its userMetadata is written first: rows to updates are null, and the facts table has them; and a batch with no rows writes an empty commit unless the batch before it had none too (for a run's first batch, as the facts table's last row says; without a facts table, it writes one) (ADR 0032).
  • A snapshot is one append whose userMetadata is {"snapshot": <capture instance>, "lsn": ..., "commit_ts": ...}; a seed's, from a copy you already had, is {"seed": <capture instance>, "lsn": ..., "commit_ts": ...} (Bootstrap). Its rows have _snapshot = _start_lsn and no _chunk.
  • A chunked snapshot is one append per wave of backfill(), with txnAppId <app_id>#snap.<S> and txnVersion the wave, so a rerun of a wave is skipped. Its userMetadata is {"backfill": "<app_id>#snap.<S>", "wave", "lsn", "attempt", "chunks"}: the wave's stamp and, per chunk, its bounds and rows. Every row has _snapshot = S, its chunk in _chunk and the wave's stamp in _start_lsn, at or after S (Bootstrap). Where the platform refuses to cache the wave, a chunk's rows, high_lsn, read_seconds and read_mb are null there; its snapshot_chunk facts row has them (ADR 0032).
  • Every append uses mergeSchema. A changed column type fails it with SchemaChangedError unless the table has delta.enableTypeWidening and the change widens (Schema changes).
DESCRIBE HISTORY bronze.orders;  -- userMetadata of each append

Facts

What each micro-batch did, plus one row per snapshot, per source change and per skip past purged changes. Optional, but monitoring, the data-loss recovery and the schema-change events all need it.

One row per micro-batch written by mssql-cdc-pyspark's delta_sink, including batches that read no change rows (rows = 0), so a current stream on a quiet table keeps writing rows: what was written (counts, LSN and commit-time ranges), how far the stream had read (end_lsn, end_commit_ts) and how long it took. Where the platform caches, the same facts are in each target commit's userMetadata (batches with rows only); elsewhere the commit's userMetadata has the keys with null counts (ADR 0032). Delta log cleanup eventually drops it. Each snapshot stream().to_delta takes (bootstrap or re-snapshot), each schema change on the source, each switch to a newer capture instance and each skip past purged changes (failOnDataLoss=false) adds one row, with event set (see its comment); a snapshot adds one more when it opens; a chunked one also adds one with the chunks its first stream().backfill() call plans and one per chunk it reads.

Column Type Comment
app_id STRING Identity of the sink that wrote the batch (Delta txnAppId of the target write). Stable for the life of one streaming checkpoint; a new checkpoint needs a new app_id.
batch_id BIGINT Structured Streaming micro-batch id. With app_id, the idempotency key: a replayed batch is skipped, so its row (event NULL) never appears twice. Its 'schema_change', 'capture_instance_switched' and 'data_skipped' rows carry it too; snapshot event rows have none.
rows BIGINT Change rows written to the target in this batch, all operations. 0 when the batch read none: its end offset moved only past idle entries or other tables' commits (or, on a new checkpoint's first batch, not at all), so it wrote nothing to the target, just this row (LSN and commit-time ranges NULL, counts 0). On 'bootstrap' and 'resnapshot' rows, the rows of the snapshot (of all its chunks); on 'snapshot_chunk' rows, the chunk's; 0 on other event rows.
min_lsn STRING Smallest source commit LSN (__$start_lsn, 0x + 20 hex) in the batch.
max_lsn STRING Largest source commit LSN in the batch; hex strings sort in LSN order.
min_commit_ts TIMESTAMP_NTZ Earliest source commit time in the batch, UTC.
max_commit_ts TIMESTAMP_NTZ Latest source commit time in the batch, UTC. written_at minus this is the batch's ingestion latency.
deletes BIGINT Rows with operation 1 (delete).
inserts BIGINT Rows with operation 2 (insert).
updates BIGINT Updated rows, counted once: operation 4 (the row after). Each has an operation 3 row (the row before) that is not counted here.
started_at TIMESTAMP_NTZ When the sink started processing the batch, UTC.
duration_ms BIGINT Milliseconds from started_at to the end of the target write: the read from SQL Server, these facts and the append. Offset planning and the checkpoint commit are not included.
source_rtt_ms DOUBLE Network latency to SQL Server during the batch: the median, over its partitions, of one round trip (SELECT 1) each made on its own connection just before reading, in milliseconds. NULL unless the source option metricsPath and delta_sink(metrics_path=...) are set.
read_seconds DOUBLE Seconds the batch's partitions spent reading from SQL Server, summed over partitions (task-seconds: with parallel partitions it can exceed wall time), including Spark taking the rows as they arrive. NULL unless the source option metricsPath and delta_sink(metrics_path=...) are set.
read_mb DOUBLE Megabytes of Arrow data the partitions read, after the cast to the Spark schema, summed. NULL under the same condition as read_seconds.
network_wait_ms BIGINT Milliseconds SQL Server waited for the client to take the rows (ASYNC_NETWORK_IO of each partition's session), summed. Close to read_seconds * 1000 means the network, not the server, set the pace. NULL under the same condition, or where the server does not expose sys.dm_exec_session_wait_stats.
retention_watermark_ts TIMESTAMP_NTZ How far CDC cleanup had deleted when the batch was read: the commit time (UTC) of sys.fn_cdc_get_min_lsn for the capture instance, the latest seen by the batch's partitions after reading. Changes committed before it are gone from the source. NULL unless the source option metricsPath and delta_sink(metrics_path=...) are set.
retention_headroom_hours DOUBLE Hours between retention_watermark_ts and end_commit_ts: how far the stream's position (the batch's end offset) is ahead of what cleanup has deleted. A current stream sits near the retention period (3 days by default), on a quiet table too, since a batch that read no rows also writes its row; it shrinks as the stream falls behind, and at 0 the next changes to read are being purged. Cleanup moves the watermark in steps (the default job runs daily), so alert with more margin than that interval, and also when facts stop arriving: the stream or CDC capture has stopped, and the real headroom keeps shrinking from the last value. Rows written before end_commit_ts existed measured from max_commit_ts, the batch's last change, which on a quiet table made a current stream look behind. NULL under the same condition as retention_watermark_ts.
event STRING What the row records: NULL for a micro-batch. Snapshots, with no batch_id: 'bootstrap' for the initial snapshot of the target, and for the chunked one stream().to_delta(snapshot_on_switch=True) opens after a switch to a newer capture instance; 'resnapshot' for a snapshot taken because CDC cleanup purged changes before the stream read them; min_lsn = max_lsn is the LSN the snapshot is stamped with, and the only trace of a snapshot of an empty table (rows = 0), which writes no target rows. Before reading the table, a snapshot stream().to_delta takes writes 'snapshot_open' (min_lsn = max_lsn = an LSN S recorded first; detail says its mode, full or chunked): it stays open until a 'bootstrap' or 'resnapshot' row of the same stream has max_lsn at or after S, and while it is open no snapshot of the other mode is taken, though a full one stops counting once CDC cleanup passes S, as it can then never complete. A full snapshot writes that row once its read ends (a rerun after a crash reads it again, under a 'snapshot_open' of its own); a chunked one once its last chunk is in, after 'snapshot_plan' when its first backfill() call planned every chunk (min_lsn = max_lsn = S; started_at and duration_ms the planning's) and one 'snapshot_chunk' row per chunk read (min_lsn = the LSN the chunk was stamped with, recorded before it was read, at or after S; max_lsn = max_lsn after the read), and its stream generation starts at S (after a switch, S is the newer capture instance's start LSN, which the stream has read past); an open chunked snapshot that a newer one supersedes is abandoned and never gets that row. Changes to the source (ADR 0023), with the batch_id of the batch that read past them and rows = 0: 'schema_change' for DDL on the source table, 'capture_instance_switched' when the stream first read a newer capture instance of the table (the older one can be dropped once the same app_id has a row with a larger batch_id: Spark commits the batch after this row); min_lsn = max_lsn is the change's LSN, detail says what changed. 'data_skipped' (failOnDataLoss=false), with the batch_id of the batch that skipped them and rows = 0: CDC cleanup had purged changes the stream had not read, and the batch went on from what it left; min_lsn = max_lsn is the capture instance's min_lsn it resumed at, lost_from_ts and lost_to_ts the gap, detail the LSNs skipped and certain true. A task that finds cleanup ran while it read a range of the batch writes one too, with certain false: changes of that range may be missing, not certainly; min_lsn = max_lsn is the min_lsn it found. Downstream rebuilds only from 'bootstrap' and 'resnapshot' rows: after a 'data_skipped' row the target lacks the skipped changes, or may lack them, (a row deleted in the gap stays) until a new snapshot.
lost_from_ts TIMESTAMP_NTZ On 'resnapshot' rows, the UTC commit time of the last offset the stream had processed. Changes committed after it and before lost_to_ts were purged unread: the target has the rows as of the snapshot, but those changes are missing from its change history. On 'data_skipped' rows, the same for the batch's start offset: the changes it skipped were committed after it and before lost_to_ts, and are missing from the target (or may be, when detail says certain false). NULL on other rows, and when the stream had processed no offset since an explicit startingLsn.
lost_to_ts TIMESTAMP_NTZ On 'resnapshot' and 'data_skipped' rows, the UTC commit time of the CDC retention watermark (sys.fn_cdc_get_min_lsn) when the loss was detected: where the gap in the change history ends. NULL on other rows.
source_max_commit_ts TIMESTAMP_NTZ How far CDC capture had got when the batch was read: the commit time (UTC) of sys.fn_cdc_get_max_lsn, the latest seen by the batch's partitions after reading. The stream can read nothing newer than this. NULL unless the source option metricsPath and delta_sink(metrics_path=...) are set.
capture_lag_seconds DOUBLE How stale CDC capture was: seconds from source_max_commit_ts to the moment a partition read it (its own clock, UTC), the largest over the batch's partitions. Seconds on a busy database; up to about 5 minutes on a quiet one, where capture writes an idle entry that often, unless the heartbeat job runs. Growing beyond that means capture is slow (a large log backlog), whatever the stream does. A stopped capture (capture job or SQL Server Agent down) does not show here: max_lsn freezes, no batch runs and the last value stays small; only facts stop arriving. For that, use now minus latestOffset.commit_ts in the query progress. Clock skew between the Spark nodes and SQL Server shifts it. NULL under the same condition as source_max_commit_ts.
ingestion_lag_seconds DOUBLE Seconds between end_commit_ts and source_max_commit_ts: how far the stream's position (the batch's end offset) is behind what CDC capture had processed. Near 0 for a current stream, on a quiet table too, since a batch that read no rows also writes its row. Growing means the stream is falling behind, and as it grows retention_headroom_hours shrinks. Only moves while the stream runs: also alert when facts stop arriving (the stream or CDC capture has stopped). Rows written before end_commit_ts existed measured from max_commit_ts, which also counted the time from the table's last change to the database's newest commit. NULL under the same condition as source_max_commit_ts.
end_lsn STRING The batch's end offset: the commit LSN (0x + 20 hex) the stream had processed up to after this batch, the largest to_lsn of its partitions. At or after max_lsn: offsets follow CDC capture (sys.fn_cdc_get_max_lsn), which moves with idle entries and with other tables' commits, so on a quiet table it keeps moving while its batches read no rows. On event rows, the snapshot's or the change's LSN. NULL unless the source option metricsPath and delta_sink(metrics_path=...) are set, and on a batch that planned no range to read (a new checkpoint's first batch when nothing is new).
end_commit_ts TIMESTAMP_NTZ Commit time (UTC) of end_lsn: how far through the source's commit history the stream had read after this batch, whether the batch had rows or not. retention_headroom_hours and ingestion_lag_seconds are measured from it. Later than max_commit_ts, the batch's last change, when the table changed less recently than the database. On event rows, the snapshot's or the change's commit time. NULL under the same condition as end_lsn.
detail STRING On 'schema_change' rows, the DDL statement; on 'capture_instance_switched' rows, JSON {old, new, start_lsn, not_captured, text}: the older and the newer capture instance, the newer one's start LSN (min_lsn), the columns the query reads that the newer one does not capture (NULL from then on), and text, 'old -> new' then those columns, the line alone on rows written before 1.0.0rc1. JSON on the rows of snapshots: 'snapshot_open' {mode ('full' or 'chunked'), kind ('bootstrap' or 'resnapshot'), generation, lost_from_ts, lost_to_ts, and on a chunked one opened after a switch to a newer capture instance switch_batch_id, the batch_id of its 'capture_instance_switched' row}; 'snapshot_plan' {snapshot, kind, keys, chunk_rows, chunk_count}, its chunks fixed while the snapshot is open; 'snapshot_chunk' {snapshot, chunk, wave, last}, last true on the plan's final chunk, whose read completes the snapshot; a chunked snapshot's 'bootstrap' or 'resnapshot' row {snapshot, chunks, rows, last_lsn}. A chunked snapshot's key values are in the plans table next to bronze (_snapshot_plans), with the detail of each of its rows whole: on 'snapshot_open' keys (the key columns) and plan (the key's extent), on 'snapshot_plan' chunks (every chunk's [lo, hi]), on 'snapshot_chunk' lo and hi (its key range). A snapshot opened before 0.7.0 has them here instead: plan {kind: 'int', lo, hi, rows} for one integer key, else {kind: 'keyset', max}, read just after S; chunks in order, fixed while the snapshot is open; a chunk's range from lo (inclusive) to hi (exclusive), null for an open end and a list for a composite key. On 'data_skipped' rows, JSON {from, to, certain}: the first LSN not read and the min_lsn reading resumed at, certain true; or, from a task that found cleanup had run while it read a range, the range's first LSN and the min_lsn it found, certain false, and a reason. On a micro-batch row, JSON {warnings}: the warnings the reader logged on the driver up to planning the batch that no earlier batch's row holds, such as columns it does not read (computed ones, or those a newer capture instance captures that the columns option leaves out), options it does not know, or the UTC offset it converts commit times with on a server older than SQL Server 2022; set only when there are any, and only with the source option metricsPath and delta_sink(metrics_path=...). NULL on other rows.
target STRING Table name or path the batch was written to.
written_at TIMESTAMP_NTZ When this facts row was written, after the target commit, UTC.

The facts hold no source key values. A chunked snapshot's key values, its key's extent and the bounds of its chunks (with a composite or non-integer key, real keys of the table every chunk_rows rows), are in the snapshot plans table next to bronze, and in the userMetadata of each wave's commit in bronze's history; give both bronze's access policy. A snapshot opened before 0.7.0 keeps them in its facts rows to its end, as that release wrote them, so jobs of both releases that share the facts read them; give such a facts table bronze's access policy until its snapshots complete (ADR 0028).

The event values, the keys of these JSON payloads and those of a wave's userMetadata are state a later release reads back: a release only adds keys, never renames or removes one, so a query that parses detail keeps working across upgrades (ADR 0021). The one exception is the key values above, which 0.7.0 moved to the snapshot plans table, whose detail holds each payload whole.

Their types, TypedDicts exported from mssql_cdc, for Python that reads them (d: SnapshotChunkDetail = json.loads(row["detail"])):

Payload Type
detail of a 'snapshot_open' row SnapshotOpenDetail
detail of a 'snapshot_plan' row SnapshotPlanDetail
detail of a 'snapshot_chunk' row SnapshotChunkDetail
detail of a chunked snapshot's 'bootstrap' or 'resnapshot' row SnapshotCompletionDetail
detail of a 'data_skipped' row DataSkippedDetail
detail of a micro-batch row BatchDetail
userMetadata of a wave's commit in bronze WaveMetadata

The kinds of row, and the Delta txnAppId and txnVersion that make each write idempotent:

Row event batch_id rows app_id Idempotency key
Micro-batch NULL the batch's change rows, 0 when none the sink's <app_id>#facts, the batch id
Initial snapshot, or a seed from a copy bootstrap NULL snapshot rows as passed to to_delta or seed <app_id>#events, 0
Re-snapshot after data loss resnapshot NULL snapshot rows the new generation's, <app_id>.g<n> <app_id>#events, n
A chunked snapshot after a capture instance switch (snapshot_on_switch=True), complete bootstrap NULL its chunks' rows the generation's <app_id>#snapdone.<S>, 0
A snapshot opened (to_delta with a facts table): chunked once per generation, full at each run snapshot_open NULL 0 the generation's (<app_id> or <app_id>.g<n>) chunked: <app_id>#snapshots, the generation; full: none, each run writes its own
A chunked snapshot opened after a capture instance switch snapshot_open NULL 0 the generation's <app_id>#snapopen.<S>, 0
A chunked snapshot's chunks planned snapshot_plan NULL 0 the generation's <app_id>#snapplan.<S>, 0
A chunk of it read snapshot_chunk NULL the chunk's rows the generation's <app_id>#snapchunks.<S>, the wave
DDL on the source schema_change the batch's 0 the sink's in the batch's own commit
Switch to a newer capture instance capture_instance_switched the batch's 0 the sink's in the batch's own commit
Purged changes skipped (failOnDataLoss=false), or possibly missed by a task that cleanup overtook data_skipped the batch's 0 the sink's in the batch's own commit

A chunked snapshot's rows in its plans table are each written just before their facts rows, with the same key in that table, but for 'snapshot_open': its row there has <app_id>#snapopen.<S>, 0, so a rerun that opens it at another S writes its own.

The sink's app_id is the one passed to to_delta, or <app_id>.g<n> in generation n. Statistics over micro-batches filter event IS NULL; the snapshots downstream rebuilds from are event IN ('bootstrap', 'resnapshot'). A chunked snapshot's bootstrap or resnapshot row comes once backfill() has read its last chunk, with the same key as a whole one's: its min_lsn = max_lsn is S, rows the sum of its chunks. The one a switch to a newer capture instance opens has keys by its S instead, as its generation's own may be taken; its snapshot_open detail has switch_batch_id, and S is the newer instance's start LSN, which the stream had read past (the min_lsn of its capture_instance_switched row).

SELECT app_id, batch_id, rows, end_commit_ts, retention_headroom_hours, ingestion_lag_seconds
FROM ops.ingestion_facts
WHERE target = 'bronze.orders' AND event IS NULL
ORDER BY written_at DESC
LIMIT 20;

What to alert on: Monitoring. The metric columns and end_lsn are NULL without metricsPath.

Snapshot plans

The key values of a chunked snapshot, next to bronze: what its 'snapshot_open', 'snapshot_plan' and 'snapshot_chunk' facts rows leave out (ADR 0028). You do not name it: to_delta creates it when it opens a chunked snapshot of target, as <target>_snapshot_plans (bronze.orders_snapshot_plans, or the sibling directory .../orders_snapshot_plans of a path), and backfill(), apply_changes and reconcile find it by bronze's name.

Key values of the chunked snapshots stream().backfill() reads into a bronze table, kept by mssql-cdc-pyspark next to it, under its name with _snapshot_plans appended: one row per 'snapshot_open', 'snapshot_plan' and 'snapshot_chunk' row of the ingestion facts table, with that row's detail whole. The facts rows leave these values out; they are values of the source table's key, so give this table bronze's access policy.

Column Type Comment
snapshot STRING The snapshot's LSN S (0x + 20 hex): min_lsn of its 'snapshot_open' facts row, and _snapshot of its rows in bronze.
event STRING The facts row of the snapshot this one completes: 'snapshot_open', 'snapshot_plan' or 'snapshot_chunk' (with the chunk and wave of its detail).
detail STRING JSON, the facts row's detail with the keys it leaves out: 'snapshot_open' {mode, kind, generation, lost_from_ts, lost_to_ts, keys, plan, and switch_batch_id on one opened after a capture instance switch}, keys the columns the chunks are cut on and plan the key's extent read just after S ({kind: 'int', lo, hi, rows} for one integer key, else {kind: 'keyset', max}); 'snapshot_plan' {snapshot, kind, keys, chunk_rows, chunk_count, chunks}, chunks the [lo, hi] of every chunk in order, fixed while the snapshot is open; 'snapshot_chunk' {snapshot, chunk, wave, lo, hi, last}, the key range the chunk was read in, from lo (inclusive) to hi (exclusive). A bound is null for an open end and a list for a composite key.

Control

The completeness verdict, finalized_until, one row per table, and the position of each silver table.

Completeness verdict per CDC-fed table, kept by mssql-cdc-pyspark: one row per table. Gate downstream work on finalized_until, e.g. with finalization.is_final().

Column Type Comment
table_name STRING The table this verdict is about: the name passed to finalization.advance(), usually the target table. One row per table.
finalized_until TIMESTAMP_NTZ The verdict, UTC. Every period that ends at or before this instant is complete in the table: no source commit at or before it can still arrive. Over a recorded loss gap (lost_from_ts..lost_to_ts of the facts' 'resnapshot' rows), a change-log table's verdict means only that nothing more will arrive, not that the gap's changes are in it. It only moves forward. A consumer of the period [start, end) waits for finalized_until >= end.
end_lsn STRING Source commit LSN (0x + 20 hex) of the batch end that last moved the verdict: how far the source had been read and committed to the table.
end_commit_ts TIMESTAMP_NTZ Commit time of end_lsn, UTC. finalized_until is this instant truncated to the period (an hour by default), because transactions sharing this exact commit time may still be arriving.
updated_at TIMESTAMP_NTZ When the verdict last moved, UTC.
applied_lsn STRING Tables built by mssql_cdc.apply_changes: the highest source commit LSN (0x + 20 hex) of the bronze changes applied to the table; the next call reads the changes after it. NULL for other tables.
snapshot_lsn STRING Tables built by mssql_cdc.apply_changes: the LSN of the bronze snapshot the table was last rebuilt from; a newer snapshot rebuilds it. NULL for other tables and before the first snapshot.
open_snapshot_lsn STRING Tables built by mssql_cdc.apply_changes: the LSN of the chunked snapshot, bootstrap or re-snapshot, still being read (its 'snapshot_open' facts row, no completion row yet) whose chunks are applied as they arrive. NULL for other tables and when none is open.
snapshot_wave INT Tables built by mssql_cdc.apply_changes: the last wave of open_snapshot_lsn's chunks applied to the table; the next call applies the later ones. NULL when none is.

finalization.advance MERGEs on table_name and updates the row only when the new verdict is later, so it never moves back. apply_changes records applied_lsn and snapshot_lsn for its silver table (and, while a chunked snapshot is open, open_snapshot_lsn and snapshot_wave), then advances that table's verdict. Every table shares the control table, and in OSS Delta two MERGEs on it conflict even when they change other rows, so both retry a MERGE that loses to a concurrent commit, for up to a minute. See Finalization and Silver.

SELECT table_name, finalized_until, end_lsn, applied_lsn, updated_at
FROM ops.table_finalization;

Silver

The current state of the source table, built by apply_changes.

Current state of a SQL Server table, one row per key, built from the bronze change log by mssql-cdc-pyspark's apply_changes. Deleted rows are removed. How far it is applied is applied_lsn in the control table; finalized_until there says which periods are complete.

Column Type Comment
captured columns as in bronze none
_start_lsn STRING Commit LSN (0x + 20 hex) of the source transaction that wrote this row's current image; on a row unchanged since a snapshot, the snapshot's LSN.
_commit_ts TIMESTAMP_NTZ Commit time of _start_lsn, UTC.

A column bronze gains is added to silver (older rows read NULL); a column's type never changes (ADR 0019).

Reconcile report

What each reconcile() run found, appended when it is given a report_table (Validation).

Report of mssql-cdc-pyspark's reconcile(), which compares a silver table with its SQL Server table. Each run adds one row per bucket of the key's range (the rows and the key sum on each side) and one row per key whose row differs in the buckets compared row by row; run_id groups them.

Column Type Comment
run_id STRING Id of the reconcile() run (a UUID): every row it wrote has it.
run_at TIMESTAMP_NTZ When the run started, UTC.
silver STRING The silver table compared (a name or a path).
silver_version BIGINT The Delta version of silver compared, the same in the run.
silver_lsn STRING Silver's applied_lsn (0x + 20 hex) in the control table, read before that version: silver holds the changes up to it.
source_lsn STRING sys.fn_cdc_get_max_lsn() read just before the source was read: the source held every commit up to it. Bucket rows: before the counts; key rows: before the rows; NULL on chunk rows.
bucket_lo STRING First key of the bucket, inclusive, as JSON: a value for a one-column key, a list for a composite one. NULL: open (both NULL: the whole table, counted as one). Chunk rows: the chunk's first key.
bucket_hi STRING Key the bucket ends before, exclusive, as JSON. NULL: open. Chunk rows: the chunk's.
source_rows BIGINT Bucket rows: the bucket's rows in the source table.
source_key_sum DECIMAL(38,0) Bucket rows: the sum of the bucket's keys in the source table (a date as its day number from 1970-01-01); NULL on a whole-table count.
silver_rows BIGINT Bucket rows: the bucket's rows in silver.
silver_key_sum DECIMAL(38,0) Bucket rows: the sum of the bucket's keys in silver.
status STRING Bucket rows: MATCH (rows and key sums equal), IN_FLIGHT (they differ, and bronze holds a change to the bucket newer than what one side read: check again later) or MISMATCH (they differ). NULL on key and chunk rows.
hashed BOOLEAN Bucket rows: whether its rows were also compared one by one (every MISMATCH and a sample of the rest); the key rows of the run in it are what differs.
key STRING Key rows: the key of a row that differs, as a JSON object. NULL on bucket and chunk rows.
failure_type STRING Key rows: MISSING_TARGET (in the source, not in silver: an insert not applied), MISSING_SOURCE (in silver, not in the source: a delete not applied, or a stale key), RECORD_DIFF (in both with other values: an update not applied) or IN_FLIGHT (differs, and bronze holds a change to the key newer than what one side read: check again later). Chunk rows (with facts_table, for the newest chunked snapshot of bronze): CHUNK_TILING (its chunks leave a gap or overlap: an index missing or twice, the first not open below, one not starting where the one before ended, the last of a complete snapshot not the plan's final one), CHUNK_ROWS (a chunk's 'snapshot_chunk' facts row counts other rows than bronze holds of it) or CHUNK_STAMP (a chunk stamped below the snapshot's LSN).
detail STRING Key rows: JSON with the silver row's _start_lsn (silver_start_lsn) and, when both sides have the row, the columns that differ (columns). Chunk rows: JSON with the snapshot's LSN (snapshot), the chunk and what was found.

Three kinds of row share it, told apart by which columns are set:

Row Set NULL
Bucket status, the counts and key sums, hashed, source_lsn key, failure_type, detail
Key that differs key, failure_type, detail, source_lsn and the bucket's bounds status, the counts
Chunk failure (with facts_table) failure_type (CHUNK_...), detail, the chunk's bounds key, status, source_lsn
SELECT status, count(*) FROM ops.reconcile_report
WHERE run_id = '<run_id>' AND status IS NOT NULL GROUP BY status;

Schema versions

Each table carries the table property mssql_cdc.schema_version: the number of migrations its kind has had. A new table is created at the latest version. An older one gets the missing migrations, in order, the next time a stream, finalization.advance or apply_changes opens it (ADR 0013). The versions, from mssql_cdc.migrations.<kind>.MIGRATIONS:

Kind Version Migrations
bronze 2 1 capture instance comments, 2 snapshot and chunk columns
facts 15 1 network and read metrics, 2 retention headroom, 3 snapshot events, 4 lag metrics, 5 end offset, 6 source change events, 7 chunked snapshot events, 8 snapshot plans and modes, 9 full snapshot opens per run, 10 skipped changes, 11 possible skips, 12 planning warnings, 13 key values in the plans table, 14 snapshots after a capture instance switch, 15 capture instance switch detail as JSON
control 4 1 add applied_lsn and snapshot_lsn, 2 add open_snapshot_lsn and snapshot_wave, 3 open re-snapshots too, 4 the verdict over a loss gap
silver 0 none
reconcile 0 none
plans 1 1 snapshots after a capture instance switch

A migration that adds columns appends them, so in a table created by an older release they come after written_at (facts), updated_at (control) or _batch_id and the captured columns (bronze): select columns by name. The other table properties the library sets, and the ones it leaves to you, are below.

Jobs that share a table can upgrade one at a time: a job on an older release keeps writing a table a newer release migrated, and logs a WARNING. A future migration that older releases would misread will set mssql_cdc.min_version, and releases that know fewer migrations than it names refuse the table with a ValueError; no migration sets it yet.

SHOW TBLPROPERTIES ops.ingestion_facts ('mssql_cdc.schema_version');

Table properties

Besides mssql_cdc.schema_version, the library sets one property, and only where a table cannot work without it (ADR 0012): delta.columnMapping.mode = 'name', on a bronze or silver table it creates with a captured column whose name Delta refuses otherwise: one with a space or one of ,;{}()=, such as [Unit Price] or [Qty (kg)]. Column mapping raises the table's Delta protocol, so every reader and writer of that table needs a Delta that supports it. A table created without it does not get it later: when a newer capture instance adds such a column, the stream and apply_changes fail before writing, until you enable it (Schema changes).

Everything else is yours to enable, where every reader and writer of the table supports it, since each one changes the table's protocol or how its files are laid out:

  • Compaction: a scheduled OPTIMIZE (and VACUUM) on bronze, silver and the facts table, or optimized writes and auto compaction where the runtime has them (Streaming).
  • Deletion vectors on silver (delta.enableDeletionVectors = true): the apply_changes MERGE marks the rows it replaces instead of rewriting whole files. On Databricks, adding row tracking (delta.enableRowTracking = true) lets concurrent writes to different rows go through without conflict.
  • Clustering silver by its keys, with liquid clustering (ALTER TABLE ... CLUSTER BY) where the runtime has it, or OPTIMIZE ... ZORDER BY the keys: a key range lives in fewer files, so each MERGE rewrites fewer.
  • Type widening (delta.enableTypeWidening = true) on bronze, so a widened source column does not stop the stream (Schema changes).