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 3ff5dbfe81e fix(native-filters): use FILTER_STATE_CACHE_CONFIG timeout
for dynamic filter option queries (#38910)
3ff5dbfe81e is described below
commit 3ff5dbfe81e68662974ac4d15f5eb2de60ad40e1
Author: Ujjwal Jain <[email protected]>
AuthorDate: Fri Jul 24 09:31:54 2026 +0530
fix(native-filters): use FILTER_STATE_CACHE_CONFIG timeout for dynamic
filter option queries (#38910)
---
superset/common/query_context_processor.py | 70 +++++++++-
superset/config.py | 13 ++
tests/integration_tests/charts/data/api_tests.py | 167 ++++++++++++++++++++++-
3 files changed, 246 insertions(+), 4 deletions(-)
diff --git a/superset/common/query_context_processor.py
b/superset/common/query_context_processor.py
index b6a36f72f04..f90f57021d5 100644
--- a/superset/common/query_context_processor.py
+++ b/superset/common/query_context_processor.py
@@ -480,16 +480,82 @@ class QueryContextProcessor:
return return_value
def get_cache_timeout(self) -> int:
- if cache_timeout_rv := self._query_context.get_cache_timeout():
- return cache_timeout_rv
+ """
+ Determine the cache timeout (in seconds) for this query context.
+
+ Priority chain (highest to lowest):
+ 1. ``custom_cache_timeout`` — explicit per-request override.
+ 2. ``NATIVE_FILTER_OPTIONS_CACHE_TIMEOUT`` — when the request is a
+ native filter option query and the operator has configured an
+ independent TTL for filter options.
+ 3. Slice-level or datasource-level timeout, via
+ :meth:`QueryContext.get_cache_timeout`.
+ 4. ``DATA_CACHE_CONFIG["CACHE_DEFAULT_TIMEOUT"]``.
+ 5. ``CACHE_DEFAULT_TIMEOUT`` — global fallback.
+ """
+ # Step 1: Request-level custom timeout (e.g., Force refresh bypass)
+ if self._query_context.custom_cache_timeout is not None:
+ return self._query_context.custom_cache_timeout
+
+ # Step 2: Native filter option query override.
+ native_filter_timeout: int | None = current_app.config.get(
+ "NATIVE_FILTER_OPTIONS_CACHE_TIMEOUT"
+ )
+ if native_filter_timeout is not None and
self._is_native_filter_options_query(
+ self._query_context.form_data or {}
+ ):
+ return native_filter_timeout
+
+ # Step 3: Slice, Dataset, or Database timeouts
+ if (cache_timeout := self._query_context.get_cache_timeout()) is not
None:
+ return cache_timeout
+
+ # Step 4: DATA_CACHE_CONFIG fallback.
if (
data_cache_timeout := current_app.config["DATA_CACHE_CONFIG"].get(
"CACHE_DEFAULT_TIMEOUT"
)
) is not None:
return data_cache_timeout
+
+ # Step 5: Global fallback.
return current_app.config["CACHE_DEFAULT_TIMEOUT"]
+ @staticmethod
+ def _is_native_filter_options_query(form_data: dict[str, Any]) -> bool:
+ """
+ Return ``True`` if this request is a native filter option query.
+
+ Native filter option queries are generated by the dashboard native
+ filter system when "Dynamically search all filter values" is enabled.
+ They share the ``/api/v1/chart/data`` endpoint with regular chart
+ queries but have different freshness requirements, especially for
+ datasets whose visible values may change frequently, including
+ RLS-constrained datasets.
+
+ Detection is based on two stable fields that are exclusively set by
+ the native filter system:
+
+ * ``native_filter_id`` — set in
+ ``nativeFilters/utils.ts::getFormData()`` (line 105); only ever
+ present for native filter requests.
+ * ``viz_type`` starting with ``"filter_"`` — the canonical prefix for
+ all native filter plugins (``filter_select``, ``filter_range``,
+ ``filter_time``, ``filter_timegrain``, ``filter_timecolumn``).
+
+ .. important::
+ We intentionally do **not** check ``form_data["metrics"]``.
+ ``getFormData()`` in ``nativeFilters/utils.ts`` line 95
+ unconditionally sets ``metrics: ["count"]`` in ``form_data``
+ for every native filter request, regardless of ``sortMetric``
+ configuration. A condition on ``not form_data.get("metrics")``
+ would therefore always evaluate to ``False`` in production and
+ silently prevent the override from ever applying.
+ """
+ return bool(form_data.get("native_filter_id")) and str(
+ form_data.get("viz_type", "")
+ ).startswith("filter_")
+
def cache_key(self, **extra: Any) -> str:
"""
The QueryContext cache key is made out of the key/values from
diff --git a/superset/config.py b/superset/config.py
index 61fddd53e4e..119c3b4c413 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1409,6 +1409,19 @@ EXTENSIONS_PERSISTENT_STORAGE: dict[str, Any] = {
# store cache keys by datasource UID (via CacheKey) for custom
processing/invalidation
STORE_CACHE_KEYS_IN_METADATA_DB = False
+# Cache timeout (in seconds) specifically for native dashboard filter option
queries.
+# Native filter queries use `DATA_CACHE_CONFIG` as their backend, but their
TTL can be
+# configured independently here because they often require fresher data (e.g.,
for
+# datasets whose visible values change frequently, including RLS-constrained
datasets).
+#
+# Valid values:
+# None : Preserve the existing cache timeout resolution chain.
+# -1 : Completely disable cache writes for native filter options.
+# 0 : Passed directly to the underlying cache backend. Backend-specific
+# behavior may vary. Do not use 0 to disable caching; use -1 instead.
+# >0 : Cache filter options for the specified number of seconds.
+NATIVE_FILTER_OPTIONS_CACHE_TIMEOUT: int | None = None
+
# CORS Options
# NOTE: enabling this requires installing the cors-related python dependencies
# `pip install .[cors]` or `pip install apache_superset[cors]`, depending
diff --git a/tests/integration_tests/charts/data/api_tests.py
b/tests/integration_tests/charts/data/api_tests.py
index 42e3ec39986..eebee483a46 100644
--- a/tests/integration_tests/charts/data/api_tests.py
+++ b/tests/integration_tests/charts/data/api_tests.py
@@ -15,16 +15,21 @@
# specific language governing permissions and limitations
# under the License.
+from __future__ import annotations
+
import copy
import time
import unittest
from contextlib import contextmanager
from datetime import datetime
from io import BytesIO
-from typing import Any, Optional
+from typing import Any, TYPE_CHECKING
from unittest import mock
from zipfile import ZipFile
+if TYPE_CHECKING:
+ from flask.testing import FlaskClient
+
import pytest
from flask import g, Response
from flask.ctx import AppContext
@@ -543,7 +548,7 @@ class TestPostChartDataApi(BaseTestChartDataApi):
assert str(ms_epoch) not in result["query"]
# assert that converted timestamp is present in query where supported
- dttm_col: Optional[TableColumn] = None
+ dttm_col: TableColumn | None = None
for col in table.columns:
if col.column_name == table.main_dttm_col:
dttm_col = col
@@ -1679,6 +1684,164 @@ def test_data_cache_default_timeout(
assert rv.json["result"][0]["cache_timeout"] == 3456
+def _native_filter_options_config(
+ native_filter_timeout: int | None = None,
+ data_cache_timeout: int = 3456,
+) -> dict[str, Any]:
+ """
+ Build a patched config for native filter option cache timeout tests.
+ """
+ config = {
+ **app.config,
+ "CACHE_DEFAULT_TIMEOUT": 100_000,
+ "DATA_CACHE_CONFIG": {
+ **app.config["DATA_CACHE_CONFIG"],
+ "CACHE_DEFAULT_TIMEOUT": data_cache_timeout,
+ },
+ }
+ if native_filter_timeout is not None:
+ config["NATIVE_FILTER_OPTIONS_CACHE_TIMEOUT"] = native_filter_timeout
+ else:
+ config.pop("NATIVE_FILTER_OPTIONS_CACHE_TIMEOUT", None)
+ return config
+
+
+_NATIVE_FILTER_SELECT_FORM_DATA: dict[str, Any] = {
+ "native_filter_id": "NATIVE_FILTER-abc123",
+ "viz_type": "filter_select",
+ "metrics": ["count"], # CRITICAL — always present in real requests
+ "groupby": ["col1"],
+ "row_limit": 1000,
+}
+
+
[email protected](
+ "superset.common.query_context_processor.current_app.config",
+ _native_filter_options_config(native_filter_timeout=None,
data_cache_timeout=3456),
+)
+def test_native_filter_default_uses_data_cache_timeout(
+ test_client: FlaskClient[Any],
+ login_as_admin: Any,
+ physical_query_context: dict[str, Any],
+) -> None:
+ physical_query_context["form_data"] =
copy.deepcopy(_NATIVE_FILTER_SELECT_FORM_DATA)
+ rv = test_client.post(CHART_DATA_URI, json=physical_query_context)
+ assert rv.json["result"][0]["cache_timeout"] == 3456
+
+
[email protected](
+ "superset.common.query_context_processor.current_app.config",
+ _native_filter_options_config(native_filter_timeout=9999,
data_cache_timeout=3456),
+)
+def test_native_filter_uses_native_filter_options_cache_timeout(
+ test_client: FlaskClient[Any],
+ login_as_admin: Any,
+ physical_query_context: dict[str, Any],
+) -> None:
+ physical_query_context["form_data"] =
copy.deepcopy(_NATIVE_FILTER_SELECT_FORM_DATA)
+ rv = test_client.post(CHART_DATA_URI, json=physical_query_context)
+ assert rv.json["result"][0]["cache_timeout"] == 9999
+
+
[email protected](
+ "superset.common.query_context_processor.current_app.config",
+ _native_filter_options_config(native_filter_timeout=300,
data_cache_timeout=3456),
+)
+def test_native_filter_overrides_dataset_timeout(
+ test_client: FlaskClient[Any],
+ login_as_admin: Any,
+ physical_query_context: dict[str, Any],
+) -> None:
+ datasource: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.id == physical_query_context["datasource"]["id"])
+ .first()
+ )
+ datasource.cache_timeout = 86400
+ db.session.commit()
+
+ physical_query_context["form_data"] =
copy.deepcopy(_NATIVE_FILTER_SELECT_FORM_DATA)
+ rv = test_client.post(CHART_DATA_URI, json=physical_query_context)
+ assert rv.json["result"][0]["cache_timeout"] == 300
+
+
[email protected](
+ "superset.common.query_context_processor.current_app.config",
+ _native_filter_options_config(native_filter_timeout=300,
data_cache_timeout=3456),
+)
+def test_standard_chart_uses_dataset_timeout(
+ test_client: FlaskClient[Any],
+ login_as_admin: Any,
+ physical_query_context: dict[str, Any],
+) -> None:
+ datasource: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.id == physical_query_context["datasource"]["id"])
+ .first()
+ )
+ datasource.cache_timeout = 86400
+ db.session.commit()
+
+ physical_query_context["form_data"] = {
+ "viz_type": "bar",
+ "metrics": ["count"],
+ "groupby": ["col1"],
+ }
+ rv = test_client.post(CHART_DATA_URI, json=physical_query_context)
+ assert rv.json["result"][0]["cache_timeout"] == 86400
+
+
[email protected](
+ "superset.common.query_context_processor.current_app.config",
+ _native_filter_options_config(
+ native_filter_timeout=CACHE_DISABLED_TIMEOUT, data_cache_timeout=3456
+ ),
+)
+def test_native_filter_cache_disabled_semantics(
+ test_client: FlaskClient[Any],
+ login_as_admin: Any,
+ physical_query_context: dict[str, Any],
+) -> None:
+ physical_query_context["form_data"] =
copy.deepcopy(_NATIVE_FILTER_SELECT_FORM_DATA)
+ test_client.post(CHART_DATA_URI, json=physical_query_context)
+ rv = test_client.post(CHART_DATA_URI, json=physical_query_context)
+ assert rv.json["result"][0]["is_cached"] is None
+
+
[email protected](
+ "superset.common.query_context_processor.current_app.config",
+ _native_filter_options_config(native_filter_timeout=9999,
data_cache_timeout=3456),
+)
+def test_false_positive_protection(
+ test_client: FlaskClient[Any],
+ login_as_admin: Any,
+ physical_query_context: dict[str, Any],
+) -> None:
+ physical_query_context["form_data"] = {
+ "native_filter_id": "TEST",
+ "viz_type": "table",
+ "metrics": ["count"],
+ "groupby": ["col1"],
+ }
+ rv = test_client.post(CHART_DATA_URI, json=physical_query_context)
+ assert rv.json["result"][0]["cache_timeout"] == 3456
+
+
[email protected](
+ "superset.common.query_context_processor.current_app.config",
+ _native_filter_options_config(native_filter_timeout=300,
data_cache_timeout=3456),
+)
+def test_explicit_custom_timeout_wins_over_native_filter(
+ test_client: FlaskClient[Any],
+ login_as_admin: Any,
+ physical_query_context: dict[str, Any],
+) -> None:
+ physical_query_context["form_data"] =
copy.deepcopy(_NATIVE_FILTER_SELECT_FORM_DATA)
+ physical_query_context["custom_cache_timeout"] = CACHE_DISABLED_TIMEOUT
+ rv = test_client.post(CHART_DATA_URI, json=physical_query_context)
+ assert rv.json["result"][0]["cache_timeout"] == CACHE_DISABLED_TIMEOUT
+
+
def test_chart_cache_timeout(
load_energy_table_with_slice: list[Slice], # noqa: F811
test_client,