Monitoring¶
The facts table answers the questions an on-call engineer asks about a CDC stream: is it running, how far behind is it, how close is it to losing data, and why was that batch slow. It holds one row per micro-batch, batches that read nothing included, plus one row per snapshot and per change to the source table.
Turn it on¶
Pass facts_table to to_delta:
from mssql_cdc import stream
query = stream(spark, options).to_delta(
"bronze.orders",
app_id="orders-v1",
checkpoint="/data/checkpoints/orders",
facts_table="ops.ingestion_facts",
)
The counts and ranges are always there. The read, retention and lag columns come from
files each partition leaves in a metrics directory (option metricsPath), which the sink
folds into the batch's row. to_delta puts that directory under the checkpoint when the
checkpoint is a path Python can write on every node (local, or a FUSE mount). With a URI
checkpoint (s3a://, abfss://...), set metricsPath yourself, to such a path or to a
URI pyarrow.fs opens with credentials every node has (s3://, abfss://...);
the files then go to <metricsPath>/<app_id>, so streams may share one
(Options,
ADR 0014).
Without it those columns are NULL. The sink never connects to SQL Server itself.
What a row holds¶
A micro-batch row (event NULL):
| Group | Columns | Filled |
|---|---|---|
| Identity | app_id, batch_id, target, written_at |
always |
| What was written | rows, inserts, updates, deletes, min_lsn, max_lsn, min_commit_ts, max_commit_ts |
always; ranges NULL when rows = 0 |
| Timing | started_at, duration_ms |
always |
| Where the stream is | end_lsn, end_commit_ts |
with metrics |
| Read and network | source_rtt_ms, read_seconds, read_mb, network_wait_ms |
with metrics |
| Retention | retention_watermark_ts, retention_headroom_hours |
with metrics |
| Lag | source_max_commit_ts, capture_lag_seconds, ingestion_lag_seconds |
with metrics |
| Warnings | detail, JSON {"warnings": [...]} |
with metrics, when the reader warned (below) |
Event rows (event set) are different: min_lsn, max_lsn and end_lsn all hold the
event's LSN, and the commit-time columns its commit time, whatever rows says. Their
started_at and duration_ms are NULL, except on a snapshot row whose snapshot that run
took (a reused one also has rows NULL). detail is set on source changes,
lost_from_ts and lost_to_ts on re-snapshots and data_skipped rows.
Every column's comment is in Tables. Times are UTC, as
TIMESTAMP_NTZ. The event column tells the kinds of rows apart:
- NULL: a micro-batch. One whose end offset moved only past idle entries or other tables'
commits has
rows = 0and wrote nothing to the target. bootstrapandresnapshot: a snapshot, with nobatch_id(Bootstrap, Data loss).schema_changeandcapture_instance_switched: DDL on the source table or a move to a newer capture instance, with thebatch_idof the batch that read past it,rows = 0and the change indetail(Schema changes).data_skipped: purged changes skipped withfailOnDataLoss=false, with thebatch_idof the batch that skipped them,rows = 0, the gap inlost_from_tsandlost_to_tsand the LSNs skipped indetail, whosecertainis false when a task found cleanup ran while it read: changes may be missing, not certainly (Data loss).
Statistics over micro-batches filter event IS NULL.
Health of every stream¶
One query covers five alerts, on the latest micro-batch row of each target.
convert_timezone('UTC', localtimestamp()) is the current UTC time as TIMESTAMP_NTZ,
whatever the session time zone:
WITH last AS (
SELECT
target,
timestampdiff(SECOND, max(written_at), convert_timezone('UTC', localtimestamp())) / 3600.0
AS hours_since_last,
max_by(retention_headroom_hours, written_at) AS headroom_hours,
max_by(ingestion_lag_seconds, written_at) AS ingestion_lag_seconds,
max_by(capture_lag_seconds, written_at) AS capture_lag_seconds
FROM ops.ingestion_facts
WHERE event IS NULL
GROUP BY target
)
SELECT *
FROM last
WHERE hours_since_last > 0.25 -- no facts for 15 minutes
OR headroom_hours - hours_since_last < 24 -- less than a day before cleanup catches up
OR headroom_hours IS NULL -- no metrics: the alerts above are blind
OR ingestion_lag_seconds > 3600 -- the stream is an hour behind capture
OR capture_lag_seconds > 600; -- capture is behind the database
Drop the WHERE to see every stream; the thresholds are examples. Keep the IS NULL line
for streams expected to have metrics (all of them under to_delta with a facts table and a
local or Volume checkpoint).
Metrics are NULL¶
A comparison with NULL is never true, so without metrics the headroom and lag conditions
never fire, and only the liveness one is left. On a stream that should have them, NULL
metrics mean the executors' files never reached the driver: the metrics directory is not
shared by every node (a driver-local checkpoint on a multi-node cluster, where each executor
writes to its own disk), or not writable there (a URI metricsPath the executors have no
credentials for: each executor logs a warning per file). The sink logs a warning on the
driver, once per run, when a batch that read rows found no metrics file. Use a path every
node shares, such as a Volume, or a URI every node can write
(metricsPath).
Facts stopped arriving¶
A running stream writes a row on every trigger, so silence means
the stream stopped, or CDC capture did (max_lsn freezes and no batch runs). For a stream
that keeps running (a processingTime trigger or the default), expect a row at least every
5 minutes (the idle entries of a quiet
database; about 10 seconds with the
heartbeat) plus the trigger interval.
A job run with availableNow on a schedule writes only while it runs: use the schedule
interval. To tell the stream from capture, see live capture lag and
capture from the SQL Server side below.
Retention headroom falls¶
retention_headroom_hours is how far the stream's position is
ahead of what CDC cleanup has deleted. A current stream sits near the retention period (3
days by default) and the value falls by the hours the stream lags; at 0 the next changes
are being purged and the stream stops with DataLossError (or re-snapshots, with
on_data_loss="resnapshot"). Cleanup moves the watermark in
steps (the default job runs daily), so keep a margin larger than that, and subtract the
hours since the last row: a stopped stream keeps its last value while the real headroom
shrinks (ADR 0017).
Ingestion lag grows¶
ingestion_lag_seconds is how far the stream is behind what CDC
capture has processed: slow batches, a sparse trigger, a stopped job. It is near 0 for a
current stream, on a quiet table too. What it gains, the headroom loses.
Capture lag grows¶
capture_lag_seconds is how stale CDC capture itself was when the
batch read: a log backlog, for the DBA rather than for the stream. On a quiet database
without the heartbeat it sits up to about 5 minutes, so alert above that
(ADR 0020).
Freshness of the verdict¶
The facts say the data arrived; consumers wait on finalized_until. A verdict that stops
moving (a tracker whose advance keeps failing, a job that no longer calls it) shows only
in the control table. Alert per table when it falls behind by more than the period
(granularity), plus the trigger or schedule interval, plus a margin. From Python,
finalization.freshness reads one table's row:
from mssql_cdc import finalization
f = finalization.freshness(spark, "ops.table_finalization", "bronze.orders")
age = f["age_seconds"] # None before the first verdict
if f["open_snapshot_lsn"] is None and (age is None or age > 76 * 60):
raise RuntimeError(f"bronze.orders: complete only until {f['finalized_until']}")
age_secondsis how long agofinalized_until, the last final instant, was. The first period not final yet ends one period after it, so its consumers have waitedage_secondsless the period, when that is positive. A current stream keeps it under the period plus the trigger interval plus the idle entries' 5 minutes: 76 minutes above is an hour, a minute and a 15-minute margin.seconds_since_updateis how long ago the verdict last moved. Small whileage_secondsis large, the verdict moves but the stream is behind; large too, it stopped.open_snapshot_lsnnames the chunked snapshot that holds a silver table's verdict (Silver tables). That can last days and is expected: leave the table out of the alert until the snapshot completes.
The ages are measured with the clock of the machine that calls it, against UTC (API).
Every table at once, with the reason each one is stale, from the control table and the facts:
WITH batches AS ( -- each stream's newest micro-batch row
SELECT target, max(written_at) AS last_batch_at,
max_by(end_commit_ts, written_at) AS end_commit_ts
FROM ops.ingestion_facts
WHERE event IS NULL
GROUP BY target
),
verdicts AS (
SELECT c.table_name, c.finalized_until, c.updated_at, c.open_snapshot_lsn, c.applied_lsn,
b.last_batch_at, b.end_commit_ts,
timestampdiff(MINUTE, c.finalized_until, convert_timezone('UTC', localtimestamp()))
AS age_minutes,
timestampdiff(MINUTE, b.last_batch_at, convert_timezone('UTC', localtimestamp()))
AS minutes_since_batch
FROM ops.table_finalization AS c
LEFT JOIN batches AS b ON b.target = c.table_name
)
SELECT table_name, finalized_until, updated_at, age_minutes, minutes_since_batch,
CASE
WHEN open_snapshot_lsn IS NOT NULL THEN 'held'
WHEN applied_lsn IS NOT NULL THEN 'silver'
WHEN last_batch_at IS NULL THEN 'no facts'
WHEN minutes_since_batch > 15 THEN 'stopped'
WHEN end_commit_ts > finalized_until + INTERVAL 76 MINUTES THEN 'not advanced'
ELSE 'behind'
END AS reason
FROM verdicts
WHERE finalized_until IS NULL
OR age_minutes > 60 + 1 + 15; -- granularity "hour", the trigger interval, a margin
reason |
What it means | Next |
|---|---|---|
held |
a chunked snapshot of this silver table is open | expected until it completes: watch the snapshot's progress (what backfill() returns) instead |
silver |
an apply_changes table: its verdict follows its bronze's |
bronze's row; if bronze is current, apply_changes stopped running or fails |
no facts |
no micro-batch row under this name | a stream without facts_table, or a control row named otherwise than the facts' target (Finalization) |
stopped |
no micro-batch row for 15 minutes | the stream stopped, or CDC capture did: below |
not advanced |
the stream read past the verdict's next period, the verdict stayed | a tracker failing (last_error, the driver's log) or a job that no longer calls advance (Finalization) |
behind |
facts arrive and the verdict follows them, both late | the stream lags capture: ingestion_lag_seconds in the health query |
A stopped stream and stopped capture look the same from the lake: neither writes facts. Capture
is per database, so when every stream of one source database stops at once, suspect capture
(or the one job running them all), and confirm on the SQL Server side
(below); when one stops while the others write, the stream
did. For a job run with availableNow on a schedule, replace the 15 minutes with the
schedule interval and the margin.
Why a batch was slow¶
SELECT
batch_id, rows, duration_ms, read_seconds, read_mb, source_rtt_ms, network_wait_ms,
round(try_divide(network_wait_ms, read_seconds * 1000), 2) AS network_share
FROM ops.ingestion_facts
WHERE target = 'bronze.orders' AND event IS NULL AND rows > 0
ORDER BY written_at DESC
LIMIT 20;
network_wait_ms is the time SQL Server waited for the client to take the rows
(ASYNC_NETWORK_IO). A network_share close to 1 with a high source_rtt_ms means the
link set the pace; a low share with a long read means the server did. read_seconds sums
the partitions (task-seconds), so with parallel partitions it can exceed duration_ms.
Snapshots and source changes¶
SELECT written_at, target, event, detail, min_lsn, rows, lost_from_ts, lost_to_ts
FROM ops.ingestion_facts
WHERE event IS NOT NULL
ORDER BY written_at DESC;
A resnapshot row is worth an alert of its own: the changes committed between
lost_from_ts and lost_to_ts were purged before the stream read them.
Warnings¶
The reader's warnings go to the log of the Python worker Spark runs the source in, on the
driver, which jobs rarely keep. Those about a change on the source are event rows (above);
the others reach the detail of the row of the next micro-batch with a range to read, as
JSON {"warnings": [...]}:
- columns it does not read: a computed column, left out of the inferred schema or listed in
columns(NULL in every row), and the columns a newer capture instance captures thatcolumnsleaves out (Schema changes); - options it does not know, a misspelt name whose default stays in force
(Options), and
maxCommitsPerBatchon a Spark without admission control, which ignores it; - the UTC offset it converts commit times with on a server older than SQL Server 2022, when it takes it and when it changes (sourceTimeZone).
Each is logged, and recorded, once per run (the offset again when it changes), so a restarted stream writes them again: in the row of its first batch, or of the next one when the first replays a batch whose row the crashed run had written.
SELECT written_at, target, batch_id,
from_json(detail, 'warnings ARRAY<STRING>').warnings AS warnings
FROM ops.ingestion_facts
WHERE event IS NULL AND detail IS NOT NULL
ORDER BY written_at DESC;
Live capture lag¶
The facts cannot show a stopped capture: no batch runs, so the last capture_lag_seconds
stays small. The query progress can. On every trigger, idle ones included, the source
reports max_lsn and its commit time as latestOffset:
import json
from datetime import datetime, timezone
latest = json.loads(query.lastProgress.json)["sources"][0]["latestOffset"]
now = datetime.now(timezone.utc).replace(tzinfo=None)
capture_lag = now - datetime.fromisoformat(latest["commit_ts"])
If facts stopped arriving while the query still runs and this lag grows, capture has stopped (capture job or SQL Server Agent down).
Capture, from the SQL Server side¶
An alerting setup built on SQL cannot read lastProgress. The DBA's side of the same
question is SQL Server's own CDC views, in the source database
(Administer and monitor change data capture):
-- the newest log scans: latency is how far capture is behind, in seconds
SELECT TOP (5) start_time, end_time, latency, tran_count, command_count, error_count
FROM sys.dm_cdc_log_scan_sessions
WHERE session_id > 0 -- session 0 aggregates every scan since the instance started
ORDER BY start_time DESC;
-- errors of the recent scans
SELECT TOP (20) entry_time, error_number, error_severity, error_message
FROM sys.dm_cdc_errors
ORDER BY entry_time DESC;
Alert when the newest scan is older than a few minutes or its latency grows, when
sys.dm_cdc_errors has new rows, and when the capture job (cdc.<database>_capture in
SQL Server Agent, listed by sys.sp_cdc_help_jobs) is not running. The views need
VIEW DATABASE STATE (VIEW DATABASE PERFORMANCE STATE on SQL Server 2022) and the job's
state needs rights in msdb: give them to a monitoring login, never to the stream's
least-privilege reader (Permissions). Both views reset when the instance
restarts.
With the liveness alert, the two tell the failures apart: facts stopped while capture is current means the stream stopped; facts stopped while capture stalls or errs means capture did.
Pitfalls¶
- Every micro-batch is a facts commit. A quiet stream with the heartbeat and the default
trigger runs a batch about every 10 seconds, some 8,600 rows and small files a day. Bound
the rate with a
processingTimetrigger, compact the facts table and bronze (auto compaction, or a scheduledOPTIMIZEandVACUUM;ZORDER BY (target, app_id)for the facts), and delete micro-batch rows older than a window, never event rows (Streaming). - Warnings raised while a batch is planned (a schema change, a capture instance switch,
captured columns that
columnsleaves out) are logged by the Python worker that Spark runs the source in on the driver. They land in the driver's stderr log, not in handlers your job attaches to themssql_cdclogger. The durable channel is the facts table: the event rowsschema_change,capture_instance_switchedanddata_skipped, and the warnings in a micro-batch row'sdetail, all of which need metricsPath. - Metrics are NULL without
metricsPath(a URI checkpoint without one), when the metrics directory is not shared by every node (above), and on the first batch of a new checkpoint that had nothing to read. - When you wire the sink by hand, give
delta_sink(metrics_path=...)the same directory as themetricsPathoption and use it for one stream only: the sink folds and removes every file in it. capture_lag_secondscompares the Spark node's clock with SQL Server's commit times; skew shifts it and can make a small lag negative.- Rows written before
end_commit_tsexisted measured the headroom and the ingestion lag frommax_commit_ts, which made a current stream on a quiet table look behind. - The facts table is the durable record. Bronze commits carry the same facts in
userMetadata(batches with rows only), but Delta log cleanup drops them.