Skip to content

fix(ingestion): materialize TABLESAMPLE into temp tables for Snowflake SQLAlchemy profiler#18253

Open
sgomezvillamor wants to merge 21 commits into
masterfrom
fix-snowflake-profiler-memory-issue
Open

fix(ingestion): materialize TABLESAMPLE into temp tables for Snowflake SQLAlchemy profiler#18253
sgomezvillamor wants to merge 21 commits into
masterfrom
fix-snowflake-profiler-memory-issue

Conversation

@sgomezvillamor

@sgomezvillamor sgomezvillamor commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Problem

The Snowflake SQLAlchemy profiler is 70-800x slower than the GE profiler on large tables because it re-evaluates TABLESAMPLE BERNOULLI(...) on every CTE in every combined query (the query combiner batches multiple scalar queries into a single cross-joined CTE query, and each CTE re-scans the source table). GE avoids this by materializing the sample into a temp table once, then querying the small temp table.

Solution

Materialize the TABLESAMPLE sample into a session-scoped Snowflake temporary table once, then run all profiling queries against that small materialized table. This matches what GE/GX does internally when given a custom_sql batch kwarg.

Key Changes

1. Gate custom_sql to GE-only (snowflake_profiler.py)

  • The custom_sql TABLESAMPLE query is now only generated when profiling.method == "ge"
  • GX internally creates a temp table from custom_sql, so all its profiling queries run against that materialized table
  • For the SQLAlchemy profiler path, custom_sql is None — the adapter handles sampling directly

2. Temp table materialization (adapters/snowflake.py)

  • setup_profiling() queries INFORMATION_SCHEMA.TABLES for instant row count (parameterized query)
  • For large tables: CREATE OR REPLACE TEMPORARY TABLE dh_sample_<uuid> AS SELECT * FROM ... TABLESAMPLE BERNOULLI(...)
  • Two-tier BLOCK+BERNOULLI for very large tables (>50M rows), with fallback to BERNOULLI-only if BLOCK fails (e.g. on views)
  • No SEED — sample is materialized once so all profiling queries see the same rows; also allows TABLESAMPLE BERNOULLI to work on views
  • Conservative approach: when INFORMATION_SCHEMA row count is unavailable, assumes table is large and samples anyway
  • custom_sql path materializes into a temp table too (same as what GX does internally)
  • Temp tables are session-scoped and auto-drop when the connection closes

3. Partition spec fix (sqlalchemy_profiler.py)

  • Changed elif custom_sql: to elif custom_sql or context.is_sampled: so the partition spec is set correctly when the adapter performs sampling via temp table

4. Query combiner observability (sqlalchemy_query_combiner.py)

  • Added PerfTimer instrumentation and short query IDs to combined and fallback query execution
  • Upgraded log levels from DEBUG to INFO for query execution timing
  • Enables correlating slow queries in production logs

5. Others

  • Added deprecation comment on custom_sql field in ProfilingContext
  • Upgraded adapter selection log from DEBUG to INFO (adapters/__init__.py)

Testing

  • ./gradlew :metadata-ingestion:lintFix — passes
  • ./gradlew :metadata-ingestion:testQuick — passes
  • 19 Snowflake adapter unit tests (new: setup_profiling, sampling, row count, cleanup)
  • Snowflake integration tests (connector-tests): test_profiling, test_profiling_ge, test_profiling_table_level, test_profiling_table_level_ge — all pass
  • End-to-end validation on a production Snowflake instance (~1,000 tables) confirmed behavioral parity with the GE profiler: same entities profiled, same sampling strategy (temp table per sampled table), and same order of magnitude per-table execution time

Notes

  • Uses existing config: use_sampling=True (default), sample_size=10000 (default)
  • No breaking changes to API or configuration
  • No new dependencies

… profiler

Root cause: SQLAlchemy profiler queries massive production tables directly,
causing memory exhaustion. A 102B row table triggered 40-CTE cross-join queries
that hung the process and spiked memory to 10GB+.

Fix: Implement temp table sampling matching GE profiler's behavior:
- Create temporary tables with TABLESAMPLE for large tables (>sample_size rows)
- Use conditional sampling strategy matching GE exactly:
  * BLOCK + BERNOULLI for very large tables (>50M rows)
  * BERNOULLI only for smaller tables
