Skip to content

Commit e33c710

Browse files
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

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/lakebridge/docs/reconcile/configuration.mdx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,79 @@ Place the file in `.lakebridge/` in your Databricks workspace home folder.
138138
</TabItem>
139139
</Tabs>
140140

141+
---
142+
143+
## Fingerprint Pre-check (Experimental)
144+
145+
The fingerprint pre-check is an opt-in optimisation that, when source and target are
146+
already in sync, replaces the per-row hash-and-join pipeline with a sub-bucket-level
147+
aggregate comparison. On a 1 M-row Redshift fixture in MATCH state, the precheck
148+
short-circuits the recon at roughly **30–45 % of the v3 baseline wall-clock**; on
149+
MISMATCH it surgically fetches only the differing rows instead of streaming the full
150+
column set across JDBC. When neither the MATCH nor the surgical-fetch path applies the
151+
recon falls open to the full pipeline so correctness is never compromised.
152+
153+
### When to enable
154+
155+
- The source / target tables are expected to be **mostly identical** (post-migration,
156+
ongoing CDC) — the precheck pays off most when MATCH is the common outcome.
157+
- The runtime targets **DBR 17.3 or later**. The Stage-2 source-side fetch uses
158+
Databricks' `remote_query()` table-valued function which requires DBR 17.3+. On
159+
earlier runtimes, leave the flag off — the eligibility gate doesn't yet check DBR
160+
version, so the JDBC call would fail mid-fetch and trigger the fail-open path.
161+
- The source dialect has a registered fingerprint query builder. Today only
162+
`redshift` is wired. Other dialects fall through to the full pipeline silently.
163+
164+
### Configuration
165+
166+
Add to `recon_config_*.json` at the top level:
167+
168+
```yaml
169+
reconcile_optimizer: true
170+
# Optional. When set, overrides the target Delta DESCRIBE DETAIL numRecords
171+
# lookup used to pick the sub-bucket tier. Use this when the target is non-Delta
172+
# (DESCRIBE DETAIL returns no numRecords) or when Delta stats are stale and the
173+
# heuristic lands on a tier that is too coarse / too fine for your workload.
174+
# Values <= 0 are treated as unset.
175+
fingerprint_row_count_override: 250000000
176+
```
177+
178+
### Eligibility rules
179+
180+
The pre-check declines (and the recon proceeds via the full pipeline) when **any** of
181+
the conditions below hold. These reasons are recorded in
182+
`recon_metrics.fingerprint_metrics.ineligibility_reason` so adoption queries can
183+
distinguish "feature off" from "feature on but table ineligible".
184+
185+
| Reason value | Meaning |
186+
| --- | --- |
187+
| `flag_disabled` | `reconcile_optimizer` is false (default). |
188+
| `unsupported_dialect` | The source dialect has no registered fingerprint query builder (today: anything other than `redshift`). |
189+
| `report_type_not_data` | `report_type` is `schema` (precheck operates on data-level reconciles). |
190+
| `no_join_columns` | `join_columns` is empty. The precheck needs primary-key columns to disambiguate culprit rows during Stage-2. |
191+
| `filters_configured` | `filters.source` or `filters.target` is set. The precheck does not project filter predicates into Stage-1 aggregates yet. |
192+
| `transforms_configured` | `transformations` is set. Custom transformations are not supported on the fingerprint hash path. |
193+
| `column_thresholds_configured` | `column_thresholds` is set. Threshold semantics conflict with exact-hash comparison. |
194+
| `table_thresholds_configured` | `table_thresholds` is set. Same rationale as column thresholds. |
195+
196+
A separate runtime gate validates that every `column_mapping.target_name` resolves to
197+
a real target column before issuing the source-side scan — a typo in the mapping is
198+
caught at eligibility time, not after Stage-1 has already pulled across JDBC.
199+
200+
### Tuning and observability
201+
202+
- The pre-check selects a sub-bucket / bucket count adaptively from the target's
203+
Delta `numRecords` (DESCRIBE DETAIL). On a non-Delta target, or when the metric
204+
is missing, it falls back to a static `(1 048 576, 32 768)` pair. Override
205+
explicitly via `ReconcileConfig.fingerprint_row_count_override` (an
206+
approximate target row count) when the heuristic picks a tier that you can
207+
show is wrong for your workload.
208+
- Every recon writes a `fingerprint_metrics` named-struct to
209+
`recon_metrics.fingerprint_metrics` regardless of eligibility, so adoption,
210+
fall-open rate, and verdict distribution can be tracked from one query.
211+
212+
---
213+
141214
## TABLE Config Schema
142215

