Compare commits

...

1 Commits

Author SHA1 Message Date
Evan Rusackas
b03642df1d 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>
2026-04-27 10:32:02 -07:00
2 changed files with 68 additions and 1 deletions

View File

@@ -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

View File

@@ -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