0028: Chunked snapshots read next to the running stream¶
Status: accepted
Date: 2026-10-02T20:30:12-03:00
Amended: 2026-10-03T14:30:38-03:00, chunks planned once from per-slice counts and fixed in a 'snapshot_plan' row; a wave's facts rebuilt from bronze; one snapshot mode per run, locked while a snapshot is open; per-wave range deletes in silver, in re-snapshots and on datetime2 keys (see the Amendment)
Amended: 2026-10-03T18:10:05-03:00, a full snapshot opens once per run and stops holding the mode once CDC cleanup passes it; a wave rebuilt from the chunks bronze holds; a keyset plan's open last chunk closed when read (see the Amendment)
Amended: 2026-10-04T17:20:07-03:00, the plan counted and sought under the backfill's isolation, so isolation="snapshot" planning does not wait for writers' locks (see the Amendment)
Amended: 2026-10-06T21:14:15-03:00, reconcile takes a difference the change table explains, after what the stream read, as IN_FLIGHT too, and finds a NULL key's change (see Amendment 2)
Amended: 2026-10-07T00:06:28-03:00, bronze's position is never a whole snapshot's S, which snapshot_on_switch takes above the stream's (see Amendment 2)
Amended: 2026-10-08T13:47:54-03:00, backfill() reads the next wave while the one before commits, and a wave takes as many rounds of numPartitions chunks as target_wave_seconds holds; the plan's chunks unchanged (see Amendment 3)
Amended: 2026-10-10T18:02:10-03:00, a chunked snapshot's key values move from the facts to a plans table next to bronze; one opened before 0.7.0 finishes with them in the facts (see Amendment 4)
Amended: 2026-10-13T13:33:12-03:00, a wave rerun after its plans rows reads the chunks they hold, and apply_changes reads the facts before the plans (see Amendment 4)
Amended: 2026-10-13T18:45:19-03:00, snapshot_on_switch opens one in chunked mode after a capture instance switch, keyed by its S (see Amendment 5)
Amended: 2026-10-13T18:52:13-03:00, chunks cut on the columns snapshotKeys names, unique or not, so a table without a unique index is read in more than one chunk (see Amendment 6)
Context¶
A snapshot taken before the stream starts (ADR 0016) has to be read within the CDC retention: the stream starts at its LSN, and whatever cleanup purges after that LSN while the table is still being read is lost, which an automatic re-snapshot can only repeat (ADR 0018). The read time is the table's size over the link: runs against a production source moved 1.8 to 5.9 MB/s of source data over four connections, 18,000 to 105,000 rows per second depending on the row width. At those rates a table of ten billion rows takes 1 to 6 days, against a default retention of 3 days, and longer for wider rows or a slower link shared with the stream. Seeding from a copy (ADR 0025) needs a copy someone already has. A long snapshot also shows no progress until its one Delta commit at the end, and a failed task reads its whole range again.
What removes the retention limit is starting the stream first, at a recorded LSN S, and keeping it running without a gap while the table is read in chunks next to it. Each chunk then only needs a stamp L ≥ S recorded before its SELECT. That is the market's pattern:
- Netflix's DBLog interleaves chunk reads with the log between low and high watermarks it writes to the source; Debezium's incremental snapshots (DDD-3) implement it.
- Flink CDC's SQL Server connector
reads its watermark without writing (the largest
start_lsnofcdc.lsn_time_mapping), and withscan.incremental.snapshot.backfill.skipit leaves the changes made during a chunk to the change-log phase instead of merging them into the chunk: this design. - Airbyte's WASS reads
the log between snapshot chunks so a long initial load does not outlive its retention;
AWS DMS
caches the changes made during a full load and applies them after it; Oracle GoldenGate's
HANDLECOLLISIONSlets the replicated changes overwrite the rows an initial load wrote; Databricks AUTO CDC orders changes by asequence_bycolumn and builds changes from snapshots.
DBLog and Debezium write watermarks and keep dedup windows because they emit one ordered stream. Here bronze is a change log ordered by LSN downstream (silver's MERGE keeps the newer image, ADR 0019), so a lower bound read from the source is enough, and nothing is written to it (invariant 11 unchanged). The conditions the design rests on:
- P1, no stale chunk: a read stamped L sees every commit at or before L. L is
max_lsnread before the SELECT; capture processes commits in order and only committed ones; READ COMMITTED and RCSI see what was committed before the statement started. - P2: every stamp is at or after S (
max_lsnonly moves forward, S is read first). - P3: bronze holds every change after S without a gap: the stream generation starts at S.
- P4: the chunks tile the key space up to MAX: complementary predicates
(
client._key_select), the first open below, the last ending just above MAX. Keys outside [MIN, MAX] read after S were inserted after S, so the stream has them. - P5: removing a key always reaches bronze as a row: a delete (operation 1), a key update recorded as a delete and an insert (1 and 2), or a before-image (3) with the old key.
Rebuild rule: the rows of the snapshot S and the changes with _start_lsn above S; the
latest row per key by (_start_lsn, _command_id, _seqval, _operation); a key whose latest
row is a delete is dropped, and a key in neither is absent. A chunk row can be newer than
its stamp (a commit after L the SELECT saw); that commit's change row comes after S and
ties or wins, so the overlap only replays. Three places took the snapshot's LSN as the
largest _start_lsn of operation-0 rows (_last_snapshot, _recover's reuse check,
silver's rebuild point), which chunk stamps break.
Decision¶
- Execution:
CdcStream.backfill(), a batch call in waves, called again until done.to_delta(..., snapshot="chunked")(withbootstrap=Trueoron_data_loss="resnapshot") only opens a snapshot: it records S and the key's extent and starts the stream generation at S at once, reading nothing from the table; the firstbackfill()call plans the chunks (Amendment).backfill(target, *, app_id, facts_table, chunk_rows=None, max_waves=None, max_seconds=None, min_headroom_hours=None, isolation=None)reads the newest open snapshot in waves ofnumPartitionschunks (rounds of them, read ahead: Amendment 3), in its own task next to the stream task, and returns{snapshot, chunks_done, chunks_total, done, paused, reason}. - Considered: a second streaming query over the chunks. Its offsets would be chunk
indices in a second checkpoint, with a query that ends; a batch call is the same reads
with a bound (
max_waves,max_seconds) and a retry the scheduler already gives. - Considered: a thread inside
to_delta. It dies with the stream's driver, cannot be scheduled or bounded apart, and two writers in one process share one failure. - State: facts event rows and two bronze columns; no new table. Bronze gains
_snapshot(S of the snapshot a row belongs to; NULL on change rows; on rows written before it,_start_lsnstands in) and_chunk(bronze migration 2). The facts carry'snapshot_open'(min = max = S, detail: mode, kind, keys, the key's extent, generation, the gap; a full snapshot writes one too since the Amendment),'snapshot_plan'with every chunk (Amendment), one'snapshot_chunk'row per chunk (rows, min = its stamp L, max =max_lsnafter the read, detail: snapshot, chunk, wave, bounds, last) and, once the last chunk is in, the usual'bootstrap'or'resnapshot'row with min = max = S (facts migrations 7 and 8 rewrite the comments). Only those two remain snapshots (invariant 14). The chunked mode requiresfacts_table. A wave is one bronze append (txnAppId <app_id>#snap.<S>,txnVersionthe wave, its chunks inuserMetadata), then its facts rows (<app_id>#snapchunks.<S>, the wave): a crash between the two reruns the wave, Delta skips the append and the facts rows are rebuilt from the commit that holds it, or from the wave's rows in bronze once log cleanup has dropped that commit (Amendment). Every reader of "the snapshot" now usescoalesce(_snapshot, _start_lsn)and the completion events, never the largest operation-0_start_lsn. - Considered: a state table of its own. Another kind to migrate, and a third table to keep consistent with bronze and the facts, which already record every other event.
- Considered: state in the checkpoint directory. Local or Volume paths only, and
downstream (silver) could not read it. Bronze
userMetadataalone: log cleanup drops it. - Silver: waves applied as they arrive, rebuild at completion by absence.
At the completion row silver is rebuilt from the rows of S and the changes after S, with
whenNotMatchedBySourceDelete: absence from the snapshot deletes, whatever the key type. While a snapshot is open, a bootstrap or (since the Amendment) a re-snapshot, its waves are applied as their'snapshot_chunk'rows arrive (positionopen_snapshot_lsnandsnapshot_wave, control migration 2), each chunk row with every bronze change of its key after S, so a chunk row never brings back a key the stream deleted after its stamp. With one integer, date or timestamp key that the chunks are cut on, each wave also deletes the silver keys of its chunks' ranges that the chunks lack (Amendment). Silver's verdict is held while a snapshot is open, and never advanced withoutfacts_table, the only place a snapshot shows before its first wave. Operation 3 now deletes its own key, outranked by the 4 of the same key and commit: a key update SQL Server records as 3 and 4 no longer leaves the old key behind. - Considered: only the rebuild at completion. A weeks-long bootstrap would leave silver without most rows for weeks.
- Considered at first and rejected, then taken by the Amendment for the keys Spark orders as SQL Server does: each chunk deleting the silver keys of its range it lacks. A silver table fresh at the open only gets keys from chunk rows and changes after S, whose removals reach bronze (P5); the rest are stale keys of a silver built before a lost history, which the rebuild removes for every key type while the verdict is held, but only at the completion, weeks away for a large re-snapshot. Spark also orders strings by bytes, SQL Server by the column's collation.
- Isolation: READ COMMITTED, optionally SNAPSHOT, never
NOLOCK. A chunk reads as a whole snapshot does.isolation="snapshot"(reader optionisolationLevel=snapshot) prefixesSET TRANSACTION ISOLATION LEVEL SNAPSHOTwhere the DBA setALLOW_SNAPSHOT_ISOLATION; SQL Server refuses it otherwise. - Considered:
NOLOCK. A dirty read breaks P1 and the absence the deletes rely on. - Under locking READ COMMITTED a chunk waits for a writer's locks and delays writers behind it; SNAPSHOT and RCSI hold the version store instead. The chunk size bounds both.
- Validation:
reconcile(). Tier 1 counts rows and sums keys per bucket of one integer or date key on both sides (one scan on SQL Server,COUNT_BIGandSUMasdecimal(38,0)); any other key gets a whole-table count. Tier 2 reads every mismatched bucket and a sample of the rest through the snapshot reader and comparessha2(to_json(struct(...)), 256)computed by the same Spark function on both sides, classifying keys as AWS DMS validation does (MISSING_TARGET, MISSING_SOURCE, RECORD_DIFF), or IN_FLIGHT when bronze holds a change (no snapshot row) newer than silver'sapplied_lsn(control_table, not the newest stamp silver holds) or the source read: check again later (also when only the change table holds it yet: Amendment 2). Its ranges are cut on the key it compares (snapshotKeys), not always the unique index. Withfacts_table, the newest chunked snapshot's chunks are checked against bronze (CHUNK_TILING, CHUNK_ROWS, CHUNK_STAMP). The report is a new table kind,reconcile. - Considered: server-side
HASHBYTESper bucket (a later tier, not now); neverCHECKSUM_AGGorBINARY_CHECKSUM, which XOR and collide.
Planning, after S: the open records the key's extent (client.snapshot_plan) and the first
backfill() call plans every chunk (client.plan_chunks) into a 'snapshot_plan' row that
no later call changes. One integer key gets chunks packed from row counts per slice of a
fixed grid; any other key gets keyset bounds, the key chunk_rows rows after the previous
bound (key_bound: a TOP (n + 1) per seekable piece of the range), walked up front below
the MAX recorded at the open. This supersedes arithmetic steps over [MIN, MAX] from a
sys.sp_spaceused estimate and keyset bounds found wave by wave (Amendment). The final
chunk ends at MAX + 1, or at the first key after MAX (open when there is none, or when it
reads back as MAX: Amendment): a table written while it is read does not pile the rows
inserted since S into the last chunk. Facts mark it last, which is what
completes the snapshot. Each wave is stamped with snapshot_lsn() before it is read and
fails if that is below S (a readable secondary). min_headroom_hours pauses backfill()
while the stream's newest facts row has less retention headroom, or there is none. A loss
while a chunked snapshot is open opens a newer one in the next generation, which abandons
the older. A chunked re-snapshot (ADR 0018) records its state and starts the generation at S
before anything is read, so the failure of a re-snapshot whose own LSN is purged before its
read ends no longer exists for it.
Consequences¶
- A table no longer has to be read within the CDC retention, only while the stream runs
without a gap. The chunks share the link with the stream: size a snapshot by its data
size over the measured MB/s, and keep
min_headroom_hoursabove the cleanup interval. - Operation-0 rows are no exact history points: a chunk's image can be newer than change rows stamped after it. The rebuilt state is right; as-of queries over bronze must not read a chunk row as the state at its stamp.
- It breaks when the stream has a gap after S (data loss,
failOnDataLoss=false, an older capture instance dropped early): the snapshot is abandoned and a newer one supersedes it. Also with a stamp below S (a manualsnapshotLsn),NOLOCK, chunks read from a readable secondary with S from the primary, and a type change during a weeks-long snapshot, which fails the chunk's cast like the stream's (ADR 0023). - State: bronze migration 2, facts migrations 7 and 8, control migration 2 and the
reconcilekind; existing tables migrate when next opened, and the legacy snapshots readcoalesce(_snapshot, _start_lsn). to_delta(bootstrap=True)in eithersnapshotmode, andsnapshot(), return the S of a chunked snapshot opened for the stream instead of reading the table again (with the facts table, a full run only once it is complete: the Amendment's lock); seeding refuses a target that holds one. A recovery that opened a chunked re-snapshot and stopped before writing its state reuses it, unless cleanup has passed it too: then it opens a newer one, a generation on.- Tests:
tests/test_source_fake.pyandtests/test_delta_sink.pyrun the plans, the waves, a commit between a chunk's stamp and its read (the fake'scommit_before_read), a crash between a wave's append and its facts rows, the throttle and a loss while a snapshot is open;tests/test_silver.pythe waves and the rebuild;tests/test_reconcile.pythe tiers and the chunk checks.tests/integration, on SQL Server 2022 with a least-privilege login: a chunked bootstrap next to a running stream and a writer making inserts, updates, deletes and key updates ends with silver equal to the table; keyset chunks on a composite key (case-insensitive leading values) and a varchar key under changes; a chunk that waits under READ COMMITTED (LCK_M_S) for a transaction holding locks in its range; SNAPSHOT isolation refused until allowed, then reading the committed rows without waiting;sp_spaceused,key_max,key_boundand a keyset plan with a table grant or column grants only;key_boundreading at mostn + 1rows per piece; reconcile matching a quiet table and classifying injected differences; and, since the Amendment, the tests listed there. Lab check t10 runs it under a continuous writer and a held range, and--resnapshotthrough a purged gap. - Not verified: the RCSI-versus-capture visibility window behind P1 (theoretical, microseconds; the tests run under locking READ COMMITTED and SNAPSHOT); key updates on SQL Server 2017 (t9 covers 2017 for switches only); readable secondaries; Databricks, the production link and weeks-long runs; a schema change during a backfill; the first wave creating bronze while the stream's first batch does (one retry on Delta's protocol or metadata conflict).
- ponytail:
backfill()andapply_changesread the snapshot's facts rows on every call; filter by wave if that shows up.
Amendment: a fixed plan, one mode per run, range deletes¶
The first version cut an integer key in equal steps over [MIN, MAX], so chunks were as
uneven as the key, and re-planned keyset chunks on every call, so a later backfill() with
another chunk_rows changed an open snapshot's chunks. A full and a chunked snapshot could
both open on one generation, and silver kept the keys a re-snapshot's purged gap deleted
until the completion, weeks away for a large table. Four changes, each superseding the
passage of the Decision that points here.
Chunks planned once, sized by row counts¶
- The first
backfill()call plans every chunk (client.plan_chunks) and records them in a'snapshot_plan'facts row (txnAppId <app_id>#snapplan.<S>, version 0; detail{snapshot, kind, keys, chunk_rows, chunks}). Later calls read it: theirchunk_rowsis ignored with a warning, andchunks_totalis exact from the first call on. A rerun's plan row is skipped by Delta and the rerun reads the stored one; for two calls committing it at the same instant this rests on Delta's conflict check on thetxnAppId, which fails the second (not tested here). The open still records only the key's extent (theplanof'snapshot_open'), so the stream starts at S without waiting for the planning. - One integer key: its rows are counted per slice of a fixed grid over [MIN, MAX] in one
server-side
GROUP BY(key_buckets, about 16 slices perchunk_rows, at most 100,000 in one query); a slice holding more thanchunk_rowsis counted again on a finer grid over its own MIN..MAX (key_range), down to one value. Consecutive slices are then packed, in key order, into chunks of at mostchunk_rows. The ranges stay fixed and the sizes follow the counts, not the key's spread: a dense cluster is split, a sparse region or a sentinel far above the ids joins its neighbours, and no chunk starts on an empty slice. When planned, every chunk but the last holds at mostchunk_rowsrows and more thanchunk_rowsless the slice after it (on an even key a slice is about a sixteenth ofchunk_rows); rows written since then change that a little. Thesys.sp_spaceusedestimate only sizes the first grid. - Other keys keep keyset bounds,
chunk_rowsrows apart when planned, now all found before the first wave. The drivers return adatetime2(7)key truncated to the microsecond (only the last key column may be one,key_types), so its bounds sit on a microsecond, and when the key after MAX shares MAX's microsecond its bound reads back as MAX's, at or below MAX: the last chunk is then open, or the keys of that microsecond would be in no chunk (found while amending,tests/integration). - The end of a keyset plan's last chunk is the first key after MAX. When there is none at
planning,
backfill()seeks it again before each wave (client.last_bound), so the rows inserted above MAX while a long backfill runs, the stream's, do not pile up in the last chunk (a key that grows at the top: adatetime2creation time, a sequentialuniqueidentifier). Every other bound stays as planned. - Cost: planning reads the whole key once before the first wave (one
GROUP BYover the narrowest index on the key plus the recounts, or oneTOP (n + 1)seek per chunk), under the backfill's isolation: READ COMMITTED by default, where a writer holding locks delays it as it delays a chunk read. Withisolation="snapshot"the counts, the seeks andlast_bound's search before each wave read under SNAPSHOT too, as the chunks do, and do not wait (the first version planned under READ COMMITTED whatever the isolation). - Considered: equal steps over [MIN, MAX] (the first version). One dense cluster or a sentinel puts nearly every row in one chunk, and the switch to keyset bounds past 4 values per row was a guess.
- Considered: planning wave by wave from the last bound. Cheaper up front, but the chunks
of an open snapshot then depend on each call's arguments, and
chunks_totalis unknown.
A wave's facts rebuilt from bronze¶
When a rerun's append is skipped and the commit that holds the wave is no longer in the
target's history (Delta log cleanup), its 'snapshot_chunk' rows are rebuilt from its rows
in bronze: the chunks of _snapshot = S from the wave's first to the last that has rows
there, whatever the rerun planned (its numPartitions may differ from the attempt's), with
the bounds of the plan, the stamp and the counts of the rows, and read_seconds and
read_mb NULL. The attempt's empty chunks after those are read again by the next wave. A
rerun whose own read is empty looks for that commit too, and a skipped append whose rows
are nowhere raises. Before, backfill() raised and the snapshot had to be taken again,
though all its rows were in bronze. The history lookup stays the first try.
One snapshot mode per run, locked while a snapshot is open¶
- A run (one
to_deltacall: its bootstrap and its re-snapshot) takes its snapshots in onesnapshotmode. The mode may change from one run to the next, never while a snapshot of the other mode is open: a full read over a chunked snapshot still being backfilled, or a chunked open over an unfinished full one, would put two snapshots in one generation. - A full snapshot now writes
'snapshot_open'too, before it reads the table: one per run, at the run's own S and with notxnAppId; a chunked one stays one per generation (<app_id>#snapshots, the generation). Its detailmodeis'full'or'chunked', andkindholds'bootstrap'or'resnapshot'(the first version, unreleased, put the kind inmode). A snapshot is open until a'bootstrap'or'resnapshot'row of the same stream, any generation, hasmax_lsnat or after its S. - A full one holds the mode only while a run may still be reading it: not once CDC cleanup has passed its S, as it can then never complete (a re-snapshot that outlived the retention, a run killed long ago), nor next to a chunked one of its generation. A chunked run then opens the generation over it. That is why a full run writes its own open: a rerun under the first attempt's S would count as dead while it reads.
- With a facts table,
to_delta(withbootstrap=Trueoron_data_loss="resnapshot"),snapshot(app_id=, facts_table=)andbackfill()raise on an open snapshot of the other mode, andseed(app_id=, facts_table=)on one of either mode. The error says how to finish it:backfill()until done for a chunked one, a rerun withsnapshot="full"(which reads the table again) for a full one. A run that finds an open of the other mode committed after its own check stops too: a chunked run whose generation holds only a full open still being read, and a full run that reads back a chunked open of its generation after writing its own. Two chunked opens of one generation at the same instant rest on Delta's conflict check on the sharedtxnAppId(not tested here).backfill()checks once per call, first, so a full open made during a call stops the next one. - Considered: no lock, the newest open wins. Silver's rebuild point and reconcile's chunk checks would then mix two snapshots.
- Considered: one mode for the stream's lifetime. A table that grows past what the retention allows needs to move to chunked without a new checkpoint.
- Considered: a row closing a full open when its run raises, so that a chunked run could follow at once. A killed process writes none, so the cleanup test is needed anyway; the row would only shorten the wait after an exception.
- Consequence: a full snapshot no run reads any more holds the mode until CDC cleanup passes its S, the retention at most: the library cannot tell it from one still being read. A full re-snapshot that outlived the retention is past that already, so its rerun may be chunked at once.
Range deletes per wave, in re-snapshots and on datetime2 keys¶
- An open re-snapshot's waves are applied as a bootstrap's are: chunk rows with every later
change of their keys, tracked by
open_snapshot_lsnandsnapshot_wave, the verdict held until the completion. Before, it applied changes only and waited for the rebuild. - Each wave also deletes, at each chunk's stamp L, the silver keys of its range [lo, hi) that the chunk does not hold and whose image is older than L: the chunk saw every commit up to L, so those keys were gone by then. They are ranked as delete rows stamped L, so a later change of the key still wins. The keys a re-snapshot's purged gap deleted leave silver wave by wave instead of at the completion.
- Only when silver's key is the snapshot's own (the open row's
keys: the bounds are cut on it) and is one column of an integer, date or timestamp type (datetime2,datetime,smalldatetime), which Spark orders as SQL Server does. Other keys (strings, whose collation Spark ignores, composite keys, other types) keep leaving stale keys to the rebuild at the completion. - Integer bounds are read as BIGINT; the plan's last bound, MAX + 1, counts as open when it is past BIGINT's range.
- A
datetime2(7)key holds 100 ns, Spark only microseconds (the drivers truncate). The plan's bounds are truncated the same way, so each key falls on the same side of them in Spark as on SQL Server. A bound with digits below the microsecond would not place the keys of its microsecond: a lower one moves up one microsecond and an upper one is truncated, so no chunk deletes those keys and the rebuild does, never the wrong chunk.
Tests¶
tests/test_source_fake.py: an integer plan packing counted slices whatever the skew.
tests/test_delta_sink.py: the plan kept across calls and a wave's facts rebuilt from
bronze without its commit, a rerun planning more chunks than the commit holds; a rerun
reading nothing finding the commit; a keyset plan's open last chunk closed at the first key
after MAX when read; a full snapshot a crash left open holding its mode until a full run
completes it; a full re-snapshot past the retention giving its generation to a chunked
rerun; runs finding the other mode opened after their check. tests/test_silver.py: each
wave deleting the stale keys of its ranges, a chunked re-snapshot deleting the gap's keys
wave by wave, an integer plan's last bound past BIGINT, no range deletes on a composite key
or on a key the bounds were not cut on. tests/test_client_sql.py: the
counting and seeking SQL. tests/integration: an integer plan counted on the server for a
skewed BIGINT key (a dense cluster, a sparse region, a key at BIGINT's maximum) and
backfilled; a datetime2(7) plan reading the keys in MAX's microsecond; a chunked
re-snapshot deleting stale datetime2(7) keys wave by wave but not one in a bound's
microsecond; integer and keyset plans under SNAPSHOT isolation not waiting for the locks a
writer holds, which a READ COMMITTED plan waits on. Lab check t10 passes in both modes with
the plan (LAB.md).
Amendment 2: in flight up to what the change table holds¶
reconcile took a difference as IN_FLIGHT only when bronze held a change to it. With the
stream behind the source, every key changed in between came out as MISMATCH,
MISSING_TARGET, MISSING_SOURCE or RECORD_DIFF: stream lag reported as an integrity failure.
And the join that finds a key's changes compared keys with =, so a change of a NULL key
never made its difference IN_FLIGHT. CDC refuses a unique index over nullable columns, but
keys may name other columns.
- Once the source is read (the counts, then the rows of the buckets compared), bronze is
pinned and
sys.fn_cdc_get_max_lsn()read. The change table's changes after bronze's position (its newest change, or the S of a chunked snapshot, which the stream reads from) up to that LSN are what the stream has not read yet; their keys count with bronze's changes after the older of E and M. They are read through each capture instance's piece of the range, as the stream reads them (ADR 0023), withiter_changeson the key columns: the change table the stream already reads (invariant 11), no new grant and no new T-SQL. - Not a whole snapshot's S:
to_delta(snapshot_on_switch=True)takes one atmax_lsnafter the batch that read the switch, while the stream's checkpoint stays at the batch's end, so a commit in between would be in neither bronze's changes nor the read. Reading from the newest change instead only finds more IN_FLIGHT, and costs more only while silver has not applied a bootstrap or a re-snapshot. - Still unseen: a commit the capture job has not harvested yet, in no change table (its lag, seconds). A MISMATCH that causes clears on the next run.
- That join is null-safe (
<=>), as reconcile's and silver's other key joins already were, and the report'skeywrites a NULL key column asnullinstead of leaving it out ({}). - Cost: every change row of the stream's lag crosses to the driver, keys only. A stream far behind makes that large, and most buckets IN_FLIGHT, which they are.
- Considered: the change table from silver's
applied_lsnE, without bronze. One read, but of every change silver has not applied (hours of them for an hourlyapply_changes), and blind to the ones cleanup purged that bronze still holds. - Considered: a
SELECT DISTINCTof the keys on the server, a new client method. Less to transfer, but new T-SQL on every client for a window that is a stream's lag; added if a stream far behind shows up. - The report's column comments on
statusandfailure_typestill name bronze alone: a comment migration of thereconcilekind waits for a minor release.
Tests¶
tests/test_reconcile.py: an insert, a delete and an update only the change table holds
IN_FLIGHT, then with bronze holding part of the lag, a real difference next to them still
RECORD_DIFF, and a commit after M that the count sees; the change table read per capture
instance from bronze's position up to the LSN read after the source (pure, on the fake); an
insert below a whole snapshot appended above the stream's position IN_FLIGHT; a
NULL key's change in bronze IN_FLIGHT, a real difference of it RECORD_DIFF, both named
null. tests/integration: the same lag on SQL Server 2022, read by the stream's
least-privilege login.
Amendment 3: waves read ahead, sized toward a duration¶
Against a production source (DBR 18.2, numPartitions 4, chunk_rows 1,000,000), a chunked
snapshot of 34 million rows took 943 s where a full one took 325 s, and one of 100 million rows
5,096 s where a full one took 3,364 s: the waves read 58,000 and 24,500 rows/s, the full
snapshots 105,000 and 30,000. Per wave of 4 chunks, read in 46 to 78 s and in 98 to 197 s,
15 to 25 s went from its slowest chunk's read to the end of its append (the Spark jobs, the
cache, the commit, the history read back), 6 to 8 s more before the next wave's read (its
facts rows, the throttle, the stamp), and its slowest chunk took 12 to 15 s longer than the
mean of the four, which the other three connections spent idle. The chunks stay as planned
(the Amendment's plan): what changes is how a wave is read and how many of them it takes.
- Read ahead. Once a wave is read (cached and counted, its metrics folded into its
tag), its append and then its facts rows go to one background thread, and the next wave
is stamped and read meanwhile. That wave's commit is handed over only once the one before
has finished: at most one wave reads while one commits, bronze takes the waves in order
(
txnVersionthe wave, as before), a wave's facts rows still follow its commit, and its stamp is still recorded before its read. A crash still leaves at most the last committed wave without its facts rows, which a rerun rebuilds as before. The read ahead assumes the wave committing holds the chunks it read; when its commit holds others (a rerun's first wave, which Delta skips for an earlier attempt's of another width, or a concurrent call's), the wave read ahead is dropped and read again after the commit's last chunk. A failure in either thread fails the call once the other has finished: a commit under way completes. The commit thread inherits the caller's job group and scheduler pool (inheritable_thread_target), and looks up commit times on a connection of its own, one more per call. - Waves of rounds, sized toward
target_wave_seconds. A wave takes whole rounds ofnumPartitionschunks; its one partition per chunk is coalesced intonumPartitionspartitions, partition p reading chunks p, p +numPartitions... one after the other, so the connections staynumPartitionsand neighbouring chunks are still read side by side. The rounds aretarget_wave_seconds(300 when None) over the pace of the last wave: its read, from its stamp to its rows cached, per round; for a call's first wave the facts rows of the last wave committed (duration_ms, its read and its append); one round without either. No more rounds than the rest ofmax_secondsholds, and at least one;target_wave_seconds=0reads one round per wave, as before. - The default: production waves of 4 chunks read in 1 to 3 minutes, so 300 s means about 4 rounds on the 34-million-row table and 2 on the 100-million-row one, 3 waves instead of 9 and about 14 instead of 27, each still a few minutes of work lost to a crash.
- Cost: a wave's rows stay cached until its commit, two waves at once while one reads
ahead (
MEMORY_AND_DISK): aboutnumPartitions×target_wave_seconds× a connection's rate, 2 to 4 GB of wire bytes on that source at 300 s.max_secondsnow also bounds a wave's size; a call may still pass it by up to a wave and the commit under way. - Considered: adapting
chunk_rows. Ruled out: the plan's chunks never change while the snapshot is open, and a rerun orreconcilereads them back as planned. - Considered: one partition per chunk, scheduled by Spark. A cluster with more cores than
numPartitionswould open as many connections as a wave has chunks. - Considered: the FAIR scheduler pool for the commit. Pools do not preempt running tasks: with fewer partitions than cores the append finds free cores anyway, and with as many the read's tasks hold them in either mode. The thread keeps the caller's pool.
- Considered: reading two waves ahead. More rows cached for no more overlap: a commit is shorter than a read.
- Considered: capping a wave's growth. The target bounds its duration already; a first wave much faster than the rest (a warm buffer pool) makes the next one longer, which the one after corrects.
- Not yet measured against SQL Server: on the fake almost all of a wave is its fixed cost, so a local run shows that cost going, not the production gain. The Databricks benchmark against the production source runs after the release candidate.
Tests¶
tests/test_delta_sink.py: a wave of four chunks read two at a time, one file per partition;
waves of one round, then of the rounds target_wave_seconds holds at an earlier call's pace
from its facts rows, then of what is left of max_seconds; the next wave stamped while the
wave before still writes its facts rows, and that commit completing when the next wave's
read fails; a wave read ahead of a rerun's commit that holds fewer chunks than it read,
dropped and read again from the commit's last chunk.
Amendment 4: key values in a table next to bronze¶
A chunked snapshot wrote source key values into the facts table: its open the key's MIN and
MAX, its plan the bounds of every chunk and each chunk row its own; with a composite,
string or date key those bounds are real keys of the table every chunk_rows rows (a
document number, an e-mail address). The facts table is the one an operator, a dashboard or
a monitoring job reads, often shared by every table of a fan-out
(ADR 0027), while bronze, which holds those values
anyway, sits behind the table's own access policy. Column masks and row filters on bronze do
not reach a copy in another table, so the documentation asked for the facts table to get
bronze's policy, one facts table per access domain. 0.7.0 moves the values instead.
- A plans table next to bronze.
<target>_snapshot_plans, in bronze's schema for a name and a sibling directory for a path, so that it gets bronze's access by where it lives; named after the target, as the facts rows match it already, so no option names it andapply_changesandreconcilefind it frombronze. Created typed and commented through theDeltaTableAPI (ADR 0012), a table kind of its own with its migrations module (ADR 0013). Its columns:snapshot(S),eventanddetail. One row per 'snapshot_open', 'snapshot_plan' and 'snapshot_chunk' facts row of a chunked snapshot, whosedetailis that row's payload whole: the shape the facts row had before 0.7.0, pluschunk_counton the plan. A chunk's row is found by its event, chunk and wave. - The facts rows leave out the key values. 'snapshot_open' drops
keysandplan, so a chunked open has a full one's keys; 'snapshot_plan' dropschunksand getschunk_count; 'snapshot_chunk' dropsloandhi.keysnames columns, not values, but leaves the open too:apply_changesbefore 0.7.0 cuts ranges only when the open haskeys, and with no bounds on the chunk rows it would take each chunk as the whole table and delete every key the chunk does not hold until the rebuild. Withoutkeysit applies the waves by key alone. Abackfill()orreconcilebefore 0.7.0 fails on such a snapshot with aKeyError(keys,chunksorlo) instead of reading wrong bounds. - Plans first. Each plans row is written before its facts row, idempotent like it: the
plan's and a wave's chunks with the facts rows' own
txnAppIdand version (another table, so the keys do not collide), a wave's from the tag of the commit that holds it, as its facts rows. An empty wave has no commit, so a rerun after a crash between its plans rows and its facts rows could plan other chunks (numPartitions=autoon other cores, the pace, a keyset plan's last bound sought again) whose plans rows Delta then skips: a wave whose plans rows are in reads exactly their chunks and bounds again, whatever the rerun would plan. The open's with<app_id>#snapopen.<S>, 0, not the facts row's key per generation: a run that stops between the two opens again at another S, whose facts row then needs a plans row of its own. After planning, the plan read back from the table is the one recorded, so a concurrent call, or one that stopped before its facts row, leaves one plan. - Readers take the payload whole.
backfill()(the open's extent and keys, the plan, and so the fallback that rebuilds a wave's facts from bronze),apply_changes(the open's key columns, each chunk's range for its deletes) andreconcile(each chunk's range for its tiling checks) read the snapshot's rows of the plans table and complete each facts row from it (events.Plans.whole), always after the facts rows they complete: a Delta read is not pinned, so a facts read after the plans read could hold a wave the plans read missed. A facts row that has its key values, written before 0.7.0, is taken as it is. A row with neither fails with aValueErrornaming the plans table: a range without bounds is never read as the whole table. - A snapshot opened before 0.7.0 finishes as it started. Its open, and maybe its plan
and some chunks, hold its key values in the facts already; 0.7.0's
backfill()writes its plan and chunk rows whole too, and nothing to a plans table. Every job sharing those facts, of either release, then reads its chunks:apply_changesof 0.6 included, whose range deletes need bounds on every chunk row. A snapshot 0.7.0 opens keeps them out of the facts from its open. - The wave commits'
userMetadatakeeps each chunk'sloandhi: bronze's history, under bronze's access, from which a rerun rebuilds a wave's facts. - State: the payload keys go from rows 0.7.0 writes only; a reader takes them from the
plans table, or from the row when it has them
(ADR 0021, amendment 9). Facts migration 13
updates the
detailcomment. Nomssql_cdc.min_version: an older release keeps writing the facts table, and fails only on a snapshot 0.7.0 opened, as above. - Considered: hashing or encrypting the values in the facts. A hash cannot bound a range, and a key to decrypt with is one more secret to keep, where bronze's access is one already.
- Considered: an option naming the plans table. A name derived from the target needs none, and cannot be given wrong to one of the three readers.
- Considered: one row per snapshot, its chunks in an array. A chunk's row is written with its wave, so a crash leaves no row half updated; and a chunk read again by a later wave (after a crash, a rebuilt wave's empty chunks at its end) has a row per wave.
Tests¶
tests/test_delta_sink.py: a chunked bootstrap's open, plan and chunk rows whole in the
plans table and without key values in the facts; a wave rebuilt from bronze after a crash
gets its plan's bounds there; an empty wave's rerun after its plans rows, with other
numPartitions, reads the chunks they hold; a keyset plan's last chunk ends there at the
key sought before its wave; a snapshot opened in the shape before 0.7.0 is planned and read
with its key values in the facts and none in the plans table. tests/test_silver.py: each
wave's range deletes from the plans table, and from the facts rows of a snapshot opened
before 0.7.0; a wave recorded between the facts read and the plans read waits for the next
call.
tests/test_reconcile.py: a chunk's bounds changed in the plans table fail its tiling.
tests/test_payloads.py: a facts row completed from the plans table, and a ValueError
without its row. tests/compat/test_compat.py: the snapshot each release left open after
one wave finishes with its key values in the facts and no plans table.
Amendment 5: a third way to open one, after a capture instance switch¶
to_delta(snapshot="chunked", snapshot_on_switch=True) opens a chunked snapshot in the
running generation after the batch that first reads a newer capture instance, at that
instance's start LSN, which the stream has read past
(ADR 0023 Amendment 7). For this
ADR it is one more open: the newest, so it supersedes one still open; read by backfill()
and applied by silver like the others; completed by a 'bootstrap' row; holding the mode.
What differs is its Delta keys: by S (<app_id>#snapopen.<S>, <app_id>#snapdone.<S>),
since the generation's keys may be taken by the generation's own snapshot, and its open's
switch_batch_id tells backfill() which key its completion takes. Its S is no max_lsn
after the batch, so Amendment 2's position (a chunked snapshot's S, which the stream has
read up to) still holds; the whole snapshot of snapshot="full" stays above it, as before.
Amendment 6: chunks cut on a column that is not unique¶
A capture instance of a table without a primary key or unique index has no
index_column_list, so snapshot_plan found no key and the snapshot was one chunk: the
whole table in one read and one wave, what the chunked mode exists to avoid. Such tables
are often the large ones (logs, staging heaps).
- The
snapshotKeysoption names the columns. It already cut the snapshot reader's ranges on other columns forreconcile;to_delta(..., snapshot="chunked")now opens the snapshot on them too, unique or not, and records them as the open'skeysin the plans table, the columns its chunks are cut on, as before. The plan, the waves (the reader gets them as itssnapshotKeys) and eachlast_boundsearch take the open'skeys, so a plan never moves to other columns; abackfill()whose stream would cut on others (the option changed or dropped, or the unique index changed, the check it replaces) raises. A full snapshot reads the option as it did, for itsnumPartitionsranges. The onesnapshot_on_switchopens (Amendment 5) takes the option as any other open does. - Checked at the open. A column CDC does not capture, of a type no bound can be bound
as (
key_typesgives None:time, a 100 ns type ahead of the last column) or one SQL Server cannot sort (bit, which has no MIN or MAX,text,ntext,image,xml) raisesValueErrorbefore anything is recorded, instead of a one-chunk plan. The unique index's own columns are not checked, as before. - Ranges of values, never a value split. Bounds are values and each chunk the range
[lo, hi)of_key_select, so a row is in exactly one chunk whatever repeats, and every row of a value in the same one; NULL sorts first, into the first chunk (an integer column's plan counts no NULL, so they all join it). One integer column is planned from per-slice counts as an integer key is: a slice of one value is never counted again, so a value held by more thanchunk_rowsrows is a chunk of its own. Other columns walkkey_boundseeks, and there the (n + 1)-th row fromlocan holdlo's value, which would end the chunk where it starts and the walk would never move: equal in Python, or equal only on SQL Server (a case-insensitive collation's'n'and'N', trailing spaces). When the columns are not the unique index's, each bound is therefore checked by one morekey_boundseek, of the first row in[lo, bound); when there is none, the seek is repeated twice as far until there is. The chunk then starts on a value held by more thanchunk_rowsrows and holds fewer than twice its rows.last_boundchecks the first key after MAX the same way, past MAX's own copies. Plans on the unique index make the same reads as before. - Bronze only. Without a key, silver cannot MERGE:
apply_changesrefuses a capture instance without a unique index unless givenkeysthat identify a row, as before, and its range deletes still need silver's key to be the open'skeys. A row whose cut column changes while the snapshot is read can be read by two chunks or by none; its change rows after S are in bronze, but nothing ties them to a row, so the documentation asks for a column the writers do not update. - Cost: on a column no index leads, each seek and each chunk read scans the table, which a unique index never made the plan do. Documented rather than replaced by one NTILE pass: the chunk reads scan anyway, and an index is the fix for both.
- State: no new key;
keyskeeps its meaning, the columns the chunks are cut on. A release before 0.8.0 resuming such a snapshot findskeysunlike its unique index and raises inbackfill(); itsapply_changesapplies the waves by key alone unless its keys are the open's. - Considered: a
backfill(chunk_key=...)argument. The extent (MIN, MAX) is read at the open, right after S, so the columns must be known there; and a fan-out (ADR 0027) gives each table its own options, where one argument would cut every table on one column. - Considered: a new client method that seeks the first key above
lo. Exact, one seek, but a new member of theCdcClientprotocol (ADR 0030) that a client of a user's own would lack; the doubling uses the seeks clients already make.
Tests¶
tests/test_properties.py: plans on a column that repeats, NULL now and then, compared as a
case-insensitive collation does, every row up to MAX in one chunk, a value in one only, and
none empty. tests/test_client_sql.py: the open refusing each type no chunk can be cut on
before any read. tests/test_reader_units.py: snapshotKeys refused unless a list of names.
tests/test_delta_sink.py: a table without a unique index chunked on a repeated string
column, NULLs in the first chunk, rows inserted and deleted between waves, and a backfill()
without the option refused. tests/integration: a heap on SQL Server 2022 chunked on a
varchar column (case-mixed under a case-insensitive collation) and on an int column,
both repeated and NULL now and then, under a writer inserting, updating and deleting: every
row there all along read once, none twice, each value in one chunk, and the stream holding
every insert.