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,

Reply via email to