Add fingerprint pre-check to Recon (Redshift) - #2570
Conversation
|
✅ 176/176 passed, 8 flaky, 2 skipped, 2h22m29s total Flaky tests:
Running from acceptance #5103 |
9f99b6b to
ef1c8de
Compare
## 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).
ef1c8de to
e33c710
Compare
|
@ameersalman-db some initial thoughts given the PR description
This is hallucinated by the AI. there was never a legacy spelling except for local development which affects no users
The config changes should be reconsidered
This should go on its own PR
revert please. leave this up to the user
I dont get this, but should be a new PR
this needs to be reconsidered. we can just build the query completely with table name in the first place
introducing a third diff is no-go. we should fix the existing diff
new PR. we can group some of the fixes together on one PR |
Changes
What does this PR do?
Adds an opt-in fingerprint pre-check to Recon, exposed as the user-facing flag
reconcile_optimizer. Whenreconcile_optimizer=Trueand 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.compare.reconcile_dataflow. If the mismatch is systemic (>15% of sub-buckets), it defers to the row-hash pipeline.Defaults to
False; existing behaviour is unchanged. The algorithm is byte-identical to the internal dataprint library; this is the first dataprint-into-lakebridge integration. Redshift is the first source dialect — adding Snowflake / Oracle / TSQL is oneFingerprintQueryBuildersubclass plus a registry entry, with no orchestrator changes.Implementation details
trigger_recon_service._run_fingerprint_or_reconcile_data. Static eligibility (flag off, unsupported dialect, non-data report type, no join columns, filters / transforms / thresholds configured) is centralised inclassify_ineligibility(fingerprint/orchestrator.py) and runs before any source compute. One reason —unmapped_target_column_mapping— needs the target schema, so it's detected inside the precheck via the typedUnmappedTargetColumnMappingErrorfromalign_columnsand routed throughFingerprintRunMetadata.ineligible(...). Every reason is anIneligibilityReasonenum value recorded onrecon_metrics.fingerprint_metrics.ineligibility_reason, so adoption queries need no log-grepping.RemoteQueryReader/remote_query()TVF unmodified. Stage-1 aggregation pushdown was verified on a 1 M-row Redshift fixture (DBR 17.3): direct JDBC (4.28 s median) andremote_query()(5.44 s median) both push the GROUP BY to Redshift with identical aggregated counts.DataType_transform_mappingviaserialize_column_for_hash— the same lookup the row-hashQueryBuilder._default_transformeruses (refactored to the sharedget_transform_for_typehelper). The fingerprint byte stream is identical to the row-hash pipeline by construction; only the MD5 → sub-bucket/bucket arithmetic is fingerprint-specific.ThreadPoolExecutor(max_workers=2); failure semantics match the serial version.ReconcileConfigfields:reconcile_optimizer: bool = False;fingerprint_row_count_override: int | None = None(pins the adaptive tier whenDESCRIBE DETAILcan't readnumRecords).__version__2 → 3: newv2_migrateaddsreconcile_optimizerand folds the legacy spellings (fingerprint_precheckand older names) into it; thev1 → v2 → v3chain runs automatically viaInstallation.load. Zero user action.Pre-existing fixes that ride along (upstream PR #2339)
Integrating against real Redshift surfaced three pre-existing correctness bugs in the Redshift connector MR (#2339). None is dataprint-specific — all corrupt the existing row-hash path on real schemas — but they sit in the integration path, so they're fixed inline in
expression_generator.pyand pinned by regression tests.TIMESTAMP/TIMESTAMPTZhandlers to the Databricks dialect (target)COALESCE(TO_CHAR(ts, 'YYYY-MM-DD HH24:MI:SS.US'), '_null_recon_')(always 6 fractional digits). The Databricks block had noTIMESTAMP/TIMESTAMPTZoverride, so the target fell through toTRIM(COALESCE(col, '_null_recon_')), where Spark'scast(timestamp AS string)emits a variable-length fraction (dropped entirely at zero microseconds:'2023-10-02 18:08:43'vs source…43.000000'). The byte-width drift broke the per-row SHA2 for every identical timestamp row in any Redshift → Databricks reconcile. Fix:COALESCE(DATE_FORMAT(_, 'yyyy-MM-dd HH:mm:ss.SSSSSS'), '_null_recon_')for both types.BOOLEANhandler to the Redshift dialect (source)SUPER/DATE/TIMESTAMP/TIMESTAMPTZand no dialect default, soBOOLEANfell through toTRIM(...), which Redshift rejects at output-schema resolution (before reading rows) withfunction pg_catalog.btrim(boolean) does not exist— any schema with oneBOOLEANcolumn crashes row-hash recon on #2339. Fix:COALESCE(CASE WHEN col THEN 'true' WHEN NOT col THEN 'false' ELSE NULL END, '_null_recon_'), matching Spark'scast(boolean AS string). The unit suite missed it because fixtures hand-build query strings and skip the transform-mapping path.DOUBLEhandler to the Redshift and Databricks dialects (both sides)DOUBLEhad no override on either dialect, so both fell through toTRIM(CAST(col AS string))— but Redshift rendersdouble precisionat 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 and the pre-check defers on any table with aDOUBLEcolumn (it can't be rescued with atransformationsoverride — a configured transform makes the pre-check ineligible by design). Fix: pin both sides to a fixed-scaleCOALESCE(CAST(CAST(_ AS DECIMAL(38,10)) AS STRING/VARCHAR), '_null_recon_')so the byte streams are identical, withNaN/ ±Infinityrendered as strings (the numeric cast would otherwise hard-fail on Redshift). This is the same normalization the Teradata recon fixture applied by hand; making it the dialect default fixes row-hash and fingerprint at once.Code-review hardening
CAST(_ AS VARCHAR(65535)). Both removed: routing throughDataType_transform_mappingmakes fingerprint serialization identical to the row-hash path by construction (the defaultTRIM(col)doesn't truncate, so no width cast is needed).DATE_FORMAT, which isspark.sql.session.timeZone-dependent, sopin_utc_sessionsets the session timezone to UTC for the recon. It's gated on the source dialect (redshift) — a row-hash correctness concern shared by the plain compare and fingerprint paths, not gated onreconcile_optimizer— and the original value is restored when the recon completes, so a shared/interactive cluster sees no lasting change.HashQueryBuilder.ordered_hash_columns()(standalonefingerprint_hash_columnsmodule deleted).compare.annotate_mismatch_columns(...)rather than hand-rolled in the orchestrator. (Routing throughReconciliation._get_sample_datawould re-issue sampling queries and defeat the surgical fetch, so it lives incompare.py.)HashQueryBuilder.substitute_table(...), which resolves every dialect form (:tbl,%(tbl)s); the orchestrator no longer hard-codes placeholder syntax.build_mismatch_outputexception falls through to the full pipeline (fallback_to_full_pipeline=True) instead of failing the table.fingerprint_metricsstruct. Optional fields (verdict,target_row_count,row_count_source,fetch_path) now render ascast(NULL as string|bigint)rather than a bareNULL. A bareNULLmakes Spark inferNullTypefor the struct field, which the vectorized Parquet reader cannot read back (VectorizedParquetRecordReadererror) and which breaks schema equality against the typedrecon_metricstable.IS NULLsemantics for dashboards are preserved.ColumnAlignment.exclude_columnsremoved, no-op reorder insource_adapter.pyreverted, flaky wall-clock assertion intest_fetch_parallel.pyreplaced with a distinct-thread-id check.Caveats for reviewers
remote_query()need classic clusters on DBR 17.3+ or SQL warehouses (Pro / Serverless) 2025.35+ — inherited from upstream'sRemoteQueryReader, documented indocs/lakebridge/docs/reconcile/index.mdx.expression_generatortest change istest_install.py(sixversion 2 → 3bumps from the migration we own). Not scope creep.success_countbug inverify_successful_reconciliation(yields counts > total) is pre-existing (Improve reconciliation result handling and logging #2259,e56c79c3d), sits next to fingerprint code, and is not fixed here to keep scope contained — filed separately.Linked issues
Resolves #..
Functionality
configuration.mdxFingerprint Pre-check (Experimental) +index.mdxruntime requirements)ReconcileConfig.reconcile_optimizer(defaultFalse)Tests
v1 → v2 → v3migration, recon-capture schema, dispatch, Stage-1 / Stage-2 symmetry) + parity tests asserting the fingerprint serializers are byte-identical to the shared transform map.tests/unit/reconcile/runs 373 tests in ~1 s. The 6 failingtest_cli_analyze.pycases (Informatica binary) are pre-existing onmainand unrelated.TIMESTAMP/TIMESTAMPTZ, theBOOLEANrendering on Redshift, and theDOUBLE→DECIMAL(38,10)normalization on both dialects.TO_UTC_TIMESTAMP/CURRENT_TIMEZONE) is rendered; the implicitDATE_FORMATdependence is pinned once at the session level bypin_utc_session(Redshift-scoped, restored after the recon).mismatchcount differs (fingerprint reports the true 10000, normal the cap-50 sample).test_solver_sufficiency.py): a randomized no-false-solve property, clean-≤2 coverage, and a per-tier ≥98.8% solve-rate floor against the real guard constants and tier table. Backed by the three-method proof underexperiments/redshift_fingerprint_validation/proof/solver_sufficiency/(see below).reconcile/+config.py.End-to-end validation (real Redshift → Delta, 1 M rows)
One self-contained demo notebook generates identical deterministic data (
MD5(order_id ‖ salt)) in both engines, reconciles the same tables twice (reconcile_optimizerOFF = built-in row-hash, ON = fingerprint), and diffs the audit tables. Source: Redshiftperf_test.orders_demoviaremote_query(); target: Deltaorders_demo.Dual-mode parity — identical counts (audit
metrics):Record-level parity — exact keys, not just counts (audit
details). Ground truth:order_id1–5 value-mutated, 6–8 deleted from target, 9–10 deleted from source. Both modes captured exactly:Live post-mutation state: Redshift = 999,998 rows (9,10 absent → 2 source deletes); Delta = 999,997 (6,7,8 absent → 3 target deletes).
On MISMATCH the solver resolved all affected sub-buckets (15 solved, no fallback) and reached the identical answer as the built-in path — same counts and same exact records, both scenarios, with the pre-check engaged (
eligible=true,fallback_to_full_pipeline=false). Re-derivable from themain/metrics/detailstables.Solver sufficiency — why d≤2 is enough
The reasonable reviewer question: the solver only resolves a sub-bucket holding ≤2 differing rows (
d=1,d=2-swap,d=2-extras) — how do we know mismatches don't pile 3+ into one bucket? It's exact, not a guess: rows map to sub-buckets uniformly by content (sub_bucket_id = ABS(MOD(rh1, N))), so it's the classic occupancy problem. Proven three ways that agree: closed-form occupancy, Monte-Carlo, and the realengine.pysolver on generated deltas.Two shipped mechanisms bound
λ = D / N(differing rows per sub-bucket): adaptive tiering scalesNwith table size, and the systemic guard bails once> min(50,000, 15%·N)sub-buckets mismatch. So the solver only runs at lowλ, which floors the solve rate per tier:Across the entire operational regime, d≤2 surgically resolves 98.8%–99.999% of mismatch records — the "95%" intuition is a conservative floor. The full 7-tier × 4-density × 3-type (missing / value / mixed) sweep shows ≥99.98% whenever the solver runs; closed-form and Monte-Carlo agree to the fourth decimal.
Correctness does not depend on this coverage. The
<2%not solved algebraically (≥3-ball buckets, plus rare 32-bitrh1collisions) are brute-force fetched, never dropped —d<3is an efficiency threshold, not a correctness one. Over 12,000,000+ simulated differing rows: 0 false solves, 0 clean-config failures, 0 records lost. All 271 declined ≤2 buckets wererh1collisions the second (rh2) channel correctly flagged and routed to brute-force — exactly where a single-channel sketch would silently declare a MATCH.Reproducible in seconds with no workspace or Spark (pure Python + NumPy, imports the real solver and tier table):
experiments/redshift_fingerprint_validation/proof/solver_sufficiency/(solver_sufficiency.py,RESULTS.txt,report.html). The≥98.8%floor and no-false-solve property are CI-guarded intests/unit/reconcile/fingerprint/test_solver_sufficiency.py.