- Cache row counts to avoid redundant COUNT(*) queries
- Clean up temp tables after profiling

Config-driven via existing use_sampling (default: True) and sample_size
(default: 10,000 rows) settings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the ingestion PR or Issue related to the ingestion of metadata label Jul 8, 2026
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90110% with 1 line in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...n/source/sqlalchemy_profiler/adapters/snowflake.py 98.38% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@datahub-connector-tests

datahub-connector-tests Bot commented Jul 8, 2026

Copy link
Copy Markdown

Connector Tests Results

All connector tests passed for commit 94acecd

View full test logs →

To skip connector tests, add the skip-connector-tests label (org members only).

Autogenerated by the connector-tests CI pipeline.

sgomezvillamor and others added 3 commits July 8, 2026 15:45
- Replace SELECT COUNT(*) with INFORMATION_SCHEMA.TABLES.ROW_COUNT
- Avoids full table scans on billion-row tables (instant vs minutes/timeout)
- Default to sampling when row count unavailable (conservative approach)
- Add warning when metadata query fails
- No additional permissions required (same as existing connector usage)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Log execution time for combined queries (INFO level)
- Log execution time for fallback queries (DEBUG level)
- Shows number of queries combined in each batch
- Helps diagnose slow profiling operations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…ution timing

Changes to snowflake.py:
- Use INFORMATION_SCHEMA.TABLES.ROW_COUNT instead of SELECT COUNT(*)
  for instant metadata lookup (no full table scan)
- Default to sampling when row count is unavailable (conservative approach)
- Add warning report when row count query fails

Changes to sqlalchemy_query_combiner.py:
- Add PerfTimer for query execution timing
- Add 5-char random query IDs to correlate log traces
- Log query execution before/after with timing and query ID
- Use INFO level for successful queries, WARNING for failures

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Filter by CURRENT_DATABASE() in INFORMATION_SCHEMA.TABLES query to
avoid ambiguity when multiple databases have the same schema.table
combination.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Snowflake GE profiler was setting custom_sql for sampling which prevented
SQLAlchemy profiler from using its adapter-based temp table sampling. Now
custom_sql is only set for method=ge, and the Snowflake adapter properly
creates session-scoped temp tables with quoted names for case preservation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
sgomezvillamor and others added 2 commits July 9, 2026 16:47
Switches Snowflake SQLAlchemy profiler from temp tables to inline
TABLESAMPLE queries, matching the proven GE profiler approach.

Previous temp table implementation had schema prefix issues and deviated
from GE profiler's battle-tested sampling strategy. GE uses inline
TABLESAMPLE queries for Snowflake (temp tables are only for BigQuery).

Changes:
- Removed temp table creation logic from SnowflakeAdapter
- Added _create_table_from_custom_sql() to wrap TABLESAMPLE as subquery
- Removed method == "ge" check in snowflake_profiler.py to enable
  custom_sql for SQLAlchemy profiler
- Added architectural comments explaining design decisions

This approach:
- Uses snowflake_profiler.py to decide on sampling (consistent with GE)
- Passes TABLESAMPLE query via context.custom_sql
- Avoids schema prefix and connection lifecycle issues with temp tables
- Maintains backwards compatibility with GE profiler

Future consideration: Evaluate temp tables for custom_sql like BigQuery
and Athena adapters, but would require handling Snowflake's session-scoped
temp table model and schema prefix complexity.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…pdate type signatures

- Add SEED parameter to TABLESAMPLE queries for deterministic sampling
  - Generates seed from hash(table_identifier) to ensure consistency
  - All profiling queries now operate on the same sample data
  - Prevents non-coherent metrics (e.g., min from one sample > max from another)

- Update type signatures from sa.Table to sa.sql.FromClause
  - Allows Snowflake adapter to use inline Subquery for TABLESAMPLE
  - Updated ProfilingContext.sql_table to accept both Table and Subquery
  - Updated all method signatures in sqlalchemy_profiler, adapters, and query_combiner_runner
  - Use getattr() for .schema/.name access where FromClause doesn't guarantee these attributes

Testing:
- All unit tests passing (4/4 Snowflake adapter tests)
- Integration tests show TABLESAMPLE with SEED working correctly
- All linting and mypy checks passing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…e SQLAlchemy profiler

