This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new f8c05bba6ed fix(clickhouse): bound system sampling reads instead of
failing on max_rows_to_read (#42464)
f8c05bba6ed is described below
commit f8c05bba6ed308fc306116bdb6ab8bae19acf127
Author: Mike Bridge <[email protected]>
AuthorDate: Wed Jul 29 18:20:20 2026 +0100
fix(clickhouse): bound system sampling reads instead of failing on
max_rows_to_read (#42464)
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
UPDATING.md | 16 ++
superset/common/query_actions.py | 5 +
superset/databases/schemas.py | 15 +-
superset/datasets/datetime_format_detector.py | 27 ++-
superset/db_engine_specs/base.py | 34 +++
superset/db_engine_specs/clickhouse.py | 51 ++++
superset/models/core.py | 71 ++++++
superset/models/helpers.py | 36 ++-
tests/unit_tests/charts/test_schemas.py | 15 ++
tests/unit_tests/common/test_query_actions.py | 39 ++++
.../datasets/test_datetime_format_detector.py | 200 ++++++++++++++++
.../unit_tests/db_engine_specs/test_clickhouse.py | 259 +++++++++++++++++++++
tests/unit_tests/models/helpers_test.py | 227 ++++++++++++++++++
13 files changed, 981 insertions(+), 14 deletions(-)
diff --git a/UPDATING.md b/UPDATING.md
index c5eb722118f..d8d0fe901d0 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -47,6 +47,22 @@ dashboard importer's behavior. This changes anything only
for a user who was not
already an editor of that chart — typically an admin overwriting a chart they
do
not own, who was previously added as an editor as a side effect of the import.
Newly-created charts are unaffected.
+### ClickHouse: system sampling queries retry with a bounded read
+
+System-generated sampling queries — filter-value dropdowns, the Samples
+tab/dataset preview, and datetime format detection — that ClickHouse rejects
+with a `max_rows_to_read` error (`TOO_MANY_ROWS`, code 158) are now retried
+once with `SETTINGS read_overflow_mode='break'` appended, so they return a
+partial result bounded by the operator's row cap instead of failing. The retry
+applies only to statements Superset generates for physical-table datasets;
+virtual datasets and user-authored SQL remain fully governed by configured
+read limits, and queries that already succeed are never altered. Operators who
+rely on `max_rows_to_read` as a hard failure gate for these system queries can
+restore the previous behavior per database with
+`"disable_sampling_read_limit_override": true` in the database's Extra JSON.
+Note that a retried query returns partial data with no truncation indicator
+(e.g. a filter dropdown may list only a subset of values on tables above the
+row cap).
### Dashboard "Export Data to Excel" requires a Celery worker and S3 bucket
diff --git a/superset/common/query_actions.py b/superset/common/query_actions.py
index 5479e12d880..fbb93e5030d 100644
--- a/superset/common/query_actions.py
+++ b/superset/common/query_actions.py
@@ -211,6 +211,11 @@ def _get_samples(
query_obj.orderby = []
query_obj.metrics = None
query_obj.post_processing = []
+ # Mark the query as system-authored sampling so query generation may apply
+ # the engine's bounded-read override (physical datasets only). The shallow
+ # copy above shares the extras dict with the original query object, so
+ # build a new dict instead of mutating in place.
+ query_obj.extras = {**(query_obj.extras or {}), "system_sampling": True}
qry_obj_cols = []
for o in datasource.columns:
if isinstance(o, dict):
diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py
index 10a7cbac540..a6876a1274c 100644
--- a/superset/databases/schemas.py
+++ b/superset/databases/schemas.py
@@ -159,11 +159,17 @@ extra_description = markdown(
"5. The ``allows_virtual_table_explore`` field is a boolean specifying "
"whether or not the Explore button in SQL Lab results is shown.<br/>"
"6. The ``disable_data_preview`` field is a boolean specifying whether or
not data "
- "preview queries will be run when fetching table metadata in SQL Lab."
- "7. The ``disable_drill_to_detail`` field is a boolean specifying whether
or not"
- "drill to detail is disabled for the database."
+ "preview queries will be run when fetching table metadata in SQL Lab.<br/>"
+ "7. The ``disable_drill_to_detail`` field is a boolean specifying whether
or not "
+ "drill to detail is disabled for the database.<br/>"
"8. The ``allow_multi_catalog`` indicates if the database allows changing "
- "the default catalog when running queries and creating datasets.",
+ "the default catalog when running queries and creating datasets.<br/>"
+ "9. The ``disable_sampling_read_limit_override`` field is a boolean "
+ "specifying whether system-generated sampling queries (filter values, "
+ "samples/preview, datetime format detection) that an engine rejects with "
+ "a read-limit error should fail outright instead of being retried once "
+ "with the engine's bounded-read override. Only affects engines that "
+ "implement such an override.",
True,
)
get_export_ids_schema = {
@@ -971,6 +977,7 @@ class ImportV1DatabaseExtraSchema(Schema):
cancel_query_on_windows_unload = fields.Boolean(required=False)
disable_data_preview = fields.Boolean(required=False)
disable_drill_to_detail = fields.Boolean(required=False)
+ disable_sampling_read_limit_override = fields.Boolean(required=False)
allow_multi_catalog = fields.Boolean(required=False)
per_user_caching = fields.Boolean(required=False)
version = fields.String(required=False, allow_none=True)
diff --git a/superset/datasets/datetime_format_detector.py
b/superset/datasets/datetime_format_detector.py
index 7e6f08f1b46..ffb44941e66 100644
--- a/superset/datasets/datetime_format_detector.py
+++ b/superset/datasets/datetime_format_detector.py
@@ -122,15 +122,23 @@ class DatetimeFormatDetector:
# This handles different SQL dialects (LIMIT, TOP, FETCH FIRST,
etc.)
sql = database.apply_limit_to_sql(sql, limit=self.sample_size,
force=True)
- # Execute query and get results. Failures here come from the
- # target database itself (bad connection config, transient
- # outage, permission errors, etc.), not from Superset's own
+ # Execute query and get results. This is system-authored sampling
+ # over a physical table (virtual datasets returned above): engines
+ # like ClickHouse reject full-table-shaped queries from a
+ # pre-execution row estimate that ignores LIMIT, so a read-limit
+ # rejection is retried once with the engine's bounded-read
+ # override. Remaining failures come from the target database
+ # itself (bad connection config, transient outage, permission
+ # errors, an exhausted retry, etc.), not from Superset's own
# logic. Format detection is a best-effort optimization with no
# user-facing impact when it's skipped, so log at WARNING
# instead of capturing an ERROR-level exception for every sample
# query a misconfigured/unreachable database rejects.
try:
- df = database.get_df(sql, dataset.schema)
+ df = database.run_with_sampling_read_limit_retry(
+ sql,
+ lambda query_sql: database.get_df(query_sql,
dataset.schema),
+ )
except Exception as ex:
logger.warning(
"Could not query column %s.%s for format detection: %s",
@@ -169,6 +177,10 @@ class DatetimeFormatDetector:
return detected_format
except Exception as ex:
+ # Database query failures (including a read limit still refusing
+ # the sample after the bounded-read retry) are caught and logged
+ # at WARNING above; anything reaching here is a failure in the
+ # detection logic itself.
logger.exception(
"Error detecting format for column %s.%s: %s",
dataset.table_name,
@@ -218,8 +230,11 @@ class DatetimeFormatDetector:
# Log results
if results:
- logger.info(
- "Detected formats for %d columns in dataset %s",
+ detected = sum(1 for fmt in results.values() if fmt)
+ log_method = logger.info if detected else logger.warning
+ log_method(
+ "Detected formats for %d of %d temporal columns in dataset %s",
+ detected,
len(results),
dataset.table_name,
)
diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py
index eee5635160a..9bf36af7c9e 100644
--- a/superset/db_engine_specs/base.py
+++ b/superset/db_engine_specs/base.py
@@ -621,6 +621,40 @@ class BaseEngineSpec: # pylint:
disable=too-many-public-methods
# the `cancel_query` value in the `extra` field of the `query` object
has_query_id_before_execute = True
+ @classmethod
+ def apply_sampling_read_limit_override(cls, sql: str) -> str | None:
+ """Build the bounded-read retry form of system-authored sampling SQL.
+
+ Some engines reject bounded queries from a pre-execution row estimate
+ that ignores LIMIT (e.g. ClickHouse ``max_rows_to_read``), which breaks
+ Superset-authored sampling queries (filter values, samples/preview,
+ datetime format detection) on large tables. Engine specs that support
+ a bounded-read override return a modified query; ``None`` (the base
+ implementation, mirroring ``get_column_description_retry_sql``) means
+ no retry is available.
+
+ The override must only be applied to sampling queries whose statement
+ Superset generated (a physical-table dataset, not a virtual dataset's
+ user-authored base query), and only as a retry after the engine
+ rejected the un-modified statement with a read-limit error (see
+ ``is_read_limit_error``), so deployments whose queries already succeed
+ — including ClickHouse users connected with ``readonly=1``, which
+ rejects in-query SETTINGS changes — never see altered SQL. Callers go
+ through ``Database.sampling_read_limit_retry_sql`` so the per-database
+ opt-out is honored.
+ """
+ return None
+
+ @classmethod
+ def is_read_limit_error(cls, ex: Exception) -> bool:
+ """Return True when the exception is this engine's read-limit
rejection.
+
+ Used to decide whether a failed system-sampling query should be
+ retried with ``apply_sampling_read_limit_override``. The base
+ implementation recognizes nothing.
+ """
+ return False
+
@classmethod
def encrypted_extra_sensitive_field_paths(cls) -> set[str]:
"""
diff --git a/superset/db_engine_specs/clickhouse.py
b/superset/db_engine_specs/clickhouse.py
index d2e73dfbb55..64c28b8c807 100644
--- a/superset/db_engine_specs/clickhouse.py
+++ b/superset/db_engine_specs/clickhouse.py
@@ -56,6 +56,57 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
time_groupby_inline = True
supports_multivalues_insert = True
+ # ClickHouse enforces max_rows_to_read against a pre-execution estimate
+ # that ignores LIMIT, so bounded sampling queries on large tables are
+ # rejected with TOO_MANY_ROWS before reading begins. Break mode keeps the
+ # operator's row cap as the read bound and returns the partial result
+ # instead of erroring. The clause is applied on its own line because the
+ # retry operates on the final statement text, which SQL mutators may have
+ # terminated with a single-line comment.
+ sampling_read_limit_override_suffix = "\nSETTINGS
read_overflow_mode='break'"
+
+ @classmethod
+ def apply_sampling_read_limit_override(cls, sql: str) -> str | None:
+ """Append a read-overflow override so bounded sampling SQL succeeds.
+
+ Returns ``None`` when no retry should be attempted: the SQL already
+ carries the override, or it contains a SETTINGS clause from another
+ source (ClickHouse permits only one per statement, so appending a
+ second would produce invalid SQL — including subquery SETTINGS in
+ this check merely degrades to the engine's normal rejection). The
+ guard matches the clause shape ``SETTINGS <key> = ...`` rather than
+ the bare token, and string literals, quoted identifiers, and comments
+ are blanked out before matching, so a column named ``settings`` or a
+ literal/comment merely containing that text does not suppress the
+ retry. A trailing statement terminator is stripped so the SETTINGS
+ clause attaches to the statement itself.
+ """
+ code_only = re.sub(
+ r"'(?:[^']|'')*'" # single-quoted string literals ('' escape)
+ r'|"(?:[^"]|"")*"' # double-quoted identifiers
+ r"|`[^`]*`" # backtick-quoted identifiers
+ r"|--[^\n]*" # single-line comments
+ r"|/\*.*?\*/", # block comments
+ " ",
+ sql,
+ flags=re.DOTALL,
+ )
+ if re.search(r"\bSETTINGS\s+\w+\s*=", code_only, re.IGNORECASE):
+ return None
+ stripped = sql.rstrip().rstrip(";").rstrip()
+ return f"{stripped}{cls.sampling_read_limit_override_suffix}"
+
+ @classmethod
+ def is_read_limit_error(cls, ex: Exception) -> bool:
+ """Recognize ClickHouse's max_rows_to_read rejection (TOO_MANY_ROWS).
+
+ Anchored to the error-code tokens ClickHouse emits ("Code: 158" /
+ "TOO_MANY_ROWS") rather than the setting name, so unrelated errors
+ that merely mention the setting are not misclassified.
+ """
+ message = str(ex)
+ return "TOO_MANY_ROWS" in message or "Code: 158" in message
+
_time_grain_expressions = {
None: "{col}",
"PT1M": "toStartOfMinute(toDateTime({col}))",
diff --git a/superset/models/core.py b/superset/models/core.py
index d9153f36160..bfa9cc98347 100755
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -291,6 +291,77 @@ class Database(CoreDatabase, AuditMixinNullable,
ImportExportMixin): # pylint:
# this will prevent any 'trash value' strings from going through
return self.get_extra().get("disable_drill_to_detail", False) is True
+ @property
+ def disable_sampling_read_limit_override(self) -> bool:
+ return (
+ self.get_extra().get("disable_sampling_read_limit_override",
False) is True
+ )
+
+ def sampling_read_limit_retry_sql(self, sql: str) -> str | None:
+ """Build the bounded-read retry form of a failed sampling query.
+
+ Honors the per-database ``disable_sampling_read_limit_override`` extra
+ flag before delegating to the engine spec, so operators can keep all
+ reads governed by their configured limits. Returns ``None`` when no
+ retry should be attempted (opt-out set, or the engine has no
+ bounded-read override).
+ """
+ if self.disable_sampling_read_limit_override:
+ return None
+ return self.db_engine_spec.apply_sampling_read_limit_override(sql)
+
+ def run_with_sampling_read_limit_retry(
+ self,
+ sql: str,
+ run: Callable[[str], Any],
+ ) -> Any:
+ """Run a system-generated sampling query, retrying with a bounded read.
+
+ Executes ``run(sql)`` unchanged first, so deployments whose sampling
+ queries already succeed never see altered SQL (including ClickHouse
+ ``readonly=1`` users, which reject in-query SETTINGS changes). Only
+ when the engine rejects the statement with a read-limit error (e.g.
+ ClickHouse ``max_rows_to_read`` / TOO_MANY_ROWS) is the query retried
+ once with the engine's bounded-read override appended, returning a
+ partial result instead of erroring.
+
+ Callers must only route sampling statements generated by Superset for
+ a physical-table dataset through this retry — never a virtual
+ dataset's user-authored base query, which stays fully governed by
+ operator read limits.
+
+ Note that ``run`` implementations going through ``get_df`` re-apply
+ the operator's ``SQL_QUERY_MUTATOR`` to the retry SQL. ClickHouse
+ accepts the override with mutator-added comments (any position) and
+ subquery wrapping; a mutator that appends non-comment clauses after
+ the statement is not supported here — the retry then fails and the
+ original read-limit error is surfaced.
+ """
+ try:
+ return run(sql)
+ except Exception as ex:
+ retry_sql = self.sampling_read_limit_retry_sql(sql)
+ if retry_sql is None or not
self.db_engine_spec.is_read_limit_error(ex):
+ raise
+ logger.warning(
+ "Read limit rejected system sampling query on database %s; "
+ "retrying with the engine's bounded-read override: %s",
+ self.unique_name,
+ str(ex),
+ )
+ try:
+ return run(retry_sql)
+ except Exception as retry_ex:
+ # The retry is best-effort (e.g. readonly connections reject
+ # in-query SETTINGS changes); surface the original read-limit
+ # error, which is the root cause.
+ logger.warning(
+ "Bounded-read retry failed on database %s: %s",
+ self.unique_name,
+ str(retry_ex),
+ )
+ raise ex from retry_ex
+
@property
def allow_multi_catalog(self) -> bool:
return self.get_extra().get("allow_multi_catalog", False)
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 5748c04e097..fbd2865abf6 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -1673,13 +1673,26 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
df.columns = labels_expected
return df
- try:
- df = self.database.get_df(
- sql,
+ extras = query_obj.get("extras") or {}
+ system_sampling = bool(extras.get("system_sampling")) and not self.sql
+
+ def run_query(query_sql: str) -> Optional[pd.DataFrame]:
+ return self.database.get_df(
+ query_sql,
self.catalog,
self.schema,
mutator=assign_column_label,
)
+
+ try:
+ if system_sampling:
+ # System-authored sampling over a physical table (e.g. the
+ # Samples tab): when the engine rejects the statement with a
+ # read-limit error, retry once with the engine's bounded-read
+ # override so a partial sample is returned instead of an error.
+ df = self.database.run_with_sampling_read_limit_retry(sql,
run_query)
+ else:
+ df = run_query(sql)
except Exception as ex: # pylint: disable=broad-except
# Re-raise SupersetErrorException (includes OAuth2RedirectError)
# to bubble up to API layer
@@ -3331,7 +3344,22 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
sql = self.database.mutate_sql_based_on_config(sql)
with engine.connect() as con:
- df = pd.read_sql_query(sql=self.text(sql), con=con)
+
+ def run_query(query_sql: str) -> pd.DataFrame:
+ return pd.read_sql_query(sql=self.text(query_sql), con=con)
+
+ if not self.sql:
+ # Physical-table dataset: the filter-values statement is
+ # generated by Superset, so a read-limit rejection (e.g.
+ # ClickHouse max_rows_to_read) is retried once with the
+ # engine's bounded-read override. Virtual datasets embed
+ # user-authored SQL and stay governed by operator read
+ # limits.
+ df = self.database.run_with_sampling_read_limit_retry(
+ sql, run_query
+ )
+ else:
+ df = run_query(sql)
# replace NaN with None to ensure it can be serialized to JSON
df = df.replace({np.nan: None})
return df["column_values"].to_list()
diff --git a/tests/unit_tests/charts/test_schemas.py
b/tests/unit_tests/charts/test_schemas.py
index caa4cadde7e..65dd1313274 100644
--- a/tests/unit_tests/charts/test_schemas.py
+++ b/tests/unit_tests/charts/test_schemas.py
@@ -20,6 +20,7 @@ from flask import current_app
from marshmallow import ValidationError
from superset.charts.schemas import (
+ ChartDataExtrasSchema,
ChartDataProphetOptionsSchema,
ChartDataQueryObjectSchema,
ChartDataRollingOptionsSchema,
@@ -420,3 +421,17 @@ def
test_chart_external_url_rejects_non_absolute(app_context: None, url: str) ->
}
)
assert "external_url" in exc_info.value.messages
+
+
+def test_chart_data_extras_rejects_system_sampling(app_context: None) -> None:
+ """
+ ``extras["system_sampling"]`` is a server-side marker (set by the samples
+ query action) that routes physical-dataset sampling queries through the
+ engine's bounded-read retry. It must never be settable through the
+ chart-data API: this pins the schema's unknown-field rejection so a future
+ ``unknown = INCLUDE`` (or an explicit field) cannot silently make an
+ operator-limit-affecting flag client-controllable.
+ """
+ with pytest.raises(ValidationError) as exc_info:
+ ChartDataExtrasSchema().load({"system_sampling": True})
+ assert "system_sampling" in exc_info.value.messages
diff --git a/tests/unit_tests/common/test_query_actions.py
b/tests/unit_tests/common/test_query_actions.py
index 9347109f260..77dd962b3d9 100644
--- a/tests/unit_tests/common/test_query_actions.py
+++ b/tests/unit_tests/common/test_query_actions.py
@@ -86,3 +86,42 @@ def test_get_drill_detail_does_not_strip_filters(
"_get_drill_detail unexpectedly stripped a filter it never touches; "
"this guards against a regression introduced in that function, not
#28562."
)
+
+
+@patch("superset.common.query_actions._get_full")
+def test_get_samples_marks_query_as_system_sampling(
+ mock_get_full: MagicMock,
+) -> None:
+ """
+ ``_get_samples`` marks the copied query object as system-authored sampling
+ (so query generation may apply the engine's bounded-read override) without
+ mutating the caller's query object.
+ """
+ from superset.common.query_actions import _get_samples
+
+ query_obj: QueryObject = QueryObject(columns=["region"], metrics=["count"])
+ original_extras = query_obj.extras
+
+ col_region: MagicMock = MagicMock()
+ col_region.column_name = "region"
+
+ datasource = MagicMock()
+ datasource.columns = [col_region]
+
+ query_context: MagicMock = MagicMock()
+ query_context.datasource = datasource
+ query_context.result_type = ChartDataResultType.SAMPLES
+
+ captured: dict[str, QueryObject] = {}
+
+ def _capture(_ctx: MagicMock, obj: QueryObject, _force: bool) -> dict[str,
Any]:
+ captured["query_obj"] = obj
+ return {}
+
+ mock_get_full.side_effect = _capture
+ _get_samples(query_context, query_obj)
+
+ assert captured["query_obj"].extras.get("system_sampling") is True
+ # the caller's query object is untouched (shallow copy must not leak)
+ assert "system_sampling" not in query_obj.extras
+ assert query_obj.extras is original_extras
diff --git a/tests/unit_tests/datasets/test_datetime_format_detector.py
b/tests/unit_tests/datasets/test_datetime_format_detector.py
index afb3a95546d..4b0bbf4c2be 100644
--- a/tests/unit_tests/datasets/test_datetime_format_detector.py
+++ b/tests/unit_tests/datasets/test_datetime_format_detector.py
@@ -17,6 +17,7 @@
"""Tests for datetime format detector."""
import logging
+from typing import Any
from unittest.mock import MagicMock
import pandas as pd
@@ -49,6 +50,28 @@ def mock_dataset() -> MagicMock:
lambda sql, limit, force: f"{sql} LIMIT {limit}"
)
+ # Mirror the real retry semantics: run the SQL unchanged first, and only
+ # on a read-limit rejection retry once with the ClickHouse-shaped
+ # bounded-read suffix.
+ def is_read_limit_error(ex: Exception) -> bool:
+ message = str(ex)
+ return "TOO_MANY_ROWS" in message or "Code: 158" in message
+
+ def run_with_retry(sql: str, run: Any) -> Any:
+ try:
+ return run(sql)
+ except Exception as ex:
+ if not is_read_limit_error(ex):
+ raise
+ try:
+ return run(f"{sql}\nSETTINGS read_overflow_mode='break'")
+ except Exception as retry_ex:
+ # Mirror Database.run_with_sampling_read_limit_retry: a failed
+ # retry surfaces the original read-limit error.
+ raise ex from retry_ex
+
+ dataset.database.run_with_sampling_read_limit_retry = run_with_retry
+
return dataset
@@ -313,3 +336,180 @@ def test_detect_column_format_with_leading_null_samples(
assert detected_format == "%Y-%m-%d"
mock_dataset.database.get_df.assert_called_once()
+
+
+def test_detect_column_format_runs_unmodified_sql_first(
+ mock_dataset: MagicMock, mock_column: MagicMock
+) -> None:
+ """When the engine accepts the sample, no bounded-read override is used."""
+ sample_data = pd.DataFrame({"date_column": ["2023-01-01"]})
+
+ captured_sql: list[str] = []
+
+ def capture_sql(sql: str, schema: str) -> pd.DataFrame:
+ captured_sql.append(sql)
+ return sample_data
+
+ mock_dataset.database.get_df.side_effect = capture_sql
+
+ detector = DatetimeFormatDetector(sample_size=100)
+ detector.detect_column_format(mock_dataset, mock_column)
+
+ assert len(captured_sql) == 1
+ assert "SETTINGS" not in captured_sql[0]
+
+
+def test_detect_column_format_retries_with_bounded_read_on_read_limit(
+ mock_dataset: MagicMock, mock_column: MagicMock
+) -> None:
+ """A read-limit rejection is retried once with the bounded-read
override."""
+ sample_data = pd.DataFrame({"date_column": ["2023-01-01"]})
+
+ captured_sql: list[str] = []
+
+ def reject_then_succeed(sql: str, schema: str) -> pd.DataFrame:
+ captured_sql.append(sql)
+ if "SETTINGS" not in sql:
+ raise Exception( # noqa: TRY002
+ "Code: 158. DB::Exception: Limit for rows (controlled by "
+ "'max_rows_to_read' setting) exceeded. (TOO_MANY_ROWS)"
+ )
+ return sample_data
+
+ mock_dataset.database.get_df.side_effect = reject_then_succeed
+
+ detector = DatetimeFormatDetector(sample_size=100)
+ detected_format = detector.detect_column_format(mock_dataset, mock_column)
+
+ assert detected_format is not None
+ assert len(captured_sql) == 2
+ assert captured_sql[1].endswith("SETTINGS read_overflow_mode='break'")
+
+
+def test_detect_column_format_read_limit_error_logs_warning(
+ mock_dataset: MagicMock,
+ mock_column: MagicMock,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """A read limit still refusing the sample after the bounded-read retry
+ is a database-query failure: logged at WARNING, never ERROR."""
+ mock_dataset.database.get_df.side_effect = Exception(
+ "Code: 158. DB::Exception: Limit for rows (controlled by "
+ "'max_rows_to_read' setting) exceeded. (TOO_MANY_ROWS)"
+ )
+
+ detector = DatetimeFormatDetector()
+ with caplog.at_level(logging.WARNING):
+ detected_format = detector.detect_column_format(mock_dataset,
mock_column)
+
+ assert detected_format is None
+ assert not any(record.levelno >= logging.ERROR for record in
caplog.records)
+ assert any(
+ record.levelno == logging.WARNING and "Could not query column" in
record.message
+ for record in caplog.records
+ )
+
+
+def test_detect_column_format_failed_retry_surfaces_original_error(
+ mock_dataset: MagicMock,
+ mock_column: MagicMock,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """When the bounded-read retry itself fails (e.g. a readonly connection
+ rejecting in-query SETTINGS), the WARNING carries the original read-limit
+ error, not the retry failure."""
+
+ def reject_both(sql: str, schema: str) -> pd.DataFrame:
+ if "SETTINGS" in sql:
+ raise Exception( # noqa: TRY002
+ "Code: 164. DB::Exception: Cannot modify 'read_overflow_mode' "
+ "setting in readonly mode. (READONLY)"
+ )
+ raise Exception( # noqa: TRY002
+ "Code: 158. DB::Exception: Limit for rows exceeded.
(TOO_MANY_ROWS)"
+ )
+
+ mock_dataset.database.get_df.side_effect = reject_both
+
+ detector = DatetimeFormatDetector()
+ with caplog.at_level(logging.WARNING):
+ detected_format = detector.detect_column_format(mock_dataset,
mock_column)
+
+ assert detected_format is None
+ warnings = [
+ record.getMessage()
+ for record in caplog.records
+ if record.levelno == logging.WARNING
+ and "Could not query column" in record.message
+ ]
+ assert len(warnings) == 1
+ assert "TOO_MANY_ROWS" in warnings[0]
+ assert "READONLY" not in warnings[0]
+
+
+def test_detect_all_formats_summary_reports_detected_of_attempted(
+ mock_dataset: MagicMock,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """The summary reports detected vs attempted counts."""
+ col1 = MagicMock(spec=TableColumn)
+ col1.column_name = "date1"
+ col1.is_temporal = True
+ col1.datetime_format = None
+ col1.expression = None
+
+ col2 = MagicMock(spec=TableColumn)
+ col2.column_name = "date2"
+ col2.is_temporal = True
+ col2.datetime_format = None
+ col2.expression = None
+
+ mock_dataset.columns = [col1, col2]
+
+ sample_data = pd.DataFrame({"date1": ["2023-01-01", "2023-01-02"]})
+ mock_dataset.database.get_df.side_effect = [
+ sample_data,
+ Exception("TOO_MANY_ROWS"),
+ ]
+
+ detector = DatetimeFormatDetector()
+ with caplog.at_level("INFO"):
+ results = detector.detect_all_formats(mock_dataset)
+
+ assert results == {"date1": "%Y-%m-%d", "date2": None}
+ summary = [
+ record
+ for record in caplog.records
+ if "Detected formats for" in record.getMessage()
+ ]
+ assert len(summary) == 1
+ assert "1 of 2 temporal columns" in summary[0].getMessage()
+
+
+def test_detect_all_formats_zero_detected_is_not_logged_as_success(
+ mock_dataset: MagicMock,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """A 0-of-N run logs a WARNING summary, not a success-looking INFO."""
+ col1 = MagicMock(spec=TableColumn)
+ col1.column_name = "date1"
+ col1.is_temporal = True
+ col1.datetime_format = None
+ col1.expression = None
+
+ mock_dataset.columns = [col1]
+ mock_dataset.database.get_df.side_effect = Exception("TOO_MANY_ROWS")
+
+ detector = DatetimeFormatDetector()
+ with caplog.at_level("INFO"):
+ results = detector.detect_all_formats(mock_dataset)
+
+ assert results == {"date1": None}
+ summary = [
+ record
+ for record in caplog.records
+ if "Detected formats for" in record.getMessage()
+ ]
+ assert len(summary) == 1
+ assert summary[0].levelname == "WARNING"
+ assert "0 of 1 temporal columns" in summary[0].getMessage()
diff --git a/tests/unit_tests/db_engine_specs/test_clickhouse.py
b/tests/unit_tests/db_engine_specs/test_clickhouse.py
index b2369855854..62c1d7e77af 100644
--- a/tests/unit_tests/db_engine_specs/test_clickhouse.py
+++ b/tests/unit_tests/db_engine_specs/test_clickhouse.py
@@ -303,3 +303,262 @@ def
test_base_engine_spec_has_no_column_description_retry_by_default() -> None:
from superset.db_engine_specs.base import BaseEngineSpec
assert BaseEngineSpec.get_column_description_retry_sql("SELECT 1") is None
+
+
+def test_sampling_read_limit_override_base_spec_returns_none() -> None:
+ from superset.db_engine_specs.base import BaseEngineSpec
+
+ sql = "SELECT col FROM tbl LIMIT 100"
+ assert BaseEngineSpec.apply_sampling_read_limit_override(sql) is None
+
+
[email protected](
+ "spec_name",
+ ["ClickHouseEngineSpec", "ClickHouseConnectEngineSpec"],
+)
+def test_sampling_read_limit_override_clickhouse_family(spec_name: str) ->
None:
+ from superset.db_engine_specs import clickhouse
+
+ spec = getattr(clickhouse, spec_name)
+ sql = "SELECT col FROM tbl LIMIT 100"
+ assert spec.apply_sampling_read_limit_override(sql) == (
+ "SELECT col FROM tbl LIMIT 100\nSETTINGS read_overflow_mode='break'"
+ )
+
+
+def test_sampling_read_limit_override_strips_statement_terminator() -> None:
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ assert ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(
+ "SELECT col FROM tbl LIMIT 100;\n"
+ ) == ("SELECT col FROM tbl LIMIT 100\nSETTINGS read_overflow_mode='break'")
+
+
+def test_sampling_read_limit_override_survives_trailing_comment() -> None:
+ """
+ The retry operates on the final mutated statement, which SQL mutators may
+ terminate with a single-line comment; the SETTINGS clause must land on its
+ own line so the comment cannot swallow it.
+ """
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ sql = "SELECT col FROM tbl LIMIT 100\n-- query hash: abc123"
+ result =
ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(sql)
+ assert result is not None
+ assert result.splitlines()[-1] == "SETTINGS read_overflow_mode='break'"
+
+
+def test_sampling_read_limit_override_already_applied_returns_none() -> None:
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ sql = "SELECT col FROM tbl LIMIT 100"
+ once = ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(sql)
+ assert once is not None
+ assert
ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(once) is None
+
+
+def test_sampling_read_limit_override_existing_settings_returns_none() -> None:
+ """
+ ClickHouse permits one SETTINGS clause per statement; SQL that already
+ carries one (from any source) must not be retried with a second.
+ """
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ sql = "SELECT col FROM tbl LIMIT 100 SETTINGS max_threads=2"
+ assert ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(sql)
is None
+
+
+def test_sampling_read_limit_override_ignores_settings_text_in_literals() ->
None:
+ """
+ SETTINGS-clause-shaped text inside string literals or comments (e.g. a
+ fetch_values_predicate value or a mutator comment) must not suppress the
+ retry -- only a genuine statement-level clause counts.
+ """
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ in_literal = (
+ "SELECT DISTINCT col AS column_values FROM tbl "
+ "WHERE note = 'try SETTINGS max_threads=4 for speed' LIMIT 100"
+ )
+ result =
ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(in_literal)
+ assert result is not None
+ assert result.endswith("SETTINGS read_overflow_mode='break'")
+
+ in_comment = (
+ "SELECT col FROM tbl LIMIT 100\n-- mutator note: SETTINGS
max_threads=4"
+ )
+ result =
ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(in_comment)
+ assert result is not None
+
+ genuine = "SELECT col FROM tbl LIMIT 100 SETTINGS max_threads=4 -- note"
+ assert (
+
ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(genuine) is None
+ )
+
+
+def test_sampling_read_limit_override_ignores_settings_named_column() -> None:
+ """
+ The existing-clause guard matches the ``SETTINGS <key> = ...`` clause
+ shape, not the bare token, so a column named ``settings`` must not
+ suppress the retry.
+ """
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ sql = "SELECT DISTINCT settings AS column_values FROM tbl LIMIT 100"
+ result =
ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(sql)
+ assert result is not None
+ assert result.endswith("SETTINGS read_overflow_mode='break'")
+
+ filtered = "SELECT DISTINCT settings FROM tbl WHERE settings = 'a' LIMIT
100"
+ result =
ClickHouseConnectEngineSpec.apply_sampling_read_limit_override(filtered)
+ assert result is not None
+
+
+def _make_database(spec: Any, opt_out: bool = False) -> Any:
+ """A minimal Database stand-in with the real retry methods bound."""
+ from superset.models.core import Database
+
+ class FakeDatabase:
+ unique_name = "test_db"
+ db_engine_spec = spec
+ disable_sampling_read_limit_override = opt_out
+ sampling_read_limit_retry_sql = Database.sampling_read_limit_retry_sql
+ run_with_sampling_read_limit_retry =
Database.run_with_sampling_read_limit_retry
+
+ return FakeDatabase()
+
+
+def test_database_sampling_read_limit_retry_sql_honors_opt_out() -> None:
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ sql = "SELECT col FROM tbl LIMIT 100"
+
+ database = _make_database(ClickHouseConnectEngineSpec)
+ retry_sql = database.sampling_read_limit_retry_sql(sql)
+ assert retry_sql is not None
+ assert retry_sql.endswith("SETTINGS read_overflow_mode='break'")
+
+ database = _make_database(ClickHouseConnectEngineSpec, opt_out=True)
+ assert database.sampling_read_limit_retry_sql(sql) is None
+
+
+def test_database_sampling_read_limit_retry_sql_none_without_engine_support()
-> None:
+ from superset.db_engine_specs.base import BaseEngineSpec
+
+ database = _make_database(BaseEngineSpec)
+ assert database.sampling_read_limit_retry_sql("SELECT col FROM tbl") is
None
+
+
+def test_is_read_limit_error_base_spec_recognizes_nothing() -> None:
+ from superset.db_engine_specs.base import BaseEngineSpec
+
+ assert not BaseEngineSpec.is_read_limit_error(Exception("TOO_MANY_ROWS"))
+
+
+READ_LIMIT_ERROR_MESSAGE = (
+ "Code: 158. DB::Exception: Limit for rows (controlled by "
+ "'max_rows_to_read' setting) exceeded. (TOO_MANY_ROWS)"
+)
+
+
+def test_is_read_limit_error_clickhouse_anchored_to_error_codes() -> None:
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ assert ClickHouseConnectEngineSpec.is_read_limit_error(
+ Exception(READ_LIMIT_ERROR_MESSAGE)
+ )
+ assert
ClickHouseConnectEngineSpec.is_read_limit_error(Exception("(TOO_MANY_ROWS)"))
+ # A message merely mentioning the setting name is not a read-limit
+ # rejection.
+ assert not ClickHouseConnectEngineSpec.is_read_limit_error(
+ Exception("Cannot modify 'max_rows_to_read' setting in readonly mode")
+ )
+
+
+def test_run_with_sampling_read_limit_retry_success_never_alters_sql() -> None:
+ """
+ Deployments whose sampling queries succeed (including readonly=1
+ ClickHouse users) never see altered SQL.
+ """
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ database = _make_database(ClickHouseConnectEngineSpec)
+ executed: list[str] = []
+
+ def run(sql: str) -> str:
+ executed.append(sql)
+ return "ok"
+
+ result = database.run_with_sampling_read_limit_retry(
+ "SELECT col FROM tbl LIMIT 100", run
+ )
+ assert result == "ok"
+ assert executed == ["SELECT col FROM tbl LIMIT 100"]
+
+
+def test_run_with_sampling_read_limit_retry_retries_on_read_limit() -> None:
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ database = _make_database(ClickHouseConnectEngineSpec)
+ executed: list[str] = []
+
+ def run(sql: str) -> str:
+ executed.append(sql)
+ if "SETTINGS" not in sql:
+ raise Exception(READ_LIMIT_ERROR_MESSAGE) # noqa: TRY002
+ return "partial"
+
+ result = database.run_with_sampling_read_limit_retry(
+ "SELECT col FROM tbl LIMIT 100", run
+ )
+ assert result == "partial"
+ assert len(executed) == 2
+ assert executed[1].endswith("SETTINGS read_overflow_mode='break'")
+
+
+def test_run_with_sampling_read_limit_retry_reraises_other_errors() -> None:
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ database = _make_database(ClickHouseConnectEngineSpec)
+
+ def run(sql: str) -> str:
+ raise ValueError("connection refused")
+
+ with pytest.raises(ValueError, match="connection refused"):
+ database.run_with_sampling_read_limit_retry("SELECT 1", run)
+
+
+def test_run_with_sampling_read_limit_retry_surfaces_original_error() -> None:
+ """
+ When the retry itself fails (e.g. a readonly connection rejecting the
+ in-query SETTINGS change), the original read-limit error is raised, so
+ such deployments see the same failure they saw before the retry existed.
+ """
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ database = _make_database(ClickHouseConnectEngineSpec)
+
+ def run(sql: str) -> str:
+ if "SETTINGS" in sql:
+ raise Exception( # noqa: TRY002
+ "Cannot modify 'read_overflow_mode' setting in readonly mode.
Code: 164"
+ )
+ raise Exception(READ_LIMIT_ERROR_MESSAGE) # noqa: TRY002
+
+ with pytest.raises(Exception, match="TOO_MANY_ROWS"):
+ database.run_with_sampling_read_limit_retry("SELECT 1", run)
+
+
+def test_run_with_sampling_read_limit_retry_honors_opt_out() -> None:
+ from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+ database = _make_database(ClickHouseConnectEngineSpec, opt_out=True)
+ executed: list[str] = []
+
+ def run(sql: str) -> str:
+ executed.append(sql)
+ raise Exception(READ_LIMIT_ERROR_MESSAGE) # noqa: TRY002
+
+ with pytest.raises(Exception, match="TOO_MANY_ROWS"):
+ database.run_with_sampling_read_limit_retry("SELECT 1", run)
+ assert executed == ["SELECT 1"]
diff --git a/tests/unit_tests/models/helpers_test.py
b/tests/unit_tests/models/helpers_test.py
index 2c36c4bf469..579dd1beb0c 100644
--- a/tests/unit_tests/models/helpers_test.py
+++ b/tests/unit_tests/models/helpers_test.py
@@ -4465,3 +4465,230 @@ def
test_get_sqla_query_calculated_column_inlined_in_raw_records(
assert "CASE WHEN a > 0" in sql
assert "'positive'" in sql
assert "'non-positive'" in sql
+
+
+def test_values_for_column_uses_read_limit_retry(
+ mocker: MockerFixture,
+ database: Database,
+) -> None:
+ """
+ Physical-table datasets run filter-value SQL through the database
+ read-limit retry so engines like ClickHouse can bound the read when the
+ engine rejects it.
+ """
+ import pandas as pd
+
+ from superset.connectors.sqla.models import SqlaTable, TableColumn
+
+ table = SqlaTable(
+ database=database,
+ schema=None,
+ table_name="t",
+ columns=[TableColumn(column_name="a")],
+ )
+
+ retry = mocker.patch.object(
+ database,
+ "run_with_sampling_read_limit_retry",
+ side_effect=lambda sql, run: run(sql),
+ )
+ with patch(
+ "pandas.read_sql_query",
+ return_value=pd.DataFrame({"column_values": [1]}),
+ ):
+ table.values_for_column("a")
+
+ retry.assert_called_once()
+
+
+def test_values_for_column_virtual_dataset_skips_read_limit_retry(
+ mocker: MockerFixture,
+ database: Database,
+) -> None:
+ """
+ Virtual datasets embed user-authored SQL and must stay governed by
+ operator read limits.
+ """
+ import pandas as pd
+
+ from superset.connectors.sqla.models import SqlaTable, TableColumn
+
+ table = SqlaTable(
+ database=database,
+ schema=None,
+ table_name="virtual_t",
+ sql="SELECT a FROM t",
+ columns=[TableColumn(column_name="a")],
+ )
+
+ retry = mocker.patch.object(
+ database,
+ "run_with_sampling_read_limit_retry",
+ side_effect=lambda sql, run: run(sql),
+ )
+ with patch(
+ "pandas.read_sql_query",
+ return_value=pd.DataFrame({"column_values": [1]}),
+ ):
+ table.values_for_column("a")
+
+ retry.assert_not_called()
+
+
+def test_get_query_str_extended_does_not_alter_system_sampling_sql(
+ database: Database,
+) -> None:
+ """
+ The bounded-read override is a retry-time concern; the generated (and
+ user-visible) statement for a samples request must stay unmodified.
+ """
+ from superset.connectors.sqla.models import SqlaTable, TableColumn
+
+ table = SqlaTable(
+ database=database,
+ schema=None,
+ table_name="t",
+ columns=[TableColumn(column_name="a")],
+ )
+
+ query_str_ext = table.get_query_str_extended(
+ {
+ "columns": ["a"],
+ "extras": {"system_sampling": True},
+ "is_timeseries": False,
+ "row_limit": 10,
+ }
+ )
+ assert "SETTINGS" not in query_str_ext.sql
+
+
+def test_query_system_sampling_uses_read_limit_retry(
+ mocker: MockerFixture,
+ database: Database,
+) -> None:
+ """
+ The system_sampling extras marker (set by the samples query action)
+ routes execution through the read-limit retry; ordinary chart queries
+ are untouched.
+ """
+ import pandas as pd
+
+ from superset.connectors.sqla.models import SqlaTable, TableColumn
+
+ table = SqlaTable(
+ database=database,
+ schema=None,
+ table_name="t",
+ columns=[TableColumn(column_name="a")],
+ )
+ retry = mocker.patch.object(
+ database,
+ "run_with_sampling_read_limit_retry",
+ side_effect=lambda sql, run: run(sql),
+ )
+ mocker.patch.object(database, "get_df", return_value=pd.DataFrame({"a":
[1]}))
+
+ table.query(
+ {
+ "columns": ["a"],
+ "extras": {"system_sampling": True},
+ "is_timeseries": False,
+ "row_limit": 10,
+ }
+ )
+ retry.assert_called_once()
+
+ retry.reset_mock()
+ table.query({"columns": ["a"], "is_timeseries": False, "row_limit": 10})
+ retry.assert_not_called()
+
+
+def test_query_system_sampling_skips_virtual_dataset(
+ mocker: MockerFixture,
+ database: Database,
+) -> None:
+ """
+ Even sample requests do not receive the retry on virtual datasets.
+ """
+ import pandas as pd
+
+ from superset.connectors.sqla.models import SqlaTable, TableColumn
+
+ table = SqlaTable(
+ database=database,
+ schema=None,
+ table_name="virtual_t",
+ sql="SELECT a FROM t",
+ columns=[TableColumn(column_name="a")],
+ )
+ retry = mocker.patch.object(
+ database,
+ "run_with_sampling_read_limit_retry",
+ side_effect=lambda sql, run: run(sql),
+ )
+ mocker.patch.object(database, "get_df", return_value=pd.DataFrame({"a":
[1]}))
+
+ table.query(
+ {
+ "columns": ["a"],
+ "extras": {"system_sampling": True},
+ "is_timeseries": False,
+ "row_limit": 10,
+ }
+ )
+ retry.assert_not_called()
+
+
+def test_select_star_returns_unmodified_sql(
+ mocker: MockerFixture,
+ database: Database,
+) -> None:
+ """
+ select_star output is displayed in SQL Lab, returned by the API, and
+ persisted as CTAS result-fetch SQL, so it must never carry the
+ bounded-read override.
+ """
+ from superset.sql.parse import Table
+
+ retry_sql = mocker.patch.object(database, "sampling_read_limit_retry_sql")
+ sql = database.select_star(Table("t"), limit=10, latest_partition=False)
+
+ retry_sql.assert_not_called()
+ assert "SETTINGS" not in sql
+
+
+def test_adhoc_type_probe_does_not_get_sampling_retry(
+ mocker: MockerFixture,
+ database: Database,
+) -> None:
+ """
+ The adhoc expression type probe is a zero-row WHERE FALSE query and must
+ never be routed through the sampling read-limit retry.
+ """
+ from superset.connectors.sqla.models import SqlaTable, TableColumn
+
+ table = SqlaTable(
+ database=database,
+ schema=None,
+ table_name="t",
+ columns=[TableColumn(column_name="a", type="INTEGER")],
+ )
+ retry = mocker.patch.object(
+ database,
+ "run_with_sampling_read_limit_retry",
+ side_effect=lambda sql, run: run(sql),
+ )
+ mocker.patch(
+ "superset.connectors.sqla.models.get_columns_description",
+ return_value=[{"is_dttm": False, "type_generic":
GenericDataType.NUMERIC}],
+ )
+
+ adhoc_col: AdhocColumn = {
+ "sqlExpression": "a + 1",
+ "label": "probe_me",
+ "columnType": "BASE_AXIS",
+ "timeGrain": "P1D",
+ }
+ table.adhoc_column_to_sqla(adhoc_col)
+
+ retry.assert_not_called()