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_idandtxnVersion= the batch id, so a replayed batch is skipped. ItsuserMetadataholds the batch's facts as JSON:rows,min_lsn,max_lsn,min_commit_ts,max_commit_ts,deletes,inserts,updates,batch_idandapp_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 itsuserMetadatais written first:rowstoupdatesare 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
userMetadatais{"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_lsnand no_chunk. - A chunked snapshot is one append per wave of
backfill(), withtxnAppId<app_id>#snap.<S>andtxnVersionthe wave, so a rerun of a wave is skipped. ItsuserMetadatais{"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_chunkand the wave's stamp in_start_lsn, at or after S (Bootstrap). Where the platform refuses to cache the wave, a chunk'srows,high_lsn,read_secondsandread_mbare null there; itssnapshot_chunkfacts row has them (ADR 0032). - Every append uses
mergeSchema. A changed column type fails it withSchemaChangedErrorunless the table hasdelta.enableTypeWideningand the change widens (Schema changes).
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 ( |
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.
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.
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(andVACUUM) 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): theapply_changesMERGE 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, orOPTIMIZE ... ZORDER BYthe 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).