You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/04/27 17:33:08 UTC
(superset) 01/01: fix(datasets): isolate filter state to fix concurrent /dataset race
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch ce/task-10
in repository https://gitbox.apache.org/repos/asf/superset.git
commit b03642df1d35cf2a7b821619814d6137e98ce8fc
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Mon Apr 27 10:32:02 2026 -0700
fix(datasets): isolate filter state to fix concurrent /dataset race
Override _handle_filters_args on DatasetRestApi to build a fresh
Filters instance per request. The FAB default mutates a single
self._filters shared across requests, which under concurrent traffic
leaks filters from one request into another (#33828).
Adopts #33895 with review-comment cleanups (drop unused imports, fix
docstring) and adds a regression test.
Co-Authored-By: Michelle <MIKEX818>
---
superset/datasets/api.py | 31 ++++++++++++++++++++++++++-
tests/unit_tests/datasets/api_tests.py | 38 ++++++++++++++++++++++++++++++++++
2 files changed, 68 insertions(+), 1 deletion(-)
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index c44a8ba2e37..4a77b4ebf1a 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -26,7 +26,12 @@ from zipfile import is_zipfile, ZipFile
from flask import request, Response, send_file
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
from flask_appbuilder.api.schemas import get_item_schema
-from flask_appbuilder.const import API_RESULT_RES_KEY, API_SELECT_COLUMNS_RIS_KEY
+from flask_appbuilder.const import (
+ API_FILTERS_RIS_KEY,
+ API_RESULT_RES_KEY,
+ API_SELECT_COLUMNS_RIS_KEY,
+)
+from flask_appbuilder.models.filters import Filters
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
from jinja2.exceptions import TemplateSyntaxError
@@ -307,6 +312,30 @@ class DatasetRestApi(BaseSupersetModelRestApi):
list_outer_default_load = True
show_outer_default_load = True
+ def _handle_filters_args(self, rison_args: dict[str, Any]) -> Filters:
+ """
+ Build a request-scoped ``Filters`` instance from Rison-encoded args.
+
+ Overrides :meth:`flask_appbuilder.api.ModelRestApi._handle_filters_args`,
+ which mutates ``self._filters`` (a single instance shared across requests).
+ Under concurrent traffic that shared state can leak filters from one
+ request into another — e.g. two parallel ``GET /api/v1/dataset/`` calls
+ filtering by different ``table_name`` values can return mixed results.
+
+ Returning a fresh ``Filters`` per call keeps each request isolated.
+
+ :param rison_args: Arguments parsed from the API request's Rison-encoded
+ ``q`` parameter.
+ :returns: A request-scoped ``Filters`` instance joined with the API's
+ base filters.
+ """
+ filters = self.datamodel.get_filters(
+ search_columns=self.search_columns,
+ search_filters=self.search_filters,
+ )
+ filters.rest_add_filters(rison_args.get(API_FILTERS_RIS_KEY, []))
+ return filters.get_joined_filters(self._base_filters)
+
@expose("/", methods=("POST",))
@protect()
@safe
diff --git a/tests/unit_tests/datasets/api_tests.py b/tests/unit_tests/datasets/api_tests.py
index 82e8453c878..88701c76961 100644
--- a/tests/unit_tests/datasets/api_tests.py
+++ b/tests/unit_tests/datasets/api_tests.py
@@ -120,3 +120,41 @@ def test_get_dataset_include_rendered_sql_passes_table_to_template_processor(
assert response.status_code == 200
mock_get_processor.assert_called_once_with(database=database, table=dataset)
+
+
+def test_handle_filters_args_returns_request_scoped_filters(
+ session: Session,
+ client: Any,
+ full_api_access: None,
+) -> None:
+ """
+ Dataset API: ``_handle_filters_args`` must return a fresh ``Filters``
+ instance per call so concurrent requests don't share filter state.
+
+ Regression test for #33828: under concurrent traffic the FAB default
+ implementation mutates ``self._filters`` (a single shared instance),
+ causing filters from one request to leak into another.
+ """
+ from flask_appbuilder.const import API_FILTERS_RIS_KEY
+
+ from superset.datasets.api import DatasetRestApi
+
+ api = DatasetRestApi()
+ api.datamodel = MagicMock()
+ api.search_columns = ["table_name"]
+ api.search_filters = {}
+ api._base_filters = MagicMock() # noqa: SLF001
+
+ # Each call should construct a fresh Filters instance via datamodel.get_filters
+ rison_args = {
+ API_FILTERS_RIS_KEY: [{"col": "table_name", "opr": "eq", "value": "a"}],
+ }
+ api._handle_filters_args(rison_args) # noqa: SLF001
+ api._handle_filters_args(rison_args) # noqa: SLF001
+
+ assert api.datamodel.get_filters.call_count == 2
+ # Returned object must be the joined-filters result of the *fresh* Filters,
+ # not the shared self._filters attribute.
+ fresh_filters = api.datamodel.get_filters.return_value
+ assert fresh_filters.rest_add_filters.call_count == 2
+ assert fresh_filters.get_joined_filters.call_count == 2