0014: Network and read metrics in the ingestion facts¶
Status: accepted
Date: 2026-09-29T10:12:04-03:00
Amended: 2026-09-29T10:48:30-03:00, the round trip moved to the partitions; stream() declares the options once
Amended: 2026-09-29T22:15:55-03:00, a partition that read no rows leaves no file (see Amendment 2)
Amended: 2026-09-30T15:16:41-03:00, every partition leaves a file again, with the commit time of its last LSN; the sink folds every file present (see Amendment 3)
Amended: 2026-09-30T17:47:32-03:00, one directory per stream, emptied before each batch is read (see Amendment 4)
Amended: 2026-10-05T13:53:56-03:00, only the batch's last partition measures where the stream is (see Amendment 5)
Amended: 2026-10-09T11:14:11-03:00, a URI metricsPath goes through pyarrow.fs (see Amendment 6)
Context¶
On a production source the reader was network-bound: round trips of 180–950 ms, and the
server spending most of each read in ASYNC_NETWORK_IO (waiting for the client), not on
disk or CPU (lab t8 and a private benchmark). Throughput moved several-fold between runs
with the link, and nothing in the facts said why a batch was slow.
The measurements happen where the facts cannot see them: partitions read in Python workers on executors, planning runs in a worker on the driver, and the Python data source API has no custom metrics or accumulators. The offset cannot carry them (invariant 1) and the bronze schema should not.
Decision¶
CdcClient.ping(samples)(round trips ofSELECT 1) andnetwork_wait_ms()(the session'sASYNC_NETWORK_IOfromsys.dm_exec_session_wait_stats, which a session may read for itself withoutVIEW SERVER STATE).- Source option
metricsPath: each partition writes one JSON file there (LSN range, rows, bytes, seconds, the change inASYNC_NETWORK_IOover the read; since Amendment 3, also the commit time of its last LSN). delta_sink(..., source_options=..., metrics_path=...): the sink pings SQL Server once per batch over one connection kept for the query run (source_rtt_ms), sums the files of the batch's partitions (read_seconds,read_mb,network_wait_ms), writes them with the other facts and removes the files.- The four columns arrive as facts migration 1 (ADR 0013); their definitions live in
migrations/facts.pyand the creation schema reuses them. - Metrics are best effort: any error leaves them NULL and never fails a batch.
Consequences¶
- A slow batch can be read as network (
network_wait_msclose toread_seconds * 1000, highsource_rtt_ms) or server (low wait, long read) from the facts alone. source_rtt_mscosts one round trip per partition read (see the Amendment; it was first a driver-side ping by the sink).metricsPathmust be a directory every node can write and the driver can read: a local path on a single node, or a FUSE path such as a Unity Catalog Volume.read_secondssums partitions (task-seconds) and includes Spark taking the rows.
Amendment: no source options in the sink, one declaration¶
Measuring the round trip in the sink meant passing the stream's options (with the
credentials) to delta_sink as well and holding a second connection per query. Each
partition now makes one SELECT 1 on its own connection just before reading and records
it with its other metrics; source_rtt_ms is the median over the batch's partitions, and
delta_sink lost source_options: the sink never connects to SQL Server. Facts migration 1
was revised in place for the new meaning of source_rtt_ms, the same day it was written
and before any release.
mssql_cdc.stream(spark, options).to_delta(target, app_id, checkpoint, facts_table) wires
source and sink from one set of options. Knowing the checkpoint, it defaults metricsPath
to <checkpoint>/_mssql_cdc_metrics when that is a path Python can write on every node
(no URI scheme); otherwise metrics stay off unless metricsPath is set.
Amendment 2: only partitions that read rows leave a file¶
The sink skips a batch without rows, so it never folded or removed the files of an empty
batch's partitions, and they piled up in metricsPath on a quiet source. A partition that
read no rows now writes no file. The cost: in a batch with rows, a partition that read none
(with numPartitions > 1, for example a trailing range of idle mapping entries) still
pings and reads the wait counter, but is left out of read_seconds, network_wait_ms and
the source_rtt_ms median. Such a partition returns no data, so its share is small.
Amendment 3: every partition leaves a file again¶
Amendment 2 existed because the sink skipped a batch without rows. It no longer does: such a
batch writes its facts row too (rows = 0), so that the headroom and the ingestion lag are
measured from where the stream is, its end offset, on quiet tables as well (ADRs 0017 and
0020, amended the same day). So every partition writes its file again, rows or not, with one
more value: to_commit_ts, the commit time of its to_lsn (one more short query per
partition). The file with the largest to_lsn is the partition that ends at the batch's end
offset: its LSN and commit time become end_lsn and end_commit_ts (facts migration 5). On
a quiet table that partition often read nothing: with numPartitions > 1 it is the range
between the batch's last change and the end offset, when the offset is past that change.
That rules out picking files by the rows' LSNs, as the sink did: an empty batch has none, and
the trailing range lies past them. The sink now folds every file in metricsPath and removes
them all after the facts write, the unreadable ones too. Every file there is the current
batch's: micro-batches run one at a time, the partitions are read inside foreachBatch, and
a retried task or batch rewrites its file under the same name. The one exception is a retry
planned with a different split (a changed numPartitions), whose failed attempt's files
are folded once into the retry. The sink removes the files without a facts table too, and
stream() hands it the metrics path whenever metricsPath is set, so no file is left
behind. A batch that plans no range (a new checkpoint's first batch when nothing is new)
has no files: its row has rows = 0 and NULL metrics. Amendment 2's cost is gone:
partitions without rows count in read_seconds, network_wait_ms and the source_rtt_ms
median again.
Amendment 4: one directory per stream, emptied before each batch¶
Folding every file present is right only when the directory holds nothing but the current
batch's files. Two things broke that. Streams sharing one explicit metricsPath (a job
looping over tables with one options dict, the natural setup with a URI checkpoint) folded
and removed each other's files: one stream's end_lsn, headroom and lags came from
another's position, maybe another database's. And files left by an attempt that died after
some partitions wrote theirs were folded into a later batch: a new checkpoint's or
generation's first batch with an explicit metricsPath, or a replay planned with another
split, which numPartitions=auto produces without the user changing anything when the
cluster restarts with other cores. A stale to_lsn put end_lsn ahead of or behind the
real position, and the read metrics were counted twice.
Now:
* The sink empties the directory at the start of each batch, before the batch is read:
its partitions run only when the sink reads the batch, so anything there is a dead
attempt's. It removes the files after the facts write as before.
* stream() puts an explicit metricsPath's files under <metricsPath>/<sink app_id>
(<app_id>.g<n> in generation n), so its streams may share one metricsPath. The
default under the checkpoint is per stream already.
* Wired by hand, metricsPath must belong to one stream, and delta_sink needs the same
directory as metrics_path: nothing else removes the files, and every partition of every
batch writes one, so without it they pile up.
Amendment 5: the batch's position, measured once¶
Every partition measured the retention watermark (ADR 0017), how far capture had got and
its lag (ADR 0020), and the commit time of its own to_lsn. The first three describe the
whole batch, and the sink kept the latest or largest of each; of the fourth it used only the
partition with the largest to_lsn. They were four of each partition's nine queries.
Now the planner marks the batch's last range (LsnRange.last, the one that ends at the end
offset), and only it measures the four after its read; the other partitions leave their
read metrics (range, rows, bytes, seconds, round trip, network wait). A partition other
than the last runs 5 queries: the ping, the wait counter, the read, the retention guard
(invariant 4) and the wait counter again; the last runs those and max_lsn plus three
commit times (tests/test_reader_units.py pins both). Each runs one more, its range's UTC
offset (ADR 0008), where the server's clock is a named zone other than UTC. The sink is unchanged: its maximum
over the one value present is that value, and end_lsn is still the largest to_lsn. When
failOnDataLoss=false skipped everything up to the end offset in the batch's last capture
instance, the last range planned measures, as the largest to_lsn did before. The facts
columns keep their meaning; their comments, written for the per-partition values ("the
latest seen by the batch's partitions"), still hold for the one partition that reports.
With small tiles merged (ADR 0015, Amendment) a small batch is a single partition anyway;
this saves queries in large batches, four per range other than the last.
Amendment 6: a URI metricsPath through pyarrow.fs¶
The files were written with os.makedirs, open and os.replace and listed with glob, so
metricsPath had to be a local or FUSE path; a URI was refused, because s3://b/m would have
made a local s3: directory on each node. EMR, Dataproc and Spark Connect platforms have no
such shared path by default, and without one the metrics, the reader's events and
snapshot_on_switch are lost.
Now a metricsPath with a URI scheme is written, listed, read and removed through
pyarrow.fs.FileSystem.from_uri (pyarrow is already a dependency), one filesystem per
directory and process. A local or FUSE path goes through Python's file functions exactly as
before. The layout does not change: <metricsPath>/<sink app_id> through stream(), one
file per partition or event, a retried task rewriting its file under the same name. A URI
keeps its query (pyarrow's options, such as ?region=) last when stream() appends the
stream's directory.
- Whole files: a file goes under a temporary name and is moved over its own, as before. On a
filesystem whose move raises
NotImplementedErrorit is written under its own name in one upload instead, which an object store shows only once complete. Detected by that behaviour, not by the scheme. - The reader opens the filesystem when it is created, on the driver, where the planner
writes the events and the sink reads the files: a URI pyarrow cannot open (
dbfs:/, a missing driver library) is aValueErrorwhen the query starts, instead of a warning per file.to_deltadoes not check it: on Spark Connect it runs on the client, which need not reach the store. - Credentials are pyarrow's: its default chains on each node (environment, instance profile, managed identity) or options in the URI. On Databricks, Unity Catalog's storage credentials do not reach pyarrow, so a Volume stays the way there.
- Listing a URI that fails (a network error) logs a warning and folds nothing, so a batch never fails on its metrics; the events left there reach the next batch's row.
to_deltastill defaultsmetricsPathonly under a local or FUSE checkpoint: a URI checkpoint (dbfs:/,abfss://on Databricks) may be one pyarrow cannot write.
Tested with file:// URIs (pyarrow's local filesystem, through to_delta and backfill())
and with a filesystem that cannot move; not against a real object store.