Replace the inline subquery approach with temp table materialization.
The previous approach re-evaluated TABLESAMPLE on every CTE in every
combined query, causing 70-800x slowdown vs GE. Now the sample is
materialized once into a session-scoped temp table and all profiling
queries run against that small table.

Key changes:
- Gate custom_sql generation to GE-only in snowflake_profiler.py
- Snowflake adapter creates temp table via CREATE OR REPLACE TEMPORARY TABLE
- Two-tier BLOCK+BERNOULLI for very large tables, BERNOULLI-only fallback for views
- No SEED needed (materialized once, no cross-query determinism needed)
- Revert SEED from GE path (wasn't on master, added only for consistency)
- Conservative sampling when INFORMATION_SCHEMA row count unavailable
- Revert sa.sql.FromClause back to sa.Table across all adapter signatures
- Revert defensive getattr patterns back to direct attribute access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sgomezvillamor sgomezvillamor changed the title fix(ingestion): implement temp table sampling in Snowflake SQLAlchemy profiler fix(ingestion): materialize TABLESAMPLE into temp tables for Snowflake SQLAlchemy profiler Jul 13, 2026
…p table sampling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sgomezvillamor
sgomezvillamor marked this pull request as ready for review July 17, 2026 12:36
@maggiehays maggiehays added the needs-review Label for PRs that need review from a maintainer. label Jul 17, 2026
executed_sql = str(mock_conn.execute.call_args[0][0])
assert "CREATE OR REPLACE TEMPORARY TABLE" in executed_sql
assert "dh_sample_" in executed_sql
assert "TABLESAMPLE BERNOULLI" in executed_sql

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: this test runs the real sampling arithmetic but never checks the computed percentages — only "TABLESAMPLE BERNOULLI" in sql. Drop the * 100, change a constant, or emit a >100% fraction (which Snowflake rejects) and this stays green. Since the percentage math is the whole point of the PR, assert the exact substring for row_count=100_000 / sample_size=10_000TABLESAMPLE BERNOULLI (10.00000000), and in test_sampled_temp_table_block_bernoulli_sql assert BLOCK (10.00000000) + BERNOULLI (0.10000000). Ideally also add a guard that block_pc <= 100.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — addressed in a4c6143:

  • Tests now assert exact percentage substrings (TABLESAMPLE BERNOULLI (10.00000000), BLOCK (10.00000000) + BERNOULLI (0.10000000)) so any math regression will be caught.
  • Added min(block_pc, 100) as a defensive cap. The use_block_presample guard (row_count > sample_size * overgeneration_factor) already guarantees block_pc < 100, but the explicit cap makes the invariant self-documenting.

return context

# Determine if sampling is needed
row_count = self._get_row_count_from_metadata(context, conn)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix (perf, on-theme): this issues a fresh INFORMATION_SCHEMA round-trip per table, but the value is already known upstream — the GE path gates on table.rows_count (fetched during schema crawl), and ProfilingContext.row_count exists as a field but is never populated. BigQueryAdapter._setup_sampling already shows the intended pattern: row_count = context.row_count or self._get_quick_row_count(...). Thread table.rows_count into the context and fall back to _get_row_count_from_metadata only when it's None — removes one metadata query per profiled table.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — addressed in 726b76d:

  • snowflake_profiler.py: now passes "row_count": table.rows_count in get_batch_kwargs
  • sqlalchemy_profiler.py: threads row_count through _generate_single_profileProfilingContext
  • snowflake.py adapter: row_count = context.row_count or self._get_row_count_from_metadata(context, conn) — skips the INFORMATION_SCHEMA query when the count is already known from the schema crawl
  • Added test_setup_profiling_uses_context_row_count that asserts _get_row_count_from_metadata is not called when context.row_count is pre-populated

# custom_sql is or may still be used for some use cases (not non-sampling).
# Materialize it into a temp table so profiling queries run against a small
# concrete table, same as what GX does internally.
if context.custom_sql:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: since custom_sql is now gated GE-only in snowflake_profiler.py, nothing populates context.custom_sql on the SQLAlchemy Snowflake path — this branch is only reachable from its unit test today. Either remove it until a real producer exists, or add a comment naming the future producer (e.g. anticipated partition support), so it doesn't read as a live capability. Related: _create_temp_table_from_custom_sql sets is_sampled=True for a filtered query that isn't a statistical sample, which would mislabel the profile as SAMPLE — worth fixing if/when this path goes live.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — custom_sql is indeed GE-only (gated by method == "ge" in snowflake_profiler.py). Replaced the dead handling branch with an assert in 3e90221:

  • if context.custom_sql:assert not context.custom_sql with a message explaining it's GE-only
  • Removed the now-unreachable _create_temp_table_from_custom_sql method
  • Updated the test to verify the assert fires when custom_sql is passed

This also sidesteps the is_sampled=True mislabeling issue you flagged — since the method no longer exists.

block_profiling_min_rows = 100 * estimated_block_row_count
overgeneration_factor = 1000

tablename = f'"{context.schema}"."{context.table}"'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (pre-existing pattern): "{schema}"."{table}" isn't escaped for embedded double-quotes — a legitimately quoted Snowflake identifier containing a " would break out of the quoting. Not introduced by this PR (the same pattern exists elsewhere in the profiler) and not what aikido flagged, but worth a .replace('"', '""') on each part while you're here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed for the SQLAlchemy path in 1023e67:

  • Made db_name optional in get_quoted_identifier_for_table (returns "schema"."table" when None)
  • SQLAlchemy adapter now uses SnowflakeIdentifierBuilder.get_quoted_identifier_for_table(db_name=None, ...) instead of raw f-string quoting
  • Added two tests for the db_name=None variant in TestSnowflakeIdentifierQuoting

Not fixing the GE path (snowflake_profiler.py:120) since GE profiler is being removed soon.

self.report.combined_queries_issued += 1
sa_res = _sa_execute_underlying_method(queue_item.conn, combined_query)
logger.info(
f"[{query_id}] Executing combined query ({len(pending_queue)} queries combined): {str(combined_query)}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (log noise): the [query_id] correlation + PerfTimer are genuinely useful, but logging the full str(combined_query) at INFO on every combined query (and the fallback equivalent) is a big verbosity jump for a utility used across all SQL profilers — a large run emits one long SQL line per batch at normal verbosity. Consider keeping the SQL body at DEBUG and logging only the [query_id] + timing at INFO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a1a7d8b — SQL body moved to DEBUG, query_id + count/timing stays at INFO for both the combined and fallback paths.

@maggiehays maggiehays added pending-submitter-merge and removed needs-review Label for PRs that need review from a maintainer. labels Jul 20, 2026
- Assert exact percentage substrings (e.g. BERNOULLI (10.00000000)) instead
  of just checking the keyword is present, so math regressions are caught.
- Add min(block_pc, 100) cap as a defensive guard against >100% fractions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ke adapter

custom_sql is GE-only (gated by `method == "ge"` in snowflake_profiler.py),
so the SQLAlchemy adapter should never receive it. Replace the handling
branch with an assert that documents this invariant, and remove the
now-unreachable _create_temp_table_from_custom_sql method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…p INFORMATION_SCHEMA query

Thread table.rows_count (from the schema crawl) through batch_kwargs →
_generate_single_profile → ProfilingContext.row_count, so the Snowflake
adapter can skip the redundant INFORMATION_SCHEMA round-trip when the
count is already known.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…wn and conservative sampling kicks in

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…E identifiers

Use SnowflakeIdentifierBuilder.get_quoted_identifier_for_table in the
SQLAlchemy adapter instead of raw f-string quoting. Made db_name
optional in get_quoted_identifier_for_table since the adapter path
only has schema + table (no database).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… sampling fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…wflake adapter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
sgomezvillamor and others added 3 commits July 20, 2026 13:38
Keep query_id + timing at INFO, move full SQL text to DEBUG to reduce
log noise on large profiling runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tifier_for_table call

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…in CTE batching tests

Replace fragile caplog-based SQL assertions with before_cursor_execute
event listeners that capture actual SQL sent to the database. Applied
to both test_queries_are_batched_with_multiple_ctes and
test_strategic_batching_with_multiple_flush_points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ingestion PR or Issue related to the ingestion of metadata pending-submitter-merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants