Skip to content

Fix Redshift↔Databricks row-hash serialization - #2664

Open
ameersalman-db wants to merge 5 commits into
mainfrom
fix/redshift-recon-row-hash-serialization
Open

ameersalman-db wants to merge 5 commits into
mainfrom
fix/redshift-recon-row-hash-serialization

Conversation

@ameersalman-db

@ameersalman-db ameersalman-db commented Sep 14, 2026 •

Copy link
Copy Markdown

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/all reconcile 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 in reconcile/query_builder/column_transformer.py (the ColumnTransformer service 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 by RuleBasedColumnTransformer._normalize_by_type.

# Fix What was broken
1 TIMESTAMP / TIMESTAMPTZ handler on the Databricks (target) dialect The target had no override and fell through to TRIM(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 with COALESCE(DATE_FORMAT(_, 'yyyy-MM-dd HH:mm:ss.SSSSSS'), '_null_recon_'); Redshift TIMESTAMPTZ is pinned to UTC via AT TIME ZONE 'UTC' so it does not depend on the Redshift session TIMEZONE. Counterpart-gated (see below): only selected when the source is Redshift, so non-Redshift → Databricks reconciles keep their previous serialization.
2 BOOLEAN handler on the Redshift (source) dialect Redshift had no boolean override and no dialect default, so BOOLEAN fell through to the universal TRIM(...), which Redshift rejects at output-schema resolution (before any rows are read) with function pg_catalog.btrim(boolean) does not exist. Any schema with a single boolean column crashed the reconcile. Fixed with COALESCE(CASE WHEN col THEN 'true' WHEN NOT col THEN 'false' ELSE NULL END, '_null_recon_'), matching Spark's cast(boolean AS string).
3 Floating-point (DOUBLE/float8 and FLOAT/real/float4) handlers on the Redshift and Databricks dialects Neither floating-point type had an override on either side, so both fell through to TRIM(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-scale COALESCE(CAST(CAST(_ AS DECIMAL(38,10)) AS STRING/VARCHAR), '_null_recon_'). FLOAT reuses the same DECIMAL(38,10) scale as DOUBLE on purpose: sqlglot resolves Redshift float→DOUBLE but Databricks float→FLOAT, so a mismatched scale would diverge a float/double column pair. NaN / ±Infinity, and any finite magnitude ≥ 1e28 (which overflows DECIMAL(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 renders 1.0E30 where Redshift renders 1e+30 (empirically confirmed on DBR 18 + live Redshift), so those rows false-positive (over-report; never hide a real diff). NaN / ±Infinity render identically on both engines (NaN / Infinity / -Infinity) and do not diverge. A finite magnitude ≥ 1e28 is 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.
4 success_count inflation in trigger_recon_service.verify_successful_reconciliation The succeeded-table count was computed as total − 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 to total − 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_TYPES handlers — DOUBLE, FLOAT, TIMESTAMP, TIMESTAMPTZ — whose pinned forms (fixed-scale DECIMAL(38,10); …HH:mm:ss.SSSSSS microseconds) are byte-identical only when both engines pin (currently Redshift ↔ Databricks). RuleBasedColumnTransformer already holds both the source and target ReconcileLayer, 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 reads spark.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.12345678901 vs 0.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 DOUBLE and FLOAT columns on both engines (to DECIMAL(38,10)). This is a behavior change to the row hash for those columns — intentional, since the previous behavior always false-mismatched them.

  • ≥ 1e28 overflow residual — documented limitation, both paths, fail-safe. The overflow guard routes a finite DOUBLE/FLOAT magnitude ≥ 1e28 (which overflows DECIMAL(38,10)), plus NaN/±Infinity, to a native string cast so the recon doesn't crash. That native cast is byte-identical across engines for NaN/±Infinity (NaN / Infinity / -Infinity on both) but not for a finite ≥ 1e28: Spark emits 1.0E30, Redshift 1e+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 shared get_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); ≥ 1e28 never occurs in practical recon data, so we accept it as a fail-safe edge. If we ever need it closed: route the overflow branch through CAST(_ 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

  • internal row-hash correctness fix — no CLI or config surface change
  • added new CLI command
  • modified existing command

Tests

  • added unit tests in tests/unit/reconcile/query_builder/test_expression_generator.py:
    exact-rendered-SQL assertions for each handler (TIMESTAMP/TIMESTAMPTZ/BOOLEAN/DOUBLE
    on 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, a
    FLOAT/real/float4 regression test (pins like DOUBLE, gated on the counterpart), and
    a ≥ 1e28 overflow-guard test (routes to the native cast instead of crashing).
  • added a success_count regression test in tests/unit/reconcile/test_verify_reconcile.py
    asserting the summary counts (not just the log wording) so an inflated count fails CI.
  • existing reconcile/ unit suite passes (row-hash query-builder tests unchanged in behavior
    for the dialects/types they cover).

…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).
@ameersalman-db
ameersalman-db requested a review from a team as a code owner September 14, 2026 08:50
@codecov

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.33%. Comparing base (fec265c) to head (0a76410).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

✅ 182/182 passed, 9 flaky, 2 skipped, 2h16m48s total

Flaky tests:

  • 🤪 test_installs_and_runs_local_bladebridge (14.66s)
  • 🤪 test_installs_and_runs_pypi_bladebridge (23.419s)
  • 🤪 test_recon_for_report_type_is_data (49.755s)
  • 🤪 test_transpiles_informatica_to_sparksql_non_interactive[True] (21.631s)
  • 🤪 test_transpiles_informatica_to_sparksql (24.02s)
  • 🤪 test_transpiles_informatica_to_sparksql_non_interactive[False] (3.974s)
  • 🤪 test_transpile_teradata_sql_non_interactive[True] (5.864s)
  • 🤪 test_transpile_teradata_sql (26.074s)
  • 🤪 test_transpile_teradata_sql_non_interactive[False] (5.642s)

Running from acceptance #5473

@ameersalman-db ameersalman-db added the bug Something isn't working label Sep 14, 2026
@m-abulazm m-abulazm added the feat/recon making sure that remorphed query produces the same results as original label Sep 18, 2026
…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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working feat/recon making sure that remorphed query produces the same results as original

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants