Fix Redshift↔Databricks row-hash serialization - #2664
Open
ameersalman-db wants to merge 5 commits into
Open
ameersalman-db wants to merge 5 commits into
ameersalman-db wants to merge 5 commits into
Conversation
…e-transform lookup Integrating a Redshift source against a Databricks target surfaced three pre-existing correctness bugs in the row-hash compare path, all in the shared DataType_transform_mapping. None is fingerprint-specific — each corrupts the existing per-row SHA2 on real Redshift schemas: 1. TIMESTAMP / TIMESTAMPTZ (Databricks target): no override fell through to TRIM(CAST(ts AS string)), where Spark emits a variable-length fraction (dropped entirely at zero microseconds) while the Redshift source emits fixed 6-digit microseconds, so every identical timestamp row false-mismatched. Fixed with COALESCE(DATE_FORMAT(_, 'yyyy-MM-dd HH:mm:ss.SSSSSS'), '_null_recon_'); Redshift TIMESTAMPTZ pinned to UTC via AT TIME ZONE 'UTC'. 2. BOOLEAN (Redshift source): no override fell through to the universal TRIM, which Redshift rejects at output-schema resolution (function pg_catalog.btrim(boolean) does not exist), crashing any recon with a boolean column. Fixed with CASE WHEN producing 'true'/'false' to match Spark's CAST(boolean AS string). 3. DOUBLE (both dialects): no override fell through to TRIM(CAST(col AS string)), but Redshift renders full 17-digit precision while Spark emits the shortest round-trip, so every double row false-mismatched. Both sides pinned to a fixed-scale DECIMAL(38,10) string. The pinned handlers (DOUBLE, TIMESTAMP, TIMESTAMPTZ) only produce a byte-identical hash when *both* engines pin the same way, so get_transform_for_type gates them on the reconcile counterpart: against a non-pinning source (Snowflake/Oracle/TSQL/ BigQuery into a Databricks target) they fall back to the universal default, leaving those reconciles' serialization unchanged. DOUBLE special values are handled without crashing: NaN / ±Infinity and any finite magnitude >= 1e28 (which overflows DECIMAL(38,10)'s 28 integer digits and would raise "numeric field overflow" on Redshift / return NULL on non-ANSI Spark) bypass the pin and render via the native string cast. The DECIMAL(38,10) pin rounds to 10 fractional digits — a documented precision floor (differences beyond the 10th decimal hash equal). Timestamp determinism relies on the reconcile cluster session being UTC (the Databricks default), left to the operator rather than force-pinned; surface this in user docs. Also centralizes the dialect/type -> transform lookup into get_transform_for_type (+ _dialect_key, which prefers a dialect name that actually carries mapping overrides so a synonym cannot shadow it). Regression tests assert the exact rendered SQL for each handler, the counterpart gating for DOUBLE and timestamps, and the DECIMAL overflow guard.
…amp UTC prerequisite and DOUBLE precision floor F1: Redshift real/float4 parse to exp.DataType.Type.FLOAT, which had no handler and fell through to the universal TRIM default -- the same full-precision vs shortest-round-trip divergence the DOUBLE pin fixes -- so every float4 row false-mismatched on a Redshift -> Databricks reconcile. Add FLOAT handlers on both dialects mirroring DOUBLE (same DECIMAL(38,10) scale, because sqlglot resolves Redshift `float`->DOUBLE but Databricks `float`->FLOAT, so a mismatched scale would diverge a float/double column pair), and add FLOAT to _COUNTERPART_PINNED_TYPES so non-Redshift sources fall back to the universal default and are unaffected. +regression test. F2: Document that the Databricks timestamp serializer renders in spark.sql.session.timeZone and matches the UTC-pinned Redshift side only on a UTC session; the requirement is not enforced at runtime (rationale in PR description). F3: Document that the DECIMAL(38,10) scale rounds to 10 fractional digits, so differences beyond the 10th decimal are reported as MATCH (a precision floor); the scale rationale and decision are captured in the PR description.
Succeeded tables are those that neither errored nor mismatched, so the count must be total - exceptions - mismatches. The previous expression added mismatched_count instead of subtracting it, reporting more succeeded tables than were reconciled (success_count > total_count) whenever any table mismatched. Pre-existing since #2259; not fingerprint/dataprint related. Add a regression test that asserts the summary counts (the existing tests only checked the log wording, never the numbers, which is why this slipped through).
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2664 +/- ##
==========================================
+ Coverage 71.31% 71.33% +0.02%
==========================================
Files 112 112
Lines 10122 10131 +9
Branches 1111 1114 +3
==========================================
+ Hits 7218 7227 +9
Misses 2690 2690
Partials 214 214 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
✅ 182/182 passed, 9 flaky, 2 skipped, 2h16m48s total Flaky tests:
Running from acceptance #5473 |
…CHAR cast)
Redshift's `col IN (CAST('NaN' AS DOUBLE PRECISION), ...)` returns FALSE for a column
NaN (even though NaN = NaN is TRUE and +/-Inf ARE matched), so the guard missed NaN and
routed it into the ELSE CAST(... AS DECIMAL(38,10)), aborting the reconcile with SQLSTATE
8001 "NaN input". Detect NaN/+/-Inf via CAST({0} AS VARCHAR) IN ('NaN','Infinity','-Infinity')
and drop the ineffective ELSE sanitize, on both the DOUBLE and FLOAT handlers. Spark side
unchanged (ISNAN already detects NaN). Live-validated on real Redshift (notebooks 50 + 30).
Co-authored-by: Isaac <no-reply@databricks.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Changes
What does this PR do?
Fixes four pre-existing correctness bugs in the row-hash reconcile path for Redshift → Databricks reconciles, and extends the shared per-type transform table so the affected types serialize identically across the two engines.
None of these are specific to any new feature — each one corrupts the existing row-hash compare pipeline (bugs 1–3) or its result reporting (bug 4) on real Redshift schemas. They surfaced while integrating a Redshift source (see the Redshift connector work in #2339), but they live in shared reconcile code and affect every Redshift → Databricks
row/data/allreconcile today. This PR is intentionally scoped to just these fixes so it can be reviewed on its own; a follow-up fingerprint pre-check PR builds on top of it.Relevant implementation details
Bugs 1–3 add or repair a dialect handler in the shared per-type transform table
_DATATYPE_TRANSFORM_MAPPING, which lives inreconcile/query_builder/column_transformer.py(theColumnTransformerservice introduced by #2601). The row-hash path resolves each column's serializer through one shared helper,get_transform_for_type(datatype, source, counterpart), so there is a single definition of "how is this column type turned into text before hashing", consumed byRuleBasedColumnTransformer._normalize_by_type.TIMESTAMP/TIMESTAMPTZhandler on the Databricks (target) dialectTRIM(CAST(ts AS string)). Spark emits a variable-length fraction (dropped entirely at zero microseconds →'2023-10-02 18:08:43') while the Redshift source emits fixed 6-digit microseconds ('…43.000000'). The byte-width drift made every identical timestamp row false-mismatch. Fixed withCOALESCE(DATE_FORMAT(_, 'yyyy-MM-dd HH:mm:ss.SSSSSS'), '_null_recon_'); RedshiftTIMESTAMPTZis pinned to UTC viaAT TIME ZONE 'UTC'so it does not depend on the Redshift sessionTIMEZONE. Counterpart-gated (see below): only selected when the source is Redshift, so non-Redshift → Databricks reconciles keep their previous serialization.BOOLEANhandler on the Redshift (source) dialectBOOLEANfell through to the universalTRIM(...), which Redshift rejects at output-schema resolution (before any rows are read) withfunction pg_catalog.btrim(boolean) does not exist. Any schema with a single boolean column crashed the reconcile. Fixed withCOALESCE(CASE WHEN col THEN 'true' WHEN NOT col THEN 'false' ELSE NULL END, '_null_recon_'), matching Spark'scast(boolean AS string).DOUBLE/float8 andFLOAT/real/float4) handlers on the Redshift and Databricks dialectsTRIM(CAST(col AS string))— but Redshift renders full precision (0.28999999999999998) while Spark emits the shortest round-trip (0.29), so every float-bearing row false-mismatched. Fixed by pinning both sides to a fixed-scaleCOALESCE(CAST(CAST(_ AS DECIMAL(38,10)) AS STRING/VARCHAR), '_null_recon_').FLOATreuses the sameDECIMAL(38,10)scale asDOUBLEon purpose: sqlglot resolves Redshiftfloat→DOUBLE but Databricksfloat→FLOAT, so a mismatched scale would diverge a float/double column pair.NaN/±Infinity, and any finite magnitude≥ 1e28(which overflowsDECIMAL(38,10)and hard-fails — Redshift aborts the whole recon, Spark returns NULL), bypass the DECIMAL cast and fall back to a direct string cast so the recon does not crash. Residual (documented, not fixed): that native fallback is not byte-identical across engines for a finite magnitude≥ 1e28— Spark renders1.0E30where Redshift renders1e+30(empirically confirmed on DBR 18 + live Redshift), so those rows false-positive (over-report; never hide a real diff).NaN/±Infinityrender identically on both engines (NaN/Infinity/-Infinity) and do not diverge. A finite magnitude≥ 1e28is vanishingly rare (and a double carries only ~15–16 significant digits there), so this is accepted as a fail-safe edge — see the reviewer note below.success_countinflation intrigger_recon_service.verify_successful_reconciliationtotal − exceptions + mismatches, adding mismatched tables instead of subtracting them, so any run with a mismatch reported more succeeded tables than were reconciled (success_count > total_count). Pre-existing since #2259; unrelated to serialization but grouped here as another pre-existing correctness fix. Fixed tototal − exceptions − mismatches. The existing tests only asserted the log wording, never the numbers — which is why it slipped through; a regression test now asserts the counts.Counterpart gating:
get_transform_for_type(datatype, source, counterpart)gates the_COUNTERPART_PINNED_TYPEShandlers —DOUBLE,FLOAT,TIMESTAMP,TIMESTAMPTZ— whose pinned forms (fixed-scaleDECIMAL(38,10);…HH:mm:ss.SSSSSSmicroseconds) are byte-identical only when both engines pin (currently Redshift ↔ Databricks).RuleBasedColumnTransformeralready holds both the source and targetReconcileLayer, so when it normalizes one layer it passes the other layer's dialect as the counterpart. When the counterpart does not pin (e.g. a BigQuery/Snowflake/Oracle/TSQL source into a Databricks target), the pinned types fall back to the universal default so the two sides still agree — so this PR does not change serialization for any non-Redshift → Databricks reconcile.Caveats/things to watch out for when reviewing:
Timestamp determinism relies on the reconcile cluster session being UTC (the Databricks default). The Databricks target renders timestamps via
DATE_FORMAT, which readsspark.sql.session.timeZone; the Redshift source renders in UTC. This PR does not force-pin the Spark session timezone — that is left to the operator. On a non-UTC session, every timestamp / timestamptz row diverges and false-mismatches. Decision for reviewers: enforce this at runtime (assert / pin the session TZ), or accept it as a documented hard prerequisite (recon clusters run in UTC)? Nothing enforces it today.Floating-point precision floor — decision for reviewers. Making the two engines agree requires pinning floats to one fixed decimal representation, so some scale must be hardcoded.
DECIMAL(38,10)(10 fractional digits) is inherited from the hand-written Teradata recon fixture (tests/integration/reconcile/conftest.py), not derived from a precision requirement. Consequence: two values differing only beyond the 10th fractional digit (e.g.0.12345678901vs0.12345678902) hash identically and are reported as a MATCH. Fine for money / most data; a blind spot for high-precision data (scientific, sub-1e-10 financial). Confirm 10 digits is acceptable, or specify a different scale.The floating-point fix changes the serialized form of
DOUBLEandFLOATcolumns on both engines (toDECIMAL(38,10)). This is a behavior change to the row hash for those columns — intentional, since the previous behavior always false-mismatched them.≥ 1e28overflow residual — documented limitation, both paths, fail-safe. The overflow guard routes a finiteDOUBLE/FLOATmagnitude≥ 1e28(which overflowsDECIMAL(38,10)), plusNaN/±Infinity, to a native string cast so the recon doesn't crash. That native cast is byte-identical across engines forNaN/±Infinity(NaN/Infinity/-Infinityon both) but not for a finite≥ 1e28: Spark emits1.0E30, Redshift1e+30(confirmed on DBR 18 + live Redshift). Effect: such rows false-positive (over-report), never a false MATCH. It is not limited to the fingerprint pre-check — the pin lives in the sharedget_transform_for_type, gated by dialect (Redshift ↔ Databricks), so the same residual applies to the legacy row-hash path. It did not exist before this PR (previously all doubles false-mismatched under the universal default);≥ 1e28never occurs in practical recon data, so we accept it as a fail-safe edge. If we ever need it closed: route the overflow branch throughCAST(_ AS DECIMAL(38,0))on both engines (plain integer, byte-identical), which narrows the divergence window to≥ 1e38.Linked issues
Relates to the Redshift connector work in #2339 (these bugs sit in that integration path).
Bug 4 (
success_count) is pre-existing since #2259.Functionality
Tests
tests/unit/reconcile/query_builder/test_expression_generator.py:exact-rendered-SQL assertions for each handler (
TIMESTAMP/TIMESTAMPTZ/BOOLEAN/DOUBLEon both dialects), a source↔target byte-alignment check for timestamps, tests that the
DOUBLE/FLOAT/timestamp pins are only emitted when the counterpart also pins, aFLOAT/real/float4regression test (pins likeDOUBLE, gated on the counterpart), anda
≥ 1e28overflow-guard test (routes to the native cast instead of crashing).success_countregression test intests/unit/reconcile/test_verify_reconcile.pyasserting the summary counts (not just the log wording) so an inflated count fails CI.
reconcile/unit suite passes (row-hash query-builder tests unchanged in behaviorfor the dialects/types they cover).