Commit e33c710
committed
Add fingerprint pre-check to Recon (Redshift)
## Changes
### What does this PR do?
Adds an opt-in fingerprint pre-check to Recon, exposed as the user-facing
flag `reconcile_optimizer`. When `reconcile_optimizer=True` and the source
has a registered query builder, Recon runs a sketch-based detection pass
(MD5-sub-bucketed aggregates over both sides) before the row-hash compare
pipeline.
- MATCH -> Recon short-circuits in seconds; no full table scan, no JOIN.
- MISMATCH -> an algebraic solver returns the differing row hashes; a
surgical Stage-2 fetch pulls just those rows and feeds them into the
existing `compare.reconcile_data` flow. If the mismatch is systemic
(>15% of sub-buckets), the precheck defers to the existing pipeline.
- Ineligible -> falls through silently.
The flag defaults to False; existing behaviour is unchanged. The algorithm
is byte-identical to the dataprint sketch-based reconciliation library;
this is the first dataprint-into-lakebridge integration. Redshift is the
first dialect — adding Snowflake / Oracle / TSQL is one
`FingerprintQueryBuilder` subclass plus one registry entry.
### Relevant implementation details
- `trigger_recon_service._run_fingerprint_or_reconcile_data` is the single
decision point. Static eligibility centralised in `classify_ineligibility`;
the schema-dependent `unmapped_target_column_mapping` reason is raised
by `align_columns` as a typed exception and routed through
`FingerprintRunMetadata.ineligible(...)`. Every reason maps to an
`IneligibilityReason` enum value and is recorded on
`recon_metrics.fingerprint_metrics.ineligibility_reason`.
- Source-side reads use upstream's `RemoteQueryReader` / `remote_query()`
TVF unmodified; Stage-1 aggregation pushdown verified empirically on a
1 M-row Redshift fixture (DBR 17.3).
- Per-column hash serialization is shared with the row-hash compare path:
both the Redshift source SQL and the Databricks target SQL render each
column through `DataType_transform_mapping` via `serialize_column_for_hash`
(`reconcile/query_builder/expression_generator.py`), the same lookup the
row-hash `_default_transformer` uses. The fingerprint byte stream is
identical to the row-hash pipeline by construction; only the MD5 ->
sub-bucket/bucket arithmetic is fingerprint-specific.
- Stage-1 detection is parallelised across source / target via a 2-thread
pool; failure semantics match the serial version.
- Two new fields on `ReconcileConfig`: `reconcile_optimizer`,
`fingerprint_row_count_override`.
- Config version bumps 2 -> 3 with a `v2_migrate` that folds the legacy
spellings (`fingerprint_precheck`, `redshift_fingerprint_precheck`,
`use_fingerprint_precheck`) into the new `reconcile_optimizer` flag.
Existing deployments upgrade automatically.
### Pre-existing fixes that ride along (upstream PR #2339)
Three correctness bugs in the upstream Redshift connector MR (#2339)
surfaced during the dataprint integration P0 / P1 runs against a real
cluster. All corrupt the existing row-hash recon path on real customer
schemas and are unrelated to dataprint, but they sat in the integration
path so they are fixed inline. All fixes live in
`reconcile/query_builder/expression_generator.py` and are pinned by
regression tests.
- **Databricks block missing TIMESTAMP / TIMESTAMPTZ handler.** Redshift's
source-side transform emits `COALESCE(TO_CHAR(ts, 'YYYY-MM-DD
HH24:MI:SS.US'), '_null_recon_')` (always 6 fractional digits), but
the Databricks block had no override, so the target side fell through
to the universal default `TRIM(COALESCE(col, '_null_recon_'))` — Spark
emits a variable-length fractional component, omitted entirely for
zero-microsecond timestamps. The byte-width drift made per-row SHA2
disagree for every TIMESTAMP / TIMESTAMPTZ row in any
Redshift -> Databricks reconcile. Fix: add `COALESCE(DATE_FORMAT(ts,
'yyyy-MM-dd HH:mm:ss.SSSSSS'), '_null_recon_')` so source and target
are byte-identical.
- **Redshift block missing BOOLEAN handler.** The Redshift block defined
overrides only for SUPER / DATE / TIMESTAMP / TIMESTAMPTZ and had no
dialect-level `default`. BOOLEAN columns fell through to the universal
default `TRIM(COALESCE(col, '_null_recon_'))`, which Redshift rejects
during output schema resolution with `function pg_catalog.btrim(boolean)
does not exist`. Any customer schema containing a single BOOLEAN
column crashes row-hash recon end-to-end. Fix: explicit `COALESCE(CASE
WHEN col THEN 'true' WHEN NOT col THEN 'false' ELSE NULL END,
'_null_recon_')` so the rendered string matches Spark's
`cast(boolean AS string)` byte-for-byte.
- **Both blocks missing DOUBLE handler.** DOUBLE had no override on
either dialect, so both fell through to `TRIM(CAST(col AS string))`.
Redshift renders `double precision` at full 17-digit precision
(`0.28999999999999998`) while Spark emits the shortest round-trip
(`0.29`), so every double-bearing row false-mismatched on a
Redshift -> Databricks reconcile. In the fingerprint path this trips
the systemic-mismatch guard, so the pre-check defers on any table with
a DOUBLE column (a `transformations` override can't rescue it — a
configured transform makes the pre-check ineligible by design). Fix:
pin both sides to `COALESCE(CAST(CAST(col AS DECIMAL(38,10)) AS
STRING/VARCHAR), '_null_recon_')`, the same normalization the Teradata
recon fixture applied by hand; as a dialect default it fixes row-hash
and fingerprint at once. NaN / +-Infinity are rendered as strings
(the numeric cast would otherwise hard-fail on Redshift).
### Code-review hardening
- **Serialization consolidated onto the shared transform map.** Earlier
revisions hand-wrote a per-column serializer on each of the three paths
(Redshift source SQL, Spark Stage-1 `Column`, Spark Stage-2 SQL), kept
byte-aligned by tests — including a per-column UTC pin (`TO_CHAR(_ AT TIME
ZONE 'UTC', _)` / `TO_UTC_TIMESTAMP(_, CURRENT_TIMEZONE())`) and a
`CAST(_ AS VARCHAR(65535))` to dodge Redshift's 256-byte default. Both
are removed: routing through `DataType_transform_mapping` makes the
fingerprint serialization identical to the row-hash path by
construction, and the default `TRIM(col)` does not truncate, making the
width cast unnecessary.
- **Session-level UTC pin for cross-engine timestamp determinism.** The
Databricks target renders timestamps via `DATE_FORMAT`, which depends on
`spark.sql.session.timeZone`, so `pin_utc_session` pins the session to
UTC for the recon. It is gated on the source dialect (`redshift`) — a
row-hash correctness concern shared by the plain compare and fingerprint
paths, not gated on `reconcile_optimizer` — and the original value is
restored once the recon completes, so a shared/interactive cluster sees
no lasting change.
- **Hash-column ordering de-duplicated** into
`HashQueryBuilder.ordered_hash_columns()`, reused by the fingerprint
pre-check (the standalone `fingerprint_hash_columns` module was deleted).
- **Null-safe column diff moved into the compare layer.** The per-column
`<=>` recompute + per-row `mismatch_columns` annotation is now the shared
`compare.annotate_mismatch_columns(...)` helper instead of being
hand-rolled in `fingerprint/orchestrator.py`.
- **Table-placeholder substitution moved behind the builder.**
`HashQueryBuilder.substitute_table(...)` owns its `:tbl` placeholder and
resolves every dialect-rendered form (`:tbl` on Spark, `%(tbl)s` on
Postgres-family); the orchestrator no longer hard-codes placeholder syntax.
- **Stage-2 build failures fall through to the full pipeline** in
`trigger_recon_service.py` instead of marking the table failed. Every
other non-MATCH branch already does this; metadata records
`fallback_to_full_pipeline=True` for observability.
- **Typed NULLs in the persisted `fingerprint_metrics` struct.** Optional
fields (`verdict`, `target_row_count`, `row_count_source`, `fetch_path`)
render as `cast(NULL as string|bigint)` rather than a bare `NULL`. A bare
`NULL` makes Spark infer `NullType`, which the vectorized Parquet reader
cannot read back and which breaks schema equality against the typed
`recon_metrics` table; `IS NULL` dashboard semantics are preserved.
Smaller cleanups: dropped unused `ColumnAlignment.exclude_columns`;
reverted a no-op reorder in `connectors/source_adapter.py`; replaced a
flaky wall-clock assertion in `test_fetch_parallel.py` with a
deterministic distinct-thread-id assertion; pinned the exact rendered
SQL on each dialect in `test_expression_generator.py` and added a
regression test for the Redshift `BOOLEAN` handler.
### Caveats
- DBR 17.3+ required for source-side reads via `remote_query()`
(inherited from upstream's `RemoteQueryReader` adoption).
- MISMATCH-state cost at 1 M scale currently exceeds row-hash-only mode
by 16-94 s because Stage-2 still feeds the existing JOIN. MATCH is
the headline win (38.7% on 1 M rows); billion-row scale is the
production motivation. Stage-1 hash persistence as Stage-2 input is
filed as a follow-up.
- Pre-existing `success_count` formula in `verify_successful_reconciliation`
(upstream PR #2259, commit `e56c79c3d`) is mathematically wrong; sits
next to fingerprint code in `trigger_recon_service.py`. Not fixed
here to keep scope contained; filed separately.
### Tests
- All unit tests on the touched surface pass: 1551 / 1557 (the 6
`test_cli_analyze.py` failures are pre-existing on main and
unrelated). `tests/unit/reconcile/` runs 375 tests in ~1 s.
- Parity tests assert the fingerprint source/target serializers are
byte-identical to the shared row-hash transform map, and that the
target timestamp serializer renders no explicit session-timezone
function (`TO_UTC_TIMESTAMP` / `CURRENT_TIMEZONE`), the implicit
`DATE_FORMAT` dependence being pinned once by `pin_utc_session`
(Redshift-scoped, restored after the recon); the fallback path
is pinned; the fingerprint serialization suites pin the exact rendered
SQL on each dialect for the three pre-existing fixes (including the
`DOUBLE` -> `DECIMAL(38,10)` normalization on source and target).
- Correctness validated end-to-end on a 1 M-row Redshift / Delta fixture
across the 20-scenario dual-mode parity matrix: 39/40 cells PASS, 1
scenario shows a known fingerprint-solver fallback edge with verdict
agreement on both sides — only the cap-bounded `mismatch` count
differs (fingerprint reports the true 10000, normal reports the
cap-50 sample).
- Linter clean: pylint 10.00/10 on touched src; ruff, black, mypy green.
- Integration coverage to follow alongside the recon e2e cluster fixture
(#2453).1 parent c1fdb1f commit e33c710
53 files changed
Lines changed: 7577 additions & 83 deletions
File tree
- docs/lakebridge/docs/reconcile
- src/databricks/labs/lakebridge
- reconcile
- fingerprint
- query_builders
- query_builder
- tests
- integration/reconcile
- fingerprint
- query_builder
- unit
- reconcile
- fingerprint
- query_builder
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
138 | 138 | | |
139 | 139 | | |
140 | 140 | | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
141 | 214 | | |
142 | 215 | | |
143 | 216 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
91 | 103 | | |
92 | 104 | | |
93 | 105 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
291 | 291 | | |
292 | 292 | | |
293 | 293 | | |
294 | | - | |
| 294 | + | |
295 | 295 | | |
296 | 296 | | |
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
300 | 300 | | |
301 | 301 | | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
302 | 310 | | |
303 | 311 | | |
304 | 312 | | |
| |||
331 | 339 | | |
332 | 340 | | |
333 | 341 | | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
334 | 366 | | |
335 | 367 | | |
336 | 368 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
191 | 191 | | |
192 | 192 | | |
193 | 193 | | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
194 | 229 | | |
195 | 230 | | |
196 | 231 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
Lines changed: 120 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
0 commit comments