Changelog¶
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning under the policy of ADR 0021 (amendment 11 from 1.0.0): a patch only fixes, a minor adds and deprecates, and only a major breaks the Python API, each break listed under "Breaking" with what to change. Before 1.0.0 a minor release could break it. The state a stream leaves behind (offsets, checkpoint layout, table schemas) never breaks without a migration path, and within 1.x it only grows. Every release says what it does to that state in its "State compatibility" line.
Unreleased¶
1.0.0rc1 - 2026-10-15¶
State compatibility: offsets and checkpoints are unchanged. The facts table gets migration
15, which rewrites the detail comment only (no mssql_cdc.min_version: older releases keep
writing it). A 'capture_instance_switched' row's detail is JSON from this release; rows
written before keep their text
(ADR 0021
amendment 12). Other payloads are unchanged. A micro-batch row's warnings may hold one more
message (on Spark without admission control).
Breaking¶
- Typing: the results that Spark Connect returns as its own class are typed as the union
(ADR 0031
amendment 2).
to_deltareturnsStreamingQueryLike(wasStreamingQuery),start_manyadict[str, StreamingQueryLike], andspark.get_sparkaSparkSessionLike(wasSparkSession). A type checker now flags code that annotated them with the classic class or used a member only one of the two has. At run time nothing changed. To migrate, annotate with the*Liketypes, or narrow (isinstance) orcastwhere you need the classic class. The newDataFrameLiketypesReconcileResult.report,seed()'sdfand the functiondelta_sinkreturns; it is the same type aspyspark.sql.DataFramefor a type checker (the Connect DataFrame subclasses it), so nothing to change there. HAS_ADMISSION_CONTROL,OPERATIONS,MssqlCdcDataSourceandmake_clientleftmssql_cdc.__all__(see "Deprecated"). A type checker now rejects importing them frommssql_cdc, andfrom mssql_cdc import *no longer binds them, without a warning. Import them from their modules instead: the first three frommssql_cdc.source,make_clientfrommssql_cdc.client, internal names that may change in any release.CdcClientleftmssql_cdc.__all__and the API reference: it is internal, and a minor release may add methods to it (ADR 0030 amendment 1; see "Deprecated"). A type checker now rejects importing it frommssql_cdc, andfrom mssql_cdc import *no longer binds it, without a warning. Import it frommssql_cdc.client.BackendandLsnstay public.
Added¶
- Build provenance: each release's sdist and wheel get a GitHub artifact attestation (SLSA
build provenance,
actions/attest) from the job that publishes them, before the upload; the TestPyPI dry run attests too. PyPI's PEP 740 attestations, uploaded with every file since 0.1.0, are now documented as well. SECURITY.md, "Verifying a release", shows how to check both (gh attestation verify ... --signer-workflow .../release.yml --source-ref refs/tags/vX.Y.Z,pypi-attestations verify pypi). CaptureInstanceSwitchedDetail, exported frommssql_cdcwith the other payload types: the JSON of a 'capture_instance_switched' row'sdetail.
Changed¶
- ADR 0021
amendment 11 sets the 1.x policy. From 1.0.0 on, versioning follows Semantic Versioning
strictly: a patch fixes, a minor adds, a major breaks, and a type-only break counts.
Anything public is deprecated in a 1.x minor before 2.0 removes it. The state contract
only grows. It now names the keys of the generation state file and the Delta
txnAppIds that make replays idempotent. Dropping support for a Python, Spark, Delta or SQL Server version that has reached its end of life is a minor release, announced here at least one release before; 1.0 keeps Python 3.10. Thewarnings, a task'sreasonand thetextof a 'capture_instance_switched' row'sdetailare text for people and may be reworded in any release. - A 'capture_instance_switched' facts row's
detailis JSON,{"old", "new", "start_lsn", "not_captured", "text"}: the capture instances, the newer one's start LSN (the row'smin_lsn), the columns the query reads that the newer one does not capture, andtext, the line the row held before (old -> new, then those columns). A query no longer parses text for the instance names. Rows written before keep the line: read both withcoalesce(get_json_object(detail, '$.text'), detail), or take the names from the JSON only on rows whosedetailstarts with{(ADR 0023 amendment 8). - The files in
metricsPathare internal: the options reference no longer documents their names or contents (a chunk'schunk-<n>.jsonwas), which may change in any release. The facts table is the public record of what they carry (ADR 0021 amendment 11). FinalizationListeneris public forjoin,last_errorandfailures. Create it withfinalization.track(); its constructor is not public, because a listener built by hand never started its worker.- The documentation site is published per version, with a version selector:
devfollowsmain, each release tag publishes its own version, andstable, the site's root, is the latest final release. A release candidate is published under its version without movingstable, and leaves the selector once its final release is published. Links from before the versions open the same page instable(devuntil 1.0.0) (ADR 0024 amendments 1 and 2).
Deprecated¶
- Importing
HAS_ADMISSION_CONTROL,OPERATIONS,MssqlCdcDataSourceormake_clientfrommssql_cdc. The API reference never listed them, so they were already internal (ADR 0021). They are no longer inmssql_cdc.__all__. Importing one frommssql_cdcwarns at run time (DeprecationWarning), and 2.0 removes them from there.mssql_cdc.sourceandmssql_cdc.clientkeep them as internal names. - Importing
CdcClientfrommssql_cdc: it warns at run time (DeprecationWarning), and 2.0 removes it from there.mssql_cdc.clientkeeps it, internal: the data source builds its own client, so none of one's own plugs in, and a minor release may add methods to it. reconcile(seed=...): renamedsample_seed, which says what it seeds (the sample of MATCH buckets compared row by row).seedkeeps working until 2.0 and warns (DeprecationWarning); passing both raisesTypeError.
Fixed¶
- On a Spark without admission control,
maxCommitsPerBatchis still ignored, but now with a warning in the driver's log and in the next micro-batch row'swarnings(withmetricsPath). Before, it was ignored silently. - Documentation:
finalization.finalized_untilhas a docstring, andget_sparksays a local session gets the UTC time zone and 4 shuffle partitions. Public docstrings anddocs/reference/options.mdno longer point at internal names.
0.8.0 - 2026-10-14¶
State compatibility: offsets and checkpoints unchanged. Facts migration 14 and snapshot plans
migration 1 rewrite column comments only (metadata), the next time a stream opens those
tables. The 'snapshot_open' payload gains the optional key switch_batch_id
(ADR 0021
amendment 10); no other payload key is new. No mssql_cdc.min_version is set.
- A chunked snapshot opened with
snapshotKeysrecords those columns as its open'skeys, the columns its chunks are cut on, as before. Abackfill()before 0.8.0 refuses such a snapshot, as it refuses one whose unique index changed. - A release before 0.8.0 cannot complete a chunked snapshot that
snapshot_on_switchopened once its generation's own snapshot has completed, because Delta skips its completion row. Runbackfill()with 0.8.0. finalization.freshness()only reads the control table.
Added¶
- Chunked snapshots of tables without a unique index. The
snapshotKeysoption already cut the snapshot reader's ranges; now it also names the columnsto_delta(..., snapshot="chunked")cuts a snapshot's chunks on, unique or not (ADR 0028, Amendment 6). Each chunk is a[lo, hi)range of the column values, NULL first, so a row is in one chunk only and a value is never split: a value held by more rows thanchunk_rowsmakes a bigger chunk. The columns are recorded with the snapshot in its plans table, and its plan stays on them. The open refuses a column CDC does not capture, of a type no bound can be bound as, or one SQL Server cannot sort (bit,text,ntext,image,xml). Such a snapshot fills bronze only:apply_changesstill needskeysthat identify a row. to_delta(snapshot="chunked", snapshot_on_switch=True), which raisedValueErrorbefore. The batch that first reads a newer capture instance now opens a chunked snapshot at that instance's start LSN, which the stream has already read past, instead of reading the whole table inside the batch.backfill()reads it next to the stream, andapply_changesapplies its waves and rebuilds silver at itsbootstraprow (ADR 0023 Amendment 7). It replaces a chunked snapshot of the stream that is still open, and it holds the snapshot mode like the others: asnapshot="full"run raises until it completes. Its 'snapshot_open' row haskind'bootstrap' and the new keyswitch_batch_id, thebatch_idof the switch's 'capture_instance_switched' row; its open and completion rows use Delta keys based on its S (<app_id>#snapopen.<S>,<app_id>#snapdone.<S>, version 0), because the generation's own keys may already be taken.finalization.freshness(spark, control_table, table_name)returns how old a table's verdict is:finalized_until,updated_at, their ages in seconds by the caller's clock, andopen_snapshot_lsnwhen a chunked snapshot holds a silver verdict. The result isFreshness, aTypedDictexported frommssql_cdc. The monitoring guide's alert query joins the control table with the facts and names why each stale verdict is stale: held, silver, no facts, stopped, not advanced, behind.- Lab check
t11_fanout_costmeasures what eachstart_manystream costs the driver: one Python planning process of about 50 MB per running query (and on a Spark Connect server, aforeachBatchprocess of about 100 MB), about 10 MB of heap and 20 JVM threads, tens of milliseconds of planning per trigger. ADR 0027 (amendment) and the many-tables guide give about 10 always-on streams per driver as a starting point, and the options past it: a longer trigger interval,availableNowgroups, more jobs.
Changed¶
start_manystarts each stream once the query before it has started its source (or stopped, up to two minutes). Spark gives the Python process that plans a query's source a fixed 10 s to connect back, and streams started back to back on a busy driver failed at their start with "Python worker failed to connect back" (9 of 20 in lab check t11 on a host other jobs kept busy, none of 20 on an idle one; t11--back-to-backrepeats it).backfill()raisesValueErrorwhen the stream'ssnapshotKeysoption (else the unique index) would cut the open snapshot's chunks on other columns than it opened with; before, it raised only when the unique index had changed.snapshotKeysmust be a non-empty JSON list of column names, or it is aValueError; a JSON string was read as one column per letter. Its names match the captured columns ignoring case, ascolumnsdoes, and a chunked snapshot records them as captured.to_delta(snapshot="full", snapshot_on_switch=True)raisesValueErrorwhile a chunked snapshot of the stream is open, as a full bootstrap does: its switch batch would read a whole snapshot next to the chunked one. Before, onlybootstrap=Trueandon_data_loss="resnapshot"runs checked.
Fixed¶
backfill()plans a keyset plan's chunks, and seeks its last chunk's end, after the stream's configured capture instance was disabled (the last step of a switch): the key types come from the table's newest instance, as the snapshot reader's do. Before, it raised "Capture instance ... not found" untilcaptureInstancenamed the new instance.
0.7.0 - 2026-10-13¶
State compatibility: offsets and checkpoints unchanged. A new table kind, the snapshot plans
table (<target>_snapshot_plans, next to bronze), starts at schema version 0 with its own
migrations module, migrations/plans.py; facts migration 13 rewrites the comment of detail
(metadata only) the next time a stream opens the facts table. No mssql_cdc.min_version is
set.
- The payload keys that hold key values (
keysandplanof 'snapshot_open',chunksof 'snapshot_plan',loandhiof 'snapshot_chunk') leave only the facts rows this release writes for a chunked snapshot it opens; the plans table holds each payload whole (ADR 0021 amendment 9). Rows written before are read as before. - A chunked snapshot opened by an earlier release finishes on this one the way it started:
its key values stay in its facts rows and no plans table is written, so jobs of both
releases that share those facts can read it.
tests/compatfinishes each release's open snapshot this way. - In the other direction, on a snapshot this release opened, an earlier release's
apply_changesapplies the waves without range deletes (the rebuild at completion removes those keys), and itsbackfill()andreconcile()fail withKeyError. Upgrade the jobs that run them together with the stream.
Breaking¶
- For type checkers only:
SnapshotPlanDetail.chunks,SnapshotChunkDetail.loandSnapshotChunkDetail.hiare now optional keys, since a facts row written by this release lacks them. At run time nothing changes and mypy still acceptsd["chunks"], but pyright in strict mode reportsreportTypedDictNotRequiredAccess: read them with.get()or check for the key first.
Added¶
- Options
userandpassword: the login is passed to the driver apart from the connection string (mssql-python'sUID/PWDkeyword arguments,arrow_odbc.connect(user=, password=)). The driver escapes them, so a password with;or}needs no braces, and Spark redacts an option namedpasswordin a query's plan by default, which it never does forconnectionString(a Spark Connect server's record of the request is another matter: see Security) (ADR 0034). - Option
accessToken: a Microsoft Entra ID access token, passed bymssql-pythonas theSQL_COPT_SS_ACCESS_TOKENpre-connect attribute (ADR 0034). - Option
tokenProvider:package.module:function, which the library imports and calls with no arguments each time it opens a connection, on the driver and in every executor task. It returns the token, so a long stream renews it by itself. Only the name crosses to the executors; the module (withazure-identity, for instance, which the library does not depend on) must be installed on every node (ADR 0034). - Combinations that leave the login ambiguous fail with a
ValueErrorbefore anything connects:accessTokenwithtokenProvider; a token withuser,password, orUID/PWD/Authentication/Trusted_ConnectioninconnectionString;userwithUID,passwordwithPWD.backend=arrow-odbcrefuses a token: arrow-odbc sets no attribute before it connects. SnapshotPlanDetail.chunk_count: the number of chunks of the plan, which a facts row written by this release carries in place ofchunks.
Changed¶
- A chunked snapshot no longer writes source key values to the facts table. Those values
are the key's MIN and MAX at its open and the bounds of every chunk, which with a
composite, string or date key are real keys of the table.
to_delta(snapshot="chunked")andbackfill()now write them to a new table next to bronze,<target>_snapshot_plans: bronze's schema for a name, a sibling directory for a path, so they fall under bronze's access policy.backfill(),apply_changesandreconcilefind the table by bronze's name; nothing new needs to be passed. For a snapshot opened by this release, the facts rows leave outkeysandplan('snapshot_open'),chunks('snapshot_plan', which gainschunk_count) andloandhi('snapshot_chunk'). The new table'sdetailholds each payload whole, so a query that read them from the facts reads them there (ADR 0028 Amendment 4, Tables).
Security¶
- The secret need not live in
connectionStringany more (user/password,accessToken,tokenProvider, above). Spark's default redaction hides thepassword,accessTokenandtokenProvideroptions in a query's plan, but showsconnectionStringanduserin full. A Spark Connect server (Databricks Connect, standard access mode, serverless) also records each request's options in the Connect tab of its Spark UI and in its event log, redacted only byspark.sql.redaction.string.regex, which is unset by default: there, prefertokenProvideror a managed identity, or set the pattern the options reference gives. examples/databricks_notebook.pypasses the login asuserandpasswordfrom the secret scope, no longer asUIDandPWDinconnectionString, which Spark shows whole in the streaming query's plan.- With the
user,password,accessTokenortokenProvideroptions, a driver error raised while connecting whose message (or any error down its chain) holds one of their values, SQL Server's "Login failed for user '...'" among them, is raised as aConnectionErrorwith each value replaced by***. Errors that hold none, and every error of a connection given onlyconnectionString, are raised unchanged, so the retry of transient errors (ADR 0029) still sees them. No option value goes into the facts, the metrics files or a commit'suserMetadata. - The facts table, often readable by more people than bronze, no longer holds source key values for a chunked snapshot this release opens (see Changed).
0.6.0 - 2026-10-10¶
The changes are listed under 0.6.0rc3, rc2 and rc1 below; this release adds only the
documentation of where it has run. The library ran on Databricks serverless (environment 4,
a Spark Connect client) with the fake backend: a stream with availableNow, a chunked
bootstrap with backfill(), apply_changes, reconcile and finalization, with the cache
API refused by the platform; 24 checks passed (bronze equal to the source, no duplicates,
facts counted without persist(), metrics files in a Volume, silver equal to the table).
Serverless refuses processingTime triggers. Its SQL Server path was not run there: the
validation workspace's serverless compute has no network path to the server.
State compatibility: as 0.6.0rc1 to rc3.
0.6.0rc3 - 2026-10-10¶
Fixed¶
- On Databricks serverless, a stream died on its first batch with rows:
InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime not supportedwhile Spark serialized the query's progress. The uncached sink (0.6.0rc1) observed the batch's first and last commit times as TIMESTAMP_NTZ values, and serverless writes observed metrics with a JSON mapper that has nojava.timesupport. They are now observed as text and parsed back, with the same facts values. Found by running the library on serverless (with the fake backend): every other check passed there, andprocessingTimetriggers are refused by the platform, as documented.
0.6.0rc2 - 2026-10-10¶
Fixed¶
- On PySpark 4.0 and 4.1, Databricks serverless environment 4 included, every read failed
with
TypeError: to_arrow_schema() got an unexpected keyword argument 'timezone'since 0.1.0 (the stream's and the snapshot's reads), andreconcile()too since 0.2.2: only PySpark 4.2'sto_arrow_schematakestimezone; 4.0 and 4.1's taketimestamp_utc, whose default reads TIMESTAMP columns as UTC instants as well. A new CI job,spark41, runs the tests that start no JVM and the source's engine tests on PySpark 4.1.1.
0.6.0rc1 - 2026-10-09¶
State compatibility: no migration; offsets, checkpoint layout, table schemas and the metrics files' names and layout are unchanged, and the state every released wheel wrote also resumes through a Spark Connect session. Where the platform refuses caching:
- a micro-batch's bronze commit has
rowstoupdatesnull in itsuserMetadata, and a batch with no rows right after one with rows writes an empty commit (so does a run's first without a facts table); - a
backfill()wave's commit hasrows,high_lsn,read_secondsandread_mbnull for each chunk.WaveChunk.rowsis nowint | None(ADR 0021 amendment 8).
The facts table holds those values. A 0.5 release that finds such a wave commit after a crash writes NULL counts for its chunks.
Added¶
- Spark Connect: the library runs from a Spark Connect client. It is tested against a local
Spark Connect server with Delta Connect and the fake backend, in a new CI job,
connect(ADR 0033). Databricks serverless and Databricks Connect are Spark Connect clients too, but neither has run it yet. On serverless, passtrigger={"availableNow": True}. The network path from serverless to SQL Server is yours to provide. The calls that talk to SQL Server themselves (bootstrap,backfill(),reconcile()...) and thetrack()listener run in the client process. - On a platform that refuses the DataFrame cache API (Databricks serverless),
delta_sinkandbackfill()now append each batch or wave as they read it instead of failing onpersist().observe()counts the rows on the way, so the facts table gets the same values. The source is read once, except for a batch with rows right after one without: the sink asksisEmpty()before that append, which reads the batch's first partition with rows up to its first row (an empty batch whole, which then writes no commit). For a run's first batch, the batch before it is the facts table's last. A Spark Connect server can answer the append before its observed metrics are in; the sink then counts the batch with one more read, andbackfill()counts the wave's chunks in bronze. Where the platform caches, nothing changes. The code tells the two apart by tryingpersist(), never by platform name. Tested on classic Spark with the cache API made to raise, and through a local Spark Connect server that refuses it in itsforeachBatchworker; not run on serverless yet (ADR 0032). metricsPathcan be a URI thatpyarrow.fsopens (s3://,gs://,abfss://,hdfs://,file://), for platforms with no shared local or FUSE path (EMR, Dataproc, Spark Connect). The metrics and event files are written, listed, read and removed throughpyarrow.fs.FileSystem.from_uri, with credentials from pyarrow's defaults on each node or options in the URI query (?region=...). Local and FUSE paths work as before, and so do the layout and file names. Each file is written whole: under a temporary name and moved, or, on a filesystem that cannot move, in one upload under its own name. Tested withfile://URIs and a filesystem without rename, not against a real object store (ADR 0014, Amendment 6).
Changed¶
reconcile()compares counts only (Tier 1) where the platform refuseslocalCheckpoint, instead of failing: it logs a warning and every bucket hashashedfalse.to_deltano longer raisesValueErrorfor a URImetricsPath. The reader raises it when the query starts, and only for a URIpyarrow.fscannot open, such asdbfs:/. On Databricks, keep the metrics in a Volume: pyarrow does not use Unity Catalog's storage credentials.
Fixed¶
- A bootstrap's or re-snapshot's facts row could record 0 rows when another writer (the
stream, auto compaction) committed to bronze between the snapshot reading the table's
latest version and its history. The snapshot's own commit is now selected by version, as
are a micro-batch's and a
backfill()wave's where the platform refuses to cache. mssql_cdc.spark.get_spark()raisedCANNOT_CONFIGURE_SPARK_CONNECT_MASTERwhenSPARK_REMOTEwas set. It now returns a session on that Spark Connect server.- Lab checks t5 and t7 failed through Spark Connect because they read
numInputRowsfrom progress, which a Connect progress leaves out. They now sum it from the sources.
0.5.0 - 2026-10-09¶
The same code as 0.5.0rc1; its changes are listed under it below. Measured against a
production SQL Server 2016 from Databricks (DBR 18.2, dedicated, numPartitions 4,
chunk_rows 1,000,000, the default target_wave_seconds), next to a running stream, with
the same checks as for 0.2.0 (every planned chunk read once, no duplicate key, silver equal to
the source, reconcile matching every bucket):
- a 34-million-row table: from the open to completion in 586 s instead of 943 s with 0.2.0 (3 waves instead of 9; 87,000 rows/s during the waves instead of 58,000);
- a 100-million-row table: 4,427 s instead of 5,096 s (17 waves instead of 27); this read is bound by the link, so the fixed cost per wave weighed less.
A full bootstrap of the same tables takes 325 s and 3,364 s, so a chunked snapshot now costs 1.8 and 1.3 times its wall time (2.9 and 1.5 times with 0.2.0), in exchange for being read next to the stream over as long as it takes.
State compatibility: unchanged from 0.4.1 (as 0.5.0rc1).
0.5.0rc1 - 2026-10-08¶
State compatibility: unchanged from 0.4.1. Offsets, checkpoint layout, table schemas, facts
events and payloads, and the userMetadata of bronze commits keep their shape; no
migration.
Added¶
backfill(target_wave_seconds=None): how long a chunked snapshot's wave should take. A wave takes whole rounds ofnumPartitionschunks: as many rounds as the target (300 s by default) holds at the pace of the last wave read (an earlier call's, from its facts rows), and no more than what is left ofmax_seconds.0reads one round per wave, as before. The plan's chunks, their order andchunk_rowsdo not change (ADR 0028 amendment 3).
Changed¶
backfill()reads the next wave while a background thread writes the previous wave's bronze commit and facts rows. At most one wave reads while one commits. Bronze appends stay in wave order, facts rows still follow their commit, and crash recovery is unchanged. A call opens one more connection to SQL Server (for commit times) and can hold two waves' rows cached at once. A wave of more chunks thannumPartitionsstill readsnumPartitionsat a time.- By default a wave now takes more than
numPartitionschunks once a wave's pace is known (from the second wave of a call, or from the first after an earlier call). Passtarget_wave_seconds=0to keep the 0.4 behaviour. The Databricks benchmark against a production source follows the release candidate. - While a chunked snapshot is open,
apply_changesreads only the waves that arrived since its last call. Delta now skips the files of the earlier waves, the files of the whole snapshot silver was rebuilt from, and the changes older than the first new wave's stamp. Results are unchanged; what still grows with the waves is the MERGE over silver (ADR 0019 amendment 3). - A full snapshot of a composite or non-integer key cuts its partitions with keyset seeks
instead of
NTILE. The row count comes fromsys.sp_spaceused, the ranges take NTILE's sizes, and each bound is a seek of its range's keys from the bound before (as in a chunked snapshot's keyset plan), under the snapshot'sisolationLevel. NTILE read and sorted or spooled every key of the table before the first row was read. On 40,000 rows in four ranges, each seek reads at most 10,002 keys, with no sort and no spool. With an exact count the partitions are NTILE's; with a stale one every row is still read once. A single integer key keeps its MIN..MAX ranges (ADR 0016 amendment 4). - The default backend sizes each Arrow batch by the widest row of the batch before (the
longest text or binary value) instead of its mean. Every read starts with one row, and
each fetch takes at most twice the previous batch's rows. A table of
(max)values therefore no longer builds gigabyte batches from a large first fetch or from one long value among short ones. A narrow table reachesarrowBatchSizefrom its 15th fetch on.MssqlPythonBackendtakesmax_bytes_per_batch, asArrowOdbcBackenddoes (ADR 0003 amendment 5). - Lab: t8
--concurrencytimes each backend over 1, 2 and 4 partitions, each with a connection of its own.arrow-odbc's concurrent fetch gains 19% to 48% at 1 and 2 partitions and nothing at 4; the backend still turns it off (ADR 0003 amendment 4). - Tests: the fake-vs-SQL Server parity test gives each scripted transaction a commit time
of its own.
sp_cdc_cleanup_change_tablelowers a low water mark to the firstcdc.lsn_time_mappingentry sharing itstran_end_time(documented;datetime, 1/300 s), so two commits within one tick made the test flaky. The library is not affected: cleanup deletes exactly below themin_lsnit reports, so the retention guard stays sound.
0.4.1 - 2026-10-08¶
State compatibility: unchanged from 0.4.0. Code moved and tests added only: offsets, checkpoint layout, table schemas, facts events and payloads are as in 0.4.0; no migration.
Changed¶
mssql_cdc.clientis a package of small private modules (protocols, validators, T-SQL builders, backends,SqlCdcClient, chunk planning). Code moved only: every name imports frommssql_cdc.clientas before, and the exceptions and themssql_cdc.clientlogger keep their names. Tracebacks show the new file paths.mssql_cdc.pipelineis a package too (CdcStreamandto_delta, whole snapshots, recovery, chunked snapshots, the snapshot mode lock), and the facts event rows are written and read through one module,mssql_cdc.events: their names, row builders and readers. Code moved only: every name imports frommssql_cdc.pipelineas before (mssql_cdc.sink.write_eventtoo), themssql_cdc.pipelinelogger keeps its name, and every facts row and query result is the same.- Both packages declare
__all__:from mssql_cdc.client import *andfrom mssql_cdc.pipeline import *bind the public names the modules bound (Lsn, andOffset,SnapshotModeand the othermssql_cdc.typesnames in pipeline), no longer their stdlib imports. - Tests:
tests/test_properties.pychecks properties over inputs Hypothesis generates (it joins the dev group, pinned byuv.lock): LSN round trips, canonical form and order; chunk plans tiling random key distributions; each key's latest image in silver whatever the order of its change rows; the spellings a boolean option takes. Behaviour is unchanged. - Tests: one mutation-testing pass (mutmut, configured in
pyproject.tomland run throughuv run --with, not a dependency) over the LSN helpers, chunk planning, validators and the pure silver and reconcile helpers, and tests for the gaps it found; scores and the equivalent mutants are in the development guide. Behaviour is unchanged.
0.4.0 - 2026-10-07¶
State compatibility: unchanged from 0.3.0. Types only: offsets, checkpoint layout, table
schemas and payload keys and formats are as in 0.3.0, now described by types. A
'data_skipped' row's detail (from, to, certain, reason), shipped since 0.2.0, joins
the payloads ADR 0021 lists as state (amendment 7). A chunked 'snapshot_open' row
writes keys and plan later in its JSON object; readers take keys by name.
Added¶
BackendandCdcClientaretyping.Protocols,runtime_checkable, exported frommssql_cdcand listed in the API reference (ADR 0030). Your own backend satisfiesBackendwithout inheriting (batches,scalar,close), andisinstancechecks it. Subclassing still works and inherits the methods that have a body. A method later added to a protocol is a break for a class that implements it without inheriting, listed under "Breaking".Lsn, aNewTypeofstrfor an LSN in its canonical form (0xand 20 uppercase hex digits).lsn.normalize,lsn.from_intand the client's methods return it. Parameters still take anystr, and at run time it is the samestr.mssql_cdc.payloads:TypedDicts for the JSON a later release reads back, plain dicts at run time, for Python that reads the facts table (d: SnapshotChunkDetail = json.loads(row["detail"])):SnapshotOpenDetail,SnapshotPlanDetail,SnapshotChunkDetail,SnapshotCompletionDetail,DataSkippedDetail,BatchDetailandWaveMetadata(a wave commit'suserMetadata), exported frommssql_cdc, with the nestedWaveChunk,SnapshotExtent(IntExtentorKeysetExtent) andSnapshotKind. Listed in the facts table's reference next todetail(ADR 0021 amendment 7).SourceOptions, exported frommssql_cdc: aTypedDictof every source option with a string value. Annotate an options dict with it and a type checker flags a misspelt name.stream(),reconcile(),apply_changes(options=)andstart_many()take aSourceOptionsor any mapping (they tookdict), so every call that type-checked before still does.SparkSessionLikeandStreamingQueryLike, exported frommssql_cdc: a classic or a Spark Connect session or query (Databricks Connect, serverless compute), which does not subclass the classic one. Everysparkandqueryparameter takes them, so a Connect caller's type checker acceptsregister,stream,start_many,apply_changes,reconcileand thefinalizationfunctions (ADR 0031 amendment 1).
Changed¶
- Typing: every function, method and attribute of the package is annotated; mypy checks
srcin strict mode, andpyright --verifytypesscores the public API 100% type-complete (76% before). CI'slintjob fails on a mypy error or a lower score (ADR 0031). A type checker now sees the types ofsparkparameters, options, the clients' methods and the helpers' results, which it saw as unknown. Behaviour is unchanged. - Typing:
CdcClient.max_lsn()returnsLsn | None, as it did at run time (NULL on a database capture has not written to yet), andWaveChunk.high_lsnisstr | None(null when the chunk's task could not readmax_lsn). Both types are new in this release. - Typing: pyright joins the dev group, pinned by
uv.lock;make lintandscripts/lab.ps1 lintrun its--verifytypesas CI does.
Deprecated¶
- Nothing.
BackendandCdcClientwere ABCs in 0.3; they are now the protocols themselves, not new classes beside them, soclass MyBackend(Backend)keeps inheriting the method bodies and an incomplete subclass still fails to instantiate. There is no separate ABC to phase out (ADR 0030).
0.3.0 - 2026-10-07¶
State compatibility: offsets and checkpoints unchanged. Facts migration 12 rewrites the
comment of detail (metadata only), the next time a stream opens the facts table; a
micro-batch row's detail gains the key warnings, an added payload
(ADR 0021
amendment 6). Rows written before, and batches without warnings, keep detail NULL. No new
event value, no other payload key changed.
Breaking¶
- Options, flags and modes are keyword-only
(ADR 0021
amendment 5). A positional call now raises
TypeErrorbefore anything runs. To migrate, pass the same values by name: stream(...).to_delta(target, app_id, checkpoint, facts_table=None, *, ...): everything afterfacts_table(trigger,query_name,bootstrap,on_data_loss,resnapshot_interval_days,snapshot_on_switch,snapshot) by keyword, e.g.to_delta("bronze.orders", "orders-v1", ckpt, "ops.facts", bootstrap=True).stream(...).snapshot(target, *, resnapshot=False, ...):snapshot(t, True)becomessnapshot(t, resnapshot=True).apply_changes(spark, bronze, target, *, capture_instance=None, keys=None, ...):apply_changes(spark, b, s, "dbo_orders", ["order_id"], ...)becomesapply_changes(spark, b, s, capture_instance="dbo_orders", keys=["order_id"], ...).reconcile(spark, options, silver, *, keys=None, ...).granularityoffinalization.advance(),finalization.track(),finalization.candidate()andfinalization.FinalizationListener(spark, run_id, control_table, table_name, *, granularity="hour"):advance(spark, control, table, end, "day")becomesadvance(spark, control, table, end, granularity="day").timeoutofawait_all()and ofFinalizationListener.join(), andsource_indexoffinalization.end_offset_from_progress():await_all(queries, 600)becomesawait_all(queries, timeout=600).sink.delta_sink(target, app_id, facts_table=None, *, metrics_path=None)andspark.get_spark(app_name, master, *, delta=True).- For type checkers only:
snapshot(),seed(),backfill(),apply_changes(),reconcile()andfinalization.end_offset_from_progress()now returnTypedDicts instead ofdict. Code annotated to take their result asdictshould take the new type orMapping[str, Any]. At run time they are the same dicts. - For type checkers only:
backfill(isolation=)now takes anIsolationandfinalization.candidate(granularity=)aGranularity, where they took anystr. Astrthat holds a valid value, a job parameter say, needs a check ortyping.cast(Isolation, value)to type-check. TheLiteralnames the canonical spelling ("readCommitted"); at run time both still match ignoring case.
Added¶
mssql_cdc.types, exported frommssql_cdc:Literalaliases for the mode parameters:SnapshotMode,OnDataLoss,Isolation,Granularity, andBackfillStateforbackfill()'sstate.TypedDicts for the results, plain dicts at run time:Offset(ofsnapshot(),seed()andfinalization.end_offset_from_progress()),BackfillStatus,ApplyResult,ReconcileResult.
The signatures use them, so a type checker flags a misspelt mode or result key. A wrong
mode still raises ValueError naming the allowed values.
- A micro-batch row of the facts table (event NULL) now carries the warnings the reader
logged on the driver in detail, as JSON {"warnings": [...]}, only when there are any:
columns it does not read (computed columns, or columns a newer capture instance captures
that columns leaves out), unknown options, and the UTC offset it converts commit times
with on a server older than SQL Server 2022. Before, they were only in the driver's worker
log. A restart whose first batch replays one already in the facts puts them in the next
batch's row. Needs metricsPath, like the other metrics
(ADR 0023
amendment 6).
Changed¶
finalization.advance()andfinalization.candidate()take the offset as any mapping (Mapping[str, Any]), so anOffsetand a parsed progress offset both type-check.
0.2.2 - 2026-10-07¶
State compatibility: unchanged from 0.2.1 (offsets keep their format; no migration, no new event value).
Fixed¶
- With a named source time zone that has daylight saving (
sourceTimeZone, orautoon SQL Server 2022), commits in the hour a fall-back repeats now keep commit order. SQL Server'sAT TIME ZONEreads a repeated time with the offset before the change, so commits made after the clock went back got commit times an hour early, behind the commits before them, and their rows could land in periodsfinalized_untilhad already declared final. Those commits now get the offset after the change (ADR 0008 amendment 4). - Before SQL Server 2022 (
sourceTimeZone=auto, fixed-offset fallback), the driver now reads the server's UTC offset again for each new batch instead of once per run, and sends it with the batch. A long run follows a daylight-saving change from its next batch, and a batch's end offset and rows use the same offset. A warning is logged when the offset changes. On Spark 4.0/4.1 an idle poll now sendsmax_lsnalone; before, it also read the commit time on every poll. reconcile()no longer reports stream lag as an integrity failure. A difference explained by a change the stream has not read yet is nowIN_FLIGHT, notMISMATCH,MISSING_TARGET,MISSING_SOURCEorRECORD_DIFF. Once the source is read, it reads the keys in the change table from bronze's position up tosys.fn_cdc_get_max_lsn()read then, through the capture instances the stream reads. This is the change table the stream already reads, so no new grant is needed. Only a commit the capture job has not harvested yet stays unseen (ADR 0028 amendment 2).reconcile()now finds a change to a key whose value is NULL (the join is null-safe), so that key's difference isIN_FLIGHTinstead ofRECORD_DIFForMISSING_*. The report'skeynow names it,{"code":null}, instead of{}.
0.2.1 - 2026-10-06¶
State compatibility: unchanged from 0.2.0 (no migration, no new event value).
Fixed¶
- pyarrow 19 or later: with pyarrow 18 imported before
mssql_python, the Python interpreter crashes on Windows (access violation). The install pins in the Databricks page, the docs home and the example notebook say 0.2.0, not 0.2.0rc1.
Changed¶
- Tests and CI: logic the streaming engine is not needed for is tested in plain unit tests
(specced mocks, pure functions), the test session starts Delta only for the tests that
take it, and
-m "not spark and not sqlserver"runs the JVM-free tests alone. CI runs the unit suite in three shards next to the integration and lab jobs instead of before them: about 13 minutes instead of 48. A parity test runs one scripted change history on the fake and on SQL Server 2022.tests/compat/0.2.0holds the state the 0.2.0 wheel wrote. - CI: a release tag no longer starts its own
cirun; the release commit's run onmainis the one thepypiapproval waits for (docs/RELEASING.md).zizmortreats a workflow a tag triggers as publishing, andcirestores caches. - CI: a
securityworkflow checks the supply chain on every pull request, every push tomainand weekly.pip-auditchecks every packageuv.lockpins against the known vulnerabilities;zizmoraudits the workflows and fails on a finding of medium severity or higher; a job installs the lowest version of each direct dependency the package declares (uv sync --resolution lowest-direct, Python 3.10) and runs the tests that start no JVM; the OpenSSF Scorecard runs frommainand publishes its results.
0.2.0 - 2026-10-06¶
The first final release of the 0.2 line: the same code as 0.2.0rc2. Its changes are listed
under 0.2.0rc2 and 0.2.0rc1 below. Both release candidates were validated against a
production SQL Server 2016 from Databricks (DBR 18.2): chunked snapshots next to a running
stream, silver applied wave by wave and reconcile matching every bucket, on a 34-million
and a 100-million-row table with 0.2.0rc1 and again on the 34-million-row one with 0.2.0rc2.
State compatibility: offsets and checkpoints unchanged since 0.1.0. Tables written by 0.1.0
migrate the next time the library opens them, through the migrations listed in the State
compatibility lines of 0.2.0rc1 and 0.2.0rc2; tests/compat/0.1.0 resumes such state with
this release.
0.2.0rc2 - 2026-10-06¶
State compatibility: offsets and checkpoints unchanged. Control migration 4 rewrites the
comment of finalized_until (metadata only), the next time finalization.advance or
apply_changes opens the control table. A migration interrupted between its change and its
version stamp now completes on the next open instead of failing every writer
(add_columns skips the columns a table already has), and one that loses to another job's
concurrent commit runs again. A release still writes a table a newer one migrated, with a
WARNING, so jobs that share a table can upgrade or roll back one at a time; a release that
knows fewer migrations than the table property mssql_cdc.min_version names refuses it,
and no migration sets that property yet
(ADR 0013
amendment,
ADR 0021).
Facts migration 10 rewrites the comments of event, lost_from_ts, lost_to_ts, detail,
batch_id and the table (metadata only), and event gains the value 'data_skipped';
facts migration 11 rewrites those of event, lost_from_ts and detail again, for the
possible skips a task records (metadata only)
(ADR 0018 amendment 3). The state contract
also covers the silver and reconcile schemas, the facts event values and the keys of a
snapshot row's detail and of a backfill wave's userMetadata, keys that are only ever added
(ADR 0021 amendment 4); tests/compat/<version>
holds the state each release's wheel wrote, and the tests resume it (amendment 3). A bronze
or silver table created with a column name Delta refuses without column mapping gets it,
which adds the columnMapping feature to its Delta protocol (older readers and writers
refuse the table); existing tables are untouched. A restarted stream's inferred schema
leaves out computed columns; an existing bronze table
keeps such a column, and the rows appended after the upgrade hold NULL in it, as its change
rows always did.
Added¶
lockTimeoutMssource option (off by default):SET LOCK_TIMEOUTbefore every read of the source table (snapshot, chunked-snapshot planning, reconcile), so a read blocked by a writer fails with error 1222 and Spark retries the task instead of hanging (ADR 0029).- The stream's driver-side calls (
initialOffset,latestOffset,reportLatestOffset,partitions,prepareForTriggerAvailableNow) retry a transient SQL Server error (SQLSTATE 08xxx, HYT00, HYT01, 40001) on a new connection, up to 3 times within about 14 s, with a WARNING each time;DataLossError,SchemaChangedError,ValueErrorandPermissionErrorare never retried (ADR 0029). apply_changestakes the capture instance from the options'captureInstancewhencapture_instanceis omitted (one given still wins).is_data_loss(exc)andis_schema_changed(exc)recogniseDataLossErrorandSchemaChangedError, also inside theStreamingQueryExceptionthatawaitTermination()raises orawait_allreturns.- Bronze and silver tables are created with
delta.columnMapping.mode = 'name'when a captured column name holds a character Delta refuses otherwise (a space or,;{}()=, as in[Unit Price]); such tables used to fail on their first write. Other Delta features (deletion vectors, clustering, OPTIMIZE, type widening) are documented as opt-ins under Tables > Table properties (ADR 0012 amendment). - When a newer capture instance adds such a column to a bronze or silver table created
without column mapping, the stream and
apply_changesfail before writing with aSchemaChangedErrorthat names the column, says column mapping is off on the table and gives theALTER TABLE ... SET TBLPROPERTIES ('delta.columnMapping.mode' = 'name', 'delta.minReaderVersion' = '2', 'delta.minWriterVersion' = '5')that enables it (an upgrade of the table's Delta protocol, which its readers must support), instead of Delta's own error midway; the library does not enable it (ADR 0012 amendment). - With
failOnDataLoss=false, a batch whose planning skips changes CDC cleanup purged writes adata_skippedfacts row: the batch'sbatch_id,rows0,min_lsn=max_lsnthemin_lsnit resumed at, the LSNs skipped indetail(JSON{from, to, certain},certaintrue) and the gap inlost_from_tsandlost_to_ts, as on aresnapshotrow. It needs a facts table andmetricsPath(to_deltasets it) (ADR 0018 amendment 3). - A task that finds, after reading its range, that CDC cleanup ran meanwhile (the
executor-side retention guard,
failOnDataLoss=false) writes adata_skippedfacts row too, the loss possible rather than certain:detail{from, to, certain: false, reason},min_lsn=max_lsnthemin_lsnit found, andlost_from_ts/lost_to_tsfrom the batch's start offset to thatmin_lsn. It rides in the partition's metrics file, so a retried task or a replayed batch writes it once; it used to be only logged (ADR 0018 amendment 3). - A compatibility test against the state released versions wrote:
tests/compat/<version>holds a checkpoint with two generations and the bronze, silver, facts and control tables that version's wheel wrote (0.1.0 from its PyPI wheel), andtests/compat/test_compat.pyresumes each with the current code; every release adds its own (docs/RELEASING.md). backfill()returnsstate:done,running,waiting_headroom,waiting_metricsorno_snapshot, so a loop can tell a pause worth waiting out from a snapshot that does not exist (a wrongapp_idortarget, or a stream that bootstraps withsnapshot="full"); the bootstrap guide's loop branches on it.apply_changesreturnsbronze_found, and logs a warning naming the table when bronze does not exist (before the stream's first batch, or a wrong name). It also warns, once per table, when called withoutfacts_table(its verdict is then held), when bronze has no_command_id, and when the facts table has no row for bronze's name.FinalizationListener.last_errorandfailures: the error of the last attempt (Noneonce one succeeds) and how many failed in a row. The first failed verdict of a streak is logged at ERROR with its traceback, then one WARNING at most every 10 minutes.- INFO logs for the bootstrap, each snapshot and each
backfill()wave (target, LSN, rows, seconds, chunks done), and a WARNING with the lost range whento_deltadecides to re-snapshot. finalization.finalized_untilis in the API reference, and so public.SECURITY.md: how to report a vulnerability privately, and what is in scope.- Documentation: alerts on NULL metrics, on the verdict's freshness (control table) and on
capture itself from the SQL Server side (
sys.dm_cdc_log_scan_sessions,sys.dm_cdc_errors, the capture job, with a monitoring login); retention for the facts table's micro-batch rows and compaction of bronze; the NULLs CDC stores in change rows (computed columns, LOB types); quoting a password in the connection string, Entra ID authentication and Spark's redaction ofconnectionString; the access policy that the key values in a chunked snapshot's facts rows and bronze history need; the Microsoft license of the default driver's binaries, and how to install without them; the driver's Python process per stream instart_many; what a Spark Connect client must reach; the source-query cost of the default trigger; minute granularity across a daylight-saving fall-back; the tested platform scope, local Spark 4.2 and Databricks classic compute on a single node, withmetricsPathon a path every node sees (without one, as on EMR or Dataproc by default, the metric columns stay NULL and schema change events do not reach the facts); Databricks serverless, unsupported until tested. The site says it documentsmain(ADR 0003 amendment 3).
Breaking¶
failOnDataLossandincludeCommandIdaccept only true/false, 1/0, yes/no or y/n (any case), and raiseValueErrornaming the option otherwise. Values such ason,offor a typo liketureused to read as false, which turned the retention guard off.maxCommitsPerBatch,numPartitionsandarrowBatchSizebelow 1, or not an integer, raiseValueError, and so does a negativeconnectTimeout.maxCommitsPerBatch=0used to mean unlimited without a word: omit the option instead.- A source column named like a metadata column or one the sink adds (
_start_lsn,_chunk, ..., in any case) raisesValueErrorwhen the stream loads, instead of being dropped, failing later on a duplicate column, or read as the chunk number: leave it out withcolumns. to_deltaraisesValueErrorforsnapshot_on_switch=Truetogether withsnapshot="chunked"(the switch snapshot reads the whole table inside a batch): dropsnapshot_on_switch, or usesnapshot="full".to_deltaraisesValueErrorfor ametricsPaththat is a URI (Python wrote it as a local directory named after the scheme, and every metric came out NULL): use a local or FUSE path, such as/Volumes/..., that every node sees. The reader refuses one too.to_deltaraisesValueErrorforon_data_loss="resnapshot"withfailOnDataLoss=false(a purge skipped while the query runs would hide the gap from the next run's check): removefailOnDataLoss=false.- The inferred schema leaves out computed columns, which SQL Server CDC stores as NULL in
every change row, so snapshot rows no longer carry values every later change nulls;
load()logs a warning naming them (sys.columns.is_computed, which the reader already sees: no new grant). A query that selects one fails withUNRESOLVED_COLUMN, and a new bronze table lacks it: list it incolumnsto keep it, NULL in every row, snapshot and seed rows included (ADR 0007 amendment).
Changed¶
reconcile()no longer compares computed columns, also one listed incolumns(ADR 0007 amendment).- A micro-batch is cut into at most
numPartitionsranges of about 50,000 change rows or more, so a batch of fewer than about 100,000 change rows is read in one partition, with one connection, instead of one per core (ADR 0015 amendment). - Only a batch's last partition measures where the stream is (retention watermark,
max_lsn, capture lag, end commit time); the other partitions skip those four queries (ADR 0014 amendment 5, ADR 0020 amendment 2). - The planner reads each capture instance's captured columns (and which are computed) once per query run, and again before checking types whenever a batch holds DDL.
stream()and the reader log a WARNING naming any option they do not know, so a misspelt name no longer leaves its default in force silently.- The reader logs a WARNING with the file when it cannot write a metrics file, instead of turning the facts' metrics NULL silently.
- Planning sends fewer queries: each range's start comes from the same query as the split points, and after the first resolution the client lists only the table's own capture instances, falling back to the whole database when that misses.
to_deltanames the query after the sink'sapp_id(<app_id>.g<n>in generationn) whenquery_nameis not given, and so doesstart_many, whose queries kept the generation-less name after a re-snapshot.backfill(isolation=None)reads and plans with the stream'sisolationLeveloption instead of READ COMMITTED;isolationtakes"snapshot"or"readCommitted"in any case and is checked before anything is read.is_finaltakes an awaredatetimein UTC instead of raisingTypeError.advanceandapply_changesretry a control-table MERGE, and every table's migrations, that lose to a concurrent commit for up to a minute, with backoff, instead of the tracker's two retries.- With
failOnDataLoss=false, skipping purged changes logs a WARNING naming the capture instance and the LSN range. - An idle stream sends one
max_lsnquery per poll:reportLatestOffsetreuses whatlatestOffsetread, and the commit count is skipped when nothing is new. - The default backend caps Arrow batches near 64 MiB, as
arrow-odbcdoes, so LOB-heavy rows read in smaller batches; the first batch of each read holds 64 rows, so it stays small before the row width is known. - With
sourceTimeZone=autobefore SQL Server 2022, the driver ships the offset it read to the executors, so a run applies one offset to its offsets and its rows, and logs a WARNING each time it takes that fallback (ADR 0008 amendment 3). - The sink logs a warning, once per run, when a batch that read rows found no metrics file.
- Error messages: "capture instance not found" ends with the driver's own error, and the
change-table permission hint also works for logins whose language is not English: on an
error naming the change table, the client asks SQL Server (
HAS_PERMS_BY_NAME) whether the login may read it, whatever the language and the driver. - The Databricks example installs the released version (
==0.2.0rc1) instead of the repository's movingmain, quotesUIDandPWDin braces, and computes the verdict's epoch in UTC. - Packaging and CI: pyarrow 18 or later; classifiers for Python 3.10 to 3.13, which the
weekly CI run tests; the release workflow refuses a tag whose commit is not on
mainor whose version has noCHANGELOG.mdheading;docker composebinds SQL Server to 127.0.0.1, where.env.exampleand the lab scripts now point (localhostcould resolve to::1first), and.env.exampleships no password (setMSSQL_SA_PASSWORD); every lab result records the server's@@VERSION.
Fixed¶
backfill()completes a chunked snapshot whose'snapshot_open'row has nokind(written by an unreleased build that put it inmode), taking generation 0 as a bootstrap and a later one as a re-snapshot, instead of failing withKeyErroronce its last chunk is in.- A checkpoint deleted or rewound while
app_idstayed the same made Delta skip every write up to the old batch id while the query ran on and the verdict advanced: with afacts_table, the run's first batch now raisesValueErrorsaying so. on_data_loss="resnapshot"with a checkpoint that Python and Spark resolve differently (a schemeless/mnt/...path on Databricks classic, an HDFS default file system, a Spark Connect client) found no offsets and re-snapshotted on every run: when the facts table holds batches of the stream, the pre-flight now raisesValueError.- A migration re-run after a crash between its change and its version stamp, or by a job that lost the race to stamp it, appended duplicate columns and failed.
apply_changesfailed on a bronze written withincludeCommandId=false; it now orders by(_start_lsn, _seqval, _operation)there.- Capture instance names with non-ASCII letters (
dbo_Café) failed every call; any name without]or a control character, up to 100 characters, is accepted. The validators no longer accept a trailing newline. backfill(isolation="SNAPSHOT")passed the reader's check but failed while planning.- A stream on a database capture had not written to yet (
sys.fn_cdc_get_max_lsn()NULL) failed withTypeErrorinlatestOffset: a NULLmax_lsnis now the zero LSN, as the pipeline already took it.startingLsn=lateston a capture instance capture has not reached yet (a quiet database just after the enable) started below the instance's first LSN and failed its first batch with a falseDataLossError: it starts just before that LSN, as a snapshot is stamped, and asks to retry when neither is known. - A snapshot's own commit is found among every commit after it, not only the last five.
- A table path holding a backtick is escaped in SQL.
- A stream whose first batch created bronze while
backfill()created it too failed withDELTA_PROTOCOL_CHANGED; a CREATE that loses the race to another writer now takes the table that writer made, for every table the library creates. - The Databricks example's epoch was off by the driver's UTC offset on a driver not set to UTC.
0.2.0rc1 - 2026-10-04¶
State compatibility: offsets and checkpoints unchanged. Existing tables migrate the next
time a stream, finalization.advance or apply_changes opens them: bronze migration 2 adds
_snapshot and _chunk (NULL on existing rows, whose snapshot is still found by their
_start_lsn) and rewrites the comment of _start_lsn; facts migration 7 rewrites the
comments of rows, event and detail, and facts migration 8 those of event and
detail and the table comment again, and facts migration 9 that of event (the facts
schema version is now 9; no column is added); control migration 2 adds open_snapshot_lsn
and snapshot_wave, and control migration 3 rewrites the comment of open_snapshot_lsn. New table kind reconcile (the report of reconcile()), with no
migrations (ADR 0013).
Added¶
- Chunked snapshots read next to the running stream, for tables the link cannot read
within the CDC retention:
to_delta(..., snapshot="chunked")(withbootstrap=Trueoron_data_loss="resnapshot"; needsfacts_table) records the snapshot's LSN S and the key's extent and starts the stream at S without reading the table, andstream(...).backfill(target, *, app_id, facts_table, chunk_rows, max_waves, max_seconds, min_headroom_hours, isolation), called repeatedly in its own task, reads them in waves ofnumPartitionschunks, each stamped withmax_lsnbefore its read, one Delta commit per wave. The first call plans every chunk and records the plan in asnapshot_planfacts row that later calls reuse (anotherchunk_rowsis ignored with a warning), so the chunks are fixed ranges andchunks_totalis exact. One integer key is counted per slice of a fixed grid in one server-sideGROUP BY, dense slices counted again on a finer grid, and the slices packed into chunks of at mostchunk_rowsrows whatever the skew; any other key gets keyset boundschunk_rowsrows apart. The last chunk ends just above the MAX recorded at the open, so the rows inserted since come from the stream alone (for a keyset plan, at the first key after it, looked for again before each wave while there is none). New facts eventssnapshot_open,snapshot_planandsnapshot_chunk(laston the final chunk); thebootstraporresnapshotrow comes after the last chunk. A crash between a wave's append and its facts rows reruns the wave without appending twice, rebuilding its facts rows from the commit or, once log cleanup dropped it, from the chunks bronze holds, whatever the rerun'snumPartitions; a loss while one is open opens a newer one (ADR 0028). - Snapshot reader options
snapshotChunks(read only the given key ranges, numbered in_chunk, with achunk-<i>.jsonmetrics file each),snapshotKeys(the columns those ranges apply to, by default the unique index) andisolationLevel=snapshot(SNAPSHOT isolation where the database allows it; neverNOLOCK). apply_changesapplies an open chunked snapshot, bootstrap or re-snapshot, wave by wave (control columnsopen_snapshot_lsn,snapshot_wave): a chunk row never brings back a key deleted after its stamp. With one integer, date or timestamp (datetime2,datetime,smalldatetime) key that the chunks are cut on, each wave also deletes the silver keys of its chunks' ranges that the chunks lack and whose image is older than the chunk's stamp, so the keys a re-snapshot's purged gap deleted leave silver wave by wave; keys in adatetime2(7)bound's microsecond, which Spark cannot place, are left to the rebuild. At the snapshot's completion row silver is rebuilt by absence; its verdict is held while a snapshot is open (ADR 0019 amended).reconcile(spark, options, silver, keys=None, *, bronze, control_table, facts_table=None, bucket_rows, sample, report_table, seed): compares silver with its SQL Server table by row count and key sum per bucket of an integer or date key (one count for other keys), then row by row through a SHA-256 of the captured columns on the mismatched buckets and a sample, read in ranges ofkeys, and classifies keys as MISSING_TARGET, MISSING_SOURCE, RECORD_DIFF or IN_FLIGHT (against silver'sapplied_lsnincontrol_table). Withfacts_tableit checks the newest chunked snapshot's chunks against bronze (CHUNK_TILING, CHUNK_ROWS, CHUNK_STAMP). The report goes to an optionalreport_table; a "Validation" guide covers it.-
Lab check t10 (
lab/checks/t10_chunked_snapshot.py): a chunked bootstrap next to the running stream under a continuous writer (inserts, updates, deletes, key updates) and a transaction holding a range's locks, and with--resnapshota chunked re-snapshot after a forced cleanup purged a gap with deletes, whose keys leave silver wave by wave. Passes on SQL Server 2022 CU27; runs in the CI lab job. -
stream(...).seed(target, df, as_of): seed the target from a copy of the table you already have, for tables too big to snapshot within the CDC retention.as_ofis an LSN or the UTC time the copy started, mapped on the server's clock (sys.fn_cdc_map_time_to_lsn, through the newCdcClient.time_to_lsn). The copy is written as snapshot rows with abootstrapfacts event, andto_delta(bootstrap=True)starts from it; a rerun returns it, also after cleanup passedas_of. Optionsallow_missing_columnsandreseed(ADR 0025). finalization.track(spark, query, control_table, table_name): continuous-mode finalization. AStreamingQueryListeneradvancesfinalized_untilafter every batch of a running query (aprocessingTimetrigger or the default), from a worker thread, off the listener bus, retrying a failed MERGE twice;join()waits for the last verdict after the query stops (ADR 0026).start_many,await_allandstop_all(mssql_cdc.fanout): oneto_deltastream per capture instance from{ci}templates for target,app_idand checkpoint, with per-table option overrides, a shared facts table created before the first start, and failures isolated per query; a "Many tables" guide covers sizing, one job versus one job per table, failure isolation and Databricks (ADR 0027).- CI runs the integration tests that read through the backend (14 of them) a second time
with
backend=arrow-odbc(ODBC Driver 18 installed in the job): thearrow-odbcbackend is now tested, except for the heartbeat, facts metrics, re-snapshot, silver, type changes and dropped columns (ADR 0003 Amendment 2). - Lab check t9 (
lab/checks/t9_capture_instance_switch.py): a table switched to a new capture instance with a new column under a continuous writer, runningsql/switch_capture_instance.sqlas written with a least-privilege login. Passes on SQL Server 2022 CU27 and 2017 CU31; runs in the CI lab job (ADR 0023 amended, which also records that the production SQL Server 2016 SP3 change tables have__$command_id).
Breaking¶
apply_changesadvances silver'sfinalized_untilonly when givenfacts_table: a chunked snapshot shows in the facts alone until its first wave, and an emptied table's re-snapshot only there, so without them silver could claim periods it does not hold. A stream without a facts table can pass any name where no table exists.
Changed¶
to_delta(bootstrap=True)with eithersnapshotmode, andsnapshot(), return the S of a chunked snapshot of the target, open or complete, instead of reading the table again;seed()refuses a target that holds one. A recovery that opened a chunked re-snapshot and stopped before writing its state opens a newer one, a generation on, once cleanup has passed the first.- One snapshot mode per run, locked while a snapshot is open: with a facts table, a full
snapshot taken by
to_deltanow writes asnapshot_openfacts row (detailmodefull,kindbootstraporresnapshot; one per run, at its own LSN) before it reads the table, closed by itsbootstraporresnapshotrow.to_delta(withbootstrap=Trueoron_data_loss="resnapshot"),snapshot()andbackfill()raiseValueErrorwhile a snapshot of the stream in the other mode is open, andseed()while one in either mode is, saying how to finish it; the mode may change between runs. A full one stops counting once CDC cleanup passes its LSN, as it can then never complete: a full re-snapshot that outlived the retention can be retried withsnapshot="chunked"in the same generation.snapshot()takes new keyword argumentsapp_idandfacts_tablefor the check (ADR 0028 amended). backfill(isolation="snapshot")plans the chunks under SNAPSHOT isolation too: the counts and seeks of the first call and the search for the first key after MAX before each wave no longer wait for a writer's locks under READ COMMITTED.plan_chunks,last_boundand the client'skey_buckets,key_rangeandkey_boundtake an optionalisolation(ADR 0028 amended).- The file-backed fake gives an update's 3 and 4 rows one
__$seqvaland__$command_id, as SQL Server does, so the unit tests rank them by__$operationalone. - A snapshot in bronze is named by its
_snapshotcolumn, never by the largest_start_lsnof its rows:snapshot(),to_delta(bootstrap=True), the re-snapshot's reuse check,seed()'s reruns and silver's rebuild point readcoalesce(_snapshot, _start_lsn)of whole snapshots, and the facts' completion rows (ADR 0016 amendment 3, ADR 0025 amended). apply_changes: operation 3 deletes its own key, outranked by the 4 of the same key and commit, instead of being ignored, so a key update SQL Server records as 3 and 4 cannot leave the old key in silver.- The bootstrap guide sizes a snapshot by MB/s (1.8 to 5.9 MB/s measured against a production source), not a fixed rows-per-second figure: rows per second vary with the row width.
- Snapshot key bounds are bound as text and
CASTserver-side for both backends (binary as hex): the same SQL shape and the same seeks (ADR 0016 amended). docker-compose.ymltakesMSSQL_IMAGEto run the lab on another SQL Server version (e.g.mcr.microsoft.com/mssql/server:2017-latest).
Fixed¶
- The
arrow-odbcbackend now reads whatmssql-pythonreads: - it adds the ODBC Driver 18 keyword when the connection string names no driver, and
honours
connectTimeout; - text is UTF-16 both ways;
(max)/text/xml/imagevalues up to 64 KiB (longer ones fail the read);datetime2is fetched in microseconds (no overflow past 2262);datetimeoffsetis converted to its UTC instant;close()releases the connection.- The integration tests retry enabling CDC while the SQL Server Agent is still starting (error 14258).
0.1.0 - 2026-10-01¶
State compatibility: first release: offsets v1 contract
(ADR 0002), checkpoint generations
(ADR 0018), facts migrations
1-6 (6: the detail column of the source's events), bronze migration 1 (the comments of
_capture_instance and _command_id), control migration 1, and the silver table kind with no
migrations yet (ADR 0013).
Added¶
- Streaming source
format("mssql_cdc")on the Python DataSource V2 API, registered withregister(spark)(ADR 0001). Offsets are the last processed commit LSN as hex plus its commit time (ADR 0002); every batch is a prefix of the commit history and ends on a commit boundary. Trigger.AvailableNowand admission control (maxCommitsPerBatch) on Spark 4.2, and on DBR 18.2 through its backport; a legacy reader for older Spark.- Reads of the change table
cdc.<ci>_CTitself, for__$command_id, with onlySELECTon it beyond what the CDC functions need (ADR 0009). - Retention guard on the driver and again on the executors after each read:
DataLossErrorunlessfailOnDataLoss=false. - Captured columns inferred with
sys.sp_cdc_get_captured_columns,columnsto override (ADR 0007). sourceTimeZone=auto: the server's time zone fromCURRENT_TIMEZONE_ID(), or its current UTC offset before SQL Server 2022 (ADR 0008); every time column is aTIMESTAMP_NTZin UTC.numPartitions=auto: the compute's cores (ADR 0011), with commit-aligned ranges balanced by the change table's rows (ADR 0015).- Drivers:
mssql-pythonby default, installed with the package and fetching straight into Arrow, andarrow-odbc(untested, the[arrow-odbc]extra) (ADR 0003); a file-backed fake (backend=fake) for engine tests (ADR 0006).mssql-pythonis imported only when it opens a connection, so the other backends work without its system libraries. - Capture instances match ignoring case, as SQL Server's default collation does: the exact
name first, and two names that differ only in case raise an error naming both.
_capture_instancekeeps the name as the options give it (ADR 0016, amendment 2). - Delta sink
sink.delta_sink(): idempotent appends (txnAppId/txnVersion) and per-batch facts (row counts, LSN and commit-time ranges, timings) in the commit'suserMetadataand a facts table. - Network and read metrics per partition in the facts, folded from
metricsPath, one directory per stream (stream()puts an explicit one's files under<metricsPath>/<app_id>) (ADR 0014), and the retention headroom (ADR 0017). - Capture lag and ingestion lag in the facts (
source_max_commit_ts,capture_lag_seconds,ingestion_lag_seconds), andmax_lsnwith its commit time in the query progress on every trigger (reportLatestOffset) (ADR 0020). - The batch's end offset in the facts (
end_lsn,end_commit_ts, facts migration 5): the retention headroom and the ingestion lag are measured from it, not from the batch's last change. Every micro-batch writes a facts row, those that read no rows included (rows = 0), so facts stop arriving only when the stream or CDC capture stops (ADR 0014 amendment 3, ADRs 0017 and 0020 amended). - Completeness signal
finalized_untilin a control table, advanced after the data and never backwards:finalization.candidate(),advance(),is_final(),end_offset_from_progress()(ADR 0004, ADR 0005). - Control, facts and bronze tables created typed and commented through the
DeltaTableAPI (ADR 0012), with append-only schema migrations per table kind (ADR 0013). stream(spark, options).to_delta(...): source and sink from one set of options.- Bootstrap snapshot stamped with the
max_lsnrecorded before the read:stream(...).snapshot(target),to_delta(bootstrap=True)andformat("mssql_cdc_snapshot")(ADR 0016). Its partitions are uniform ranges of a single integer key, elseNTILEtiles of the rows by the whole key, for composite and non-integer keys (ADR 0016 amendment). - Automatic re-snapshot after CDC data loss,
to_delta(on_data_loss="resnapshot"), into a new checkpoint generation, with the loss recorded in the facts (ADR 0018). - Silver helper
apply_changes: keeps a current-state table equal to the source from the bronze change log with MERGE, rebuilds it from a newer snapshot (bootstrap or re-snapshot), keeps its position in the control table (applied_lsn,snapshot_lsn, control migration 1) and propagatesfinalized_until(ADR 0019). - Schema changes on the source and a second capture instance
(ADR 0023): every
batch checks the DDL SQL Server recorded inside it (
sys.sp_cdc_get_ddl_history); a captured column whose new type the query's no longer holds fails the batch before it reads (SchemaChangedError, exported), other DDL goes on with aschema_changefacts row, andschemaChangePolicy=failfails on any DDL. The stream follows a newer capture instance of its table at that instance's start LSN, within one batch, and writes acapture_instance_switchedfacts row; the schema is the union of both instances' columns. Bronze appends usemergeSchema, and a type bronze cannot take fails with a message aboutdelta.enableTypeWidening.to_delta(snapshot_on_switch=True)snapshots the table after the switch. Snapshots read NULL for a dropped column and are found under any instance of the table; aDataLossErrorcaused by an old instance disabled too early says so, and aPermissionErrornames the new change table's grant.sql/switch_capture_instance.sqldocuments the DBA's procedure. sql/heartbeat.sql, an optional Agent job that keepsmax_lsnmoving on a quiet database (ADR 0010).- The lab: a Faker OLTP workload and checks t1-t8 against SQL Server 2022, Spark and Delta
(
LAB.md). - Inline type hints (
py.typed). import mssql_cdcwithout PySpark raises anImportErrorthat says to run on a Spark platform, which ships its own, or to install the[spark]extra.