143216
<Tabs>

docs/lakebridge/docs/reconcile/index.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ The User configuring reconcile must have permission to:
8888
- `USE CATALOG` and `CREATE SCHEMA` on the target catalog
8989
- `CREATE VOLUME` if using a pre-existing schema on a serverless cluster
9090

91+
### Runtime requirements
92+
93+
- The job / interactive cluster running reconcile must be on **DBR 15.4 LTS or later**
94+
for the standard data-comparison path.
95+
- If `reconcile_optimizer` is enabled (see
96+
[Configuration Reference → Fingerprint Pre-check](/docs/reconcile/configuration#fingerprint-pre-check-experimental)),
97+
the cluster must be on **DBR 17.3 or later**. The Stage-2 source-side fetch uses
98+
Databricks' `remote_query()` table-valued function, which became available on DBR
99+
17.3. On earlier runtimes, leave the flag off; otherwise the JDBC call fails
100+
mid-fetch and the recon falls open to the full pipeline (correct, but the precheck
101+
buys you nothing while paying for itself in cluster time).
102+
91103
### Serverless cluster support
92104

93105
Reconcile automatically detects the cluster type and optimizes intermediate data persistence accordingly:

src/databricks/labs/lakebridge/config.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,14 +291,22 @@ class ReconcileJobConfig:
291291
@dataclass
292292
class ReconcileConfig:
293293
__file__ = "reconcile.yml"
294-
__version__ = 2
294+
__version__ = 3
295295

296296
report_type: str
297297
source: SourceConnectionConfig
298298
target: TargetConnectionConfig
299299
metadata_config: ReconcileMetadataConfig
300300
job_overrides: ReconcileJobConfig | None = None
301301
hash_expression_overrides: HashExpressionOverrides | None = None
302+
reconcile_optimizer: bool = False
303+
# Optional explicit row count for fingerprint tier selection. When set,
304+
# overrides Delta ``DESCRIBE DETAIL`` numRecords lookup so customers whose
305+
# target is non-Delta (or whose Delta stats are stale) can pick the right
306+
# sub-bucket tier without waiting for a full COUNT(*). ``None`` keeps the
307+
# default heuristic. Values ``<= 0`` are treated as "unset" by
308+
# ``fetch_target_row_count``.
309+
fingerprint_row_count_override: int | None = None
302310

303311
def __post_init__(self):
304312
# Teradata has no out of the box cryptographic hash in pure SQL, so the user has to configure
@@ -331,6 +339,30 @@ def v1_migrate(cls, raw: dict[str, Any]) -> dict[str, Any]:
331339
raw["version"] = 2
332340
return raw
333341

342+
@classmethod
343+
def v2_migrate(cls, raw: dict[str, Any]) -> dict[str, Any]:
344+
"""v2 → v3: introduce the source-agnostic ``reconcile_optimizer`` flag.
345+
346+
Older field names (``fingerprint_precheck``, ``redshift_fingerprint_precheck``,
347+
``use_fingerprint_precheck``) from earlier deployments are folded into the new
348+
flag if present; otherwise the field defaults to ``False`` so existing v2 configs
349+
keep their current behaviour.
350+
"""
351+
_legacy_optimizer_flags = (
352+
"fingerprint_precheck",
353+
"redshift_fingerprint_precheck",
354+
"use_fingerprint_precheck",
355+
)
356+
if "reconcile_optimizer" not in raw:
357+
for legacy in _legacy_optimizer_flags:
358+
if legacy in raw:
359+
raw["reconcile_optimizer"] = raw.pop(legacy)
360+
break
361+
for legacy in _legacy_optimizer_flags:
362+
raw.pop(legacy, None)
363+
raw["version"] = 3
364+
return raw
365+
334366
@property
335367
def table_recon_filename(self) -> str:
336368
"""Canonical filename of the `TableRecon` config file in the install folder."""

src/databricks/labs/lakebridge/reconcile/compare.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,41 @@ def _get_mismatch_columns(df: DataFrame, columns: list[str]):
191191
return mismatch_columns
192192

193193

194+
def annotate_mismatch_columns(mismatch_df: DataFrame | None) -> DataFrame | None:
195+
"""Recompute per-column match flags null-safely and append a per-row ``mismatch_columns`` string.
196+
197+
``_get_mismatch_df`` builds each ``<col>_match`` with bare ``=``, which is NOT null-safe:
198+
``NULL = NULL`` and ``NULL = value`` both yield NULL. A naive ``NOT <col>_match`` filter would
199+
then silently drop every row whose only difference involves a NULL, while a naive
200+
``COALESCE(_match, false)`` would over-report columns that were NULL on both sides. Recomputing
201+
each ``<col>_match`` from ``<col>_base <=> <col>_compare`` (null-safe equality) yields a non-null
202+
BOOLEAN — ``NULL <=> NULL`` is TRUE (match), ``NULL <=> value`` is FALSE (mismatch). The frame is
203+
then filtered to rows with at least one false match and annotated with a comma-separated
204+
``mismatch_columns`` column listing exactly the columns that differ on that row.
205+
206+
Used by the fingerprint Stage-2 path, whose prefetched frames already carry every projected
207+
column, so column-level attribution can be computed in-place without a second sampling round-trip.
208+
"""
209+
if mismatch_df is None:
210+
return mismatch_df
211+
match_cols = [c for c in mismatch_df.columns if c.endswith("_match")]
212+
if not match_cols:
213+
return mismatch_df
214+
215+
for match_col in match_cols:
216+
stem = match_col[: -len("_match")]
217+
base_col = f"{stem}_base"
218+
compare_col = f"{stem}_compare"
219+
if base_col in mismatch_df.columns and compare_col in mismatch_df.columns:
220+
mismatch_df = mismatch_df.withColumn(match_col, expr(f"`{base_col}` <=> `{compare_col}`"))
221+
222+
not_all_match = " OR ".join(f"NOT `{c}`" for c in match_cols)
223+
diff_case_exprs = ", ".join(f"CASE WHEN NOT `{c}` THEN '{c[: -len('_match')]}' END" for c in match_cols)
224+
return mismatch_df.filter(expr(not_all_match)).withColumn(
225+
"mismatch_columns", expr(f"concat_ws(',', {diff_case_exprs})")
226+
)
227+
228+
194229
def _normalize_mismatch_df_col(column, suffix):
195230
unnormalized = DialectUtils.unnormalize_identifier(column) + suffix
196231
return DialectUtils.ansi_normalize_identifier(unnormalized)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Fingerprint-accelerated reconciliation."""
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Shared constants and SQL helpers for fingerprint detection and row filtering."""
2+
3+
from __future__ import annotations
4+
5+
# Must match the row-hash path's NULL stand-in (``'_null_recon_'`` literal in
6+
# ``reconcile/query_builder/expression_generator.py``). Fingerprint and row-hash
7+
# both encode NULLs into the per-column hash payload before MD5/SHA — picking a
8+
# different stand-in here would alias real data ``'_null_recon_'`` with NULL on
9+
# only one side and produce the inverse alias on the other, so any row that
10+
# happens to carry either literal would be silently misclassified by Stage-1.
11+
# A unit test pins this to the row-hash literal so a future drift fails CI
12+
# rather than the reconcile.
13+
NULL_SENTINEL = "_null_recon_"
14+
15+
# chr(1) — column separator inside the MD5 concat. Rendered three ways:
16+
# Redshift SQL: CHR(1)
17+
# Spark SQL: CHAR(1)
18+
# Python / Spark DataFrame: "\x01"
19+
SEPARATOR_PYTHON = "\x01"
20+
SEPARATOR_REDSHIFT_SQL = "CHR(1)"
21+
SEPARATOR_SPARK_SQL = "CHAR(1)"
22+
23+
24+
def quote_identifier(bare: str, quote_char: str) -> str:
25+
"""Wrap ``bare`` in ``quote_char`` delimiters, doubling any embedded delimiter.
26+
27+
Shared by the Redshift source builder (``"``) and the Spark target builder
28+
(`````) so the two boundaries cannot diverge. Deliberately does NOT route through
29+
``DialectUtils.normalize_identifier``: that helper lowercases, which would corrupt
30+
case-sensitive physical column names. We only need standard-SQL delimiter escaping
31+
here (defense-in-depth — today's names arrive validated from the connector /
32+
``information_schema``), so case is preserved verbatim.
33+
34+
>>> quote_identifier('plain', '"')
35+
'"plain"'
36+
>>> quote_identifier('we"ird', '"')
37+
'"we""ird"'
38+
>>> quote_identifier('a`b', '`')
39+
'`a``b`'
40+
"""
41+
escaped = bare.replace(quote_char, quote_char * 2)
42+
return f"{quote_char}{escaped}{quote_char}"
43+
44+
45+
# Static defaults retained for backwards compatibility and as the fallback when the
46+
# adaptive selector has no row count to work with.
47+
SUB_BUCKET_COUNT = 1_048_576 # 1M sub-buckets
48+
BUCKET_COUNT = 32_768
49+
50+
# Adaptive tier table. Each entry: (max_row_count_inclusive, sub_bucket_count, bucket_count).
51+
# Last entry's max_row_count is None and clamps everything larger. Sub-bucket counts are
52+
# powers of 2 to keep MOD distribution clean; bucket count = sub_bucket_count / 1024.
53+
SUB_BUCKET_TIERS: tuple[tuple[int | None, int, int], ...] = (
54+
(50_000, 16_384, 128), # < 50K
55+
(500_000, 262_144, 512), # 50K – 500K
56+
(50_000_000, 1_048_576, 1_024), # 500K – 50M
57+
(500_000_000, 2_097_152, 2_048), # 50M – 500M
58+
(5_000_000_000, 4_194_304, 4_096), # 500M – 5B
59+
(50_000_000_000, 8_388_608, 8_192), # 5B – 50B
60+
(None, 16_777_216, 16_384), # 50B+
61+
)
62+
63+
64+
def pick_sub_bucket_count(row_count: int | None) -> tuple[int, int]:
65+
"""Select (sub_bucket_count, bucket_count) for ``row_count``.
66+
67+
Falls back to (SUB_BUCKET_COUNT, BUCKET_COUNT) when the count is unknown or
68+
non-positive, so callers can pass None safely.
69+
70+
>>> pick_sub_bucket_count(10_000)
71+
(16384, 128)
72+
>>> pick_sub_bucket_count(100_000_000)
73+
(2097152, 2048)
74+
>>> pick_sub_bucket_count(None)
75+
(1048576, 32768)
76+
"""
77+
if row_count is None or row_count <= 0:
78+
return SUB_BUCKET_COUNT, BUCKET_COUNT
79+
for max_row_count, sub_buckets, buckets in SUB_BUCKET_TIERS:
80+
if max_row_count is None or row_count <= max_row_count:
81+
return sub_buckets, buckets
82+
return SUB_BUCKET_COUNT, BUCKET_COUNT
83+
84+
85+
def build_fingerprint_where_clause(
86+
sb_expr: str,
87+
rh1_expr: str,
88+
solved_hashes: dict[int, list[int]],
89+
unsolved_sb_ids: list[int],
90+
) -> str:
91+
"""Build the WHERE body (no ``WHERE``, no trailing alias) for a filtered fetch.
92+
93+
Emits the union form ``(sb_expr IN (..) AND rh1_expr IN (..)) [OR sb_expr IN (..)]``.
94+
The form is mathematically equivalent to per-sub-bucket disjuncts because
95+
``sb_id = ABS(MOD(rh1, N))`` is invariant, but stays ``O(|sb_expr| + |IN list|)``
96+
instead of ``O(k · |sb_expr|)`` so it stays under Redshift's 16 MB statement
97+
limit even on workloads with millions of solved sub-buckets.
98+
99+
Raises ``ValueError`` when both filter inputs are empty: callers must gate the
100+
fetch (eligibility check in the orchestrator) before reaching this helper. An
101+
empty result here would interpolate to ``WHERE )`` downstream — fail-loud beats
102+
silently emitting a syntactically broken query that fail-open would mask.
103+
"""
104+
if not solved_hashes and not unsolved_sb_ids:
105+
raise ValueError(
106+
"build_fingerprint_where_clause requires at least one of solved_hashes "
107+
"or unsolved_sb_ids to be non-empty; the empty case must be filtered "
108+
"out by the caller before issuing a fetch."
109+
)
110+
conditions: list[str] = []
111+
# Sort all IN-list operands for deterministic SQL across dict / list iteration
112+
# orders — helps query-plan caching and unit-test diffing.
113+
if solved_hashes:
114+
sb_list = ", ".join(str(sb_id) for sb_id in sorted(solved_hashes))
115+
hash_list = ", ".join(str(h) for h in sorted({h for hs in solved_hashes.values() for h in hs}))
116+
conditions.append(f"({sb_expr} IN ({sb_list}) AND {rh1_expr} IN ({hash_list}))")
117+
if unsolved_sb_ids:
118+
sb_list = ", ".join(str(sb_id) for sb_id in sorted(unsolved_sb_ids))
119+
conditions.append(f"{sb_expr} IN ({sb_list})")
120+
return " OR ".join(conditions)

0 commit comments

Comments
 (0)