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 afb863684b1 feat(cache): add DATA_CACHE_MAX_VALUE_SIZE to skip caching
oversized results (#42570)
afb863684b1 is described below
commit afb863684b1f23ae11c0db7058c110d75e51a748
Author: Luiz Otavio <[email protected]>
AuthorDate: Wed Jul 29 15:35:42 2026 -0300
feat(cache): add DATA_CACHE_MAX_VALUE_SIZE to skip caching oversized
results (#42570)
---
docs/admin_docs/configuration/cache.mdx | 15 +++
superset/config.py | 9 ++
superset/utils/cache.py | 21 +++
tests/unit_tests/utils/cache_test.py | 225 ++++++++++++++++++++++++++++++++
4 files changed, 270 insertions(+)
diff --git a/docs/admin_docs/configuration/cache.mdx
b/docs/admin_docs/configuration/cache.mdx
index d4a5f36d087..ba6f842e6aa 100644
--- a/docs/admin_docs/configuration/cache.mdx
+++ b/docs/admin_docs/configuration/cache.mdx
@@ -76,6 +76,21 @@ value defined in `DATA_CACHE_CONFIG`.
Note, that by setting the cache timeout to `-1`, caching for charting data can
be disabled, either
per chart, dataset or database, or by default if set in `DATA_CACHE_CONFIG`.
+## Limiting Cached Result Size
+
+Very large chart or SQL query results can flood the cache backend
(Redis/Memcached), evicting many
+smaller useful entries or exhausting memory. To cap the size of any single
value written to the data
+cache, set `DATA_CACHE_MAX_VALUE_SIZE` (in bytes) in `superset_config.py`:
+
+```python
+DATA_CACHE_MAX_VALUE_SIZE = 10 * 1024 * 1024 # 10 MB
+```
+
+When a result's serialized size exceeds this threshold it is not written to
the data cache — the
+chart still renders, but the next load re-queries the datasource instead of
getting a cache hit. The
+`skip_cache_value_too_large` statsd metric is incremented each time this
happens. Set to `None` (the
+default) to disable the check.
+
## SQL Lab Query Results
Caching for SQL Lab query results is used when async queries are enabled and
is configured using
diff --git a/superset/config.py b/superset/config.py
index d77c26c1002..70c932f611f 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1350,6 +1350,15 @@ CACHE_CONFIG: CacheConfig = {"CACHE_TYPE": "NullCache"}
# Cache for datasource metadata and query results
DATA_CACHE_CONFIG: CacheConfig = {"CACHE_TYPE": "NullCache"}
+# Upper bound, in bytes, on the serialized size of a single value written to
the
+# data cache (chart and SQL query results). When a result's pickled size
exceeds
+# this threshold the value is NOT written to the cache: the chart still
renders,
+# but the next load re-queries the datasource instead of getting a cache hit.
This
+# protects the cache backend (e.g. Redis/Memcached) from being flooded by very
+# large result sets. Set to ``None`` to disable the check (the default).
Example:
+# 10 * 1024 * 1024 for a 10 MB limit.
+DATA_CACHE_MAX_VALUE_SIZE: int | None = None
+
# Cache for dashboard filter state. `CACHE_TYPE` defaults to
`SupersetMetastoreCache`
# that stores the values in the key-value table in the Superset metastore, as
it's
# required for Superset to operate correctly, but can be replaced by any
diff --git a/superset/utils/cache.py b/superset/utils/cache.py
index d138a7e9326..161b40832d2 100644
--- a/superset/utils/cache.py
+++ b/superset/utils/cache.py
@@ -18,6 +18,7 @@ from __future__ import annotations
import inspect
import logging
+import pickle
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Any, Callable
@@ -77,6 +78,26 @@ def set_and_log_cache(
datetime.now(timezone.utc).replace(tzinfo=None).isoformat().split(".")[0]
)
value = {**cache_value, "dttm": dttm}
+
+ # Skip caching results that are too large to protect the cache backend
+ # (e.g. Redis/Memcached) from being flooded by huge result sets. The
chart
+ # still renders; the value is simply not cached, causing a re-query on
the
+ # next load instead of a cache hit. Disabled when
DATA_CACHE_MAX_VALUE_SIZE
+ # is None (the default), in which case no serialization overhead is
incurred.
+ max_value_size = app.config.get("DATA_CACHE_MAX_VALUE_SIZE")
+ if max_value_size is not None:
+ value_size = len(pickle.dumps(value,
protocol=pickle.HIGHEST_PROTOCOL))
+ if value_size > max_value_size:
+ logger.warning(
+ "Skipping cache set for key %s: serialized value size %d
bytes "
+ "exceeds DATA_CACHE_MAX_VALUE_SIZE (%d bytes)",
+ cache_key,
+ value_size,
+ max_value_size,
+ )
+ app.config["STATS_LOGGER"].incr("skip_cache_value_too_large")
+ return
+
cache_instance.set(cache_key, value, timeout=timeout)
stats_logger = app.config["STATS_LOGGER"]
stats_logger.incr("set_cache_key")
diff --git a/tests/unit_tests/utils/cache_test.py
b/tests/unit_tests/utils/cache_test.py
index bd6179957e4..2d0d48e5983 100644
--- a/tests/unit_tests/utils/cache_test.py
+++ b/tests/unit_tests/utils/cache_test.py
@@ -17,6 +17,9 @@
# pylint: disable=import-outside-toplevel, unused-argument
+from typing import Any
+from unittest.mock import MagicMock
+
from pytest_mock import MockerFixture
@@ -49,3 +52,225 @@ def test_memoized_func(mocker: MockerFixture) -> None:
cache.get.return_value = 43
result = decorated(self, "public", cache=True)
assert result == 43
+
+
+def _make_cache_instance(mocker: MockerFixture) -> MagicMock:
+ """A cache instance whose ``.cache`` is not a ``NullCache``."""
+ cache_instance = mocker.MagicMock()
+ cache_instance.cache = object()
+ return cache_instance
+
+
+def _patch_config(mocker: MockerFixture, **overrides: Any) -> dict[str, Any]:
+ config = {
+ "CACHE_DEFAULT_TIMEOUT": 100,
+ "STATS_LOGGER": mocker.MagicMock(),
+ "STORE_CACHE_KEYS_IN_METADATA_DB": False,
+ "DATA_CACHE_MAX_VALUE_SIZE": None,
+ }
+ config.update(overrides)
+ mocker.patch("superset.utils.cache.app.config", config)
+ return config
+
+
+def test_set_and_log_cache_under_threshold(mocker: MockerFixture) -> None:
+ """A value under DATA_CACHE_MAX_VALUE_SIZE is cached normally."""
+ from superset.utils.cache import set_and_log_cache
+
+ config = _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=10 * 1024 * 1024)
+ cache_instance = _make_cache_instance(mocker)
+
+ set_and_log_cache(cache_instance, "my_key", {"df": "small"})
+
+ cache_instance.set.assert_called_once()
+ config["STATS_LOGGER"].incr.assert_any_call("set_cache_key")
+ assert (
+ mocker.call("skip_cache_value_too_large")
+ not in config["STATS_LOGGER"].incr.mock_calls
+ )
+
+
+def test_set_and_log_cache_over_threshold(mocker: MockerFixture) -> None:
+ """A value exceeding DATA_CACHE_MAX_VALUE_SIZE is not cached."""
+ from superset.utils.cache import set_and_log_cache
+
+ config = _patch_config(
+ mocker,
+ DATA_CACHE_MAX_VALUE_SIZE=10,
+ STORE_CACHE_KEYS_IN_METADATA_DB=True,
+ )
+ cache_instance = _make_cache_instance(mocker)
+ mock_session = mocker.patch("superset.utils.cache.db.session")
+
+ set_and_log_cache(
+ cache_instance,
+ "my_key",
+ {"df": "a value large enough to exceed the tiny threshold"},
+ datasource_uid="1__table",
+ )
+
+ cache_instance.set.assert_not_called()
+
config["STATS_LOGGER"].incr.assert_called_once_with("skip_cache_value_too_large")
+ assert mocker.call("set_cache_key") not in
config["STATS_LOGGER"].incr.mock_calls
+ mock_session.add.assert_not_called()
+
+
+def test_set_and_log_cache_disabled_no_serialization(mocker: MockerFixture) ->
None:
+ """When the limit is None (default), no pickling overhead is incurred."""
+ from superset.utils.cache import set_and_log_cache
+
+ _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=None)
+ cache_instance = _make_cache_instance(mocker)
+ mock_dumps = mocker.patch("superset.utils.cache.pickle.dumps")
+
+ set_and_log_cache(cache_instance, "my_key", {"df": "small"})
+
+ cache_instance.set.assert_called_once()
+ mock_dumps.assert_not_called()
+
+
+def test_set_and_log_cache_null_cache(mocker: MockerFixture) -> None:
+ """A NullCache backend short-circuits before any set."""
+ from flask_caching.backends import NullCache
+
+ from superset.utils.cache import set_and_log_cache
+
+ _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=10)
+ cache_instance = mocker.MagicMock()
+ cache_instance.cache = NullCache()
+
+ set_and_log_cache(cache_instance, "my_key", {"df": "small"})
+
+ cache_instance.set.assert_not_called()
+
+
+def test_set_and_log_cache_disabled_timeout(mocker: MockerFixture) -> None:
+ """A timeout of -1 (CACHE_DISABLED_TIMEOUT) short-circuits before any
set."""
+ from superset.utils.cache import set_and_log_cache
+
+ _patch_config(mocker)
+ cache_instance = _make_cache_instance(mocker)
+
+ set_and_log_cache(cache_instance, "my_key", {"df": "small"},
cache_timeout=-1)
+
+ cache_instance.set.assert_not_called()
+
+
+def test_set_and_log_cache_equal_threshold(mocker: MockerFixture) -> None:
+ """A value whose size EQUALS the threshold is still cached (guard is
``>``)."""
+ import pickle
+
+ from superset.utils.cache import set_and_log_cache
+
+ cache_value = {"df": "boundary"}
+ # Compute the exact serialized size the function will see, including the
+ # injected ``dttm`` field, so we can set the threshold to that exact value.
+ dttm = "2021-01-01T00:00:00"
+ value = {**cache_value, "dttm": dttm}
+ exact_size = len(pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL))
+
+ config = _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=exact_size)
+ cache_instance = _make_cache_instance(mocker)
+ # Pin the timestamp so the pickled size matches ``exact_size``
deterministically.
+ mock_datetime = mocker.patch("superset.utils.cache.datetime")
+ mock_datetime.now.return_value.replace.return_value.isoformat.return_value
= dttm
+
+ set_and_log_cache(cache_instance, "my_key", cache_value)
+
+ cache_instance.set.assert_called_once()
+ config["STATS_LOGGER"].incr.assert_any_call("set_cache_key")
+ assert (
+ mocker.call("skip_cache_value_too_large")
+ not in config["STATS_LOGGER"].incr.mock_calls
+ )
+
+
+def test_set_and_log_cache_over_threshold_no_datasource(mocker: MockerFixture)
-> None:
+ """Over-threshold with no datasource_uid: skipped, and no metadata-DB
write."""
+ from superset.utils.cache import set_and_log_cache
+
+ config = _patch_config(
+ mocker,
+ DATA_CACHE_MAX_VALUE_SIZE=10,
+ STORE_CACHE_KEYS_IN_METADATA_DB=True,
+ )
+ cache_instance = _make_cache_instance(mocker)
+ mock_session = mocker.patch("superset.utils.cache.db.session")
+
+ set_and_log_cache(
+ cache_instance,
+ "my_key",
+ {"df": "a value large enough to exceed the tiny threshold"},
+ )
+
+ cache_instance.set.assert_not_called()
+
config["STATS_LOGGER"].incr.assert_called_once_with("skip_cache_value_too_large")
+ mock_session.add.assert_not_called()
+
+
+def test_set_and_log_cache_over_threshold_warns(mocker: MockerFixture) -> None:
+ """The over-threshold branch emits a warning naming the key and sizes."""
+ from superset.utils.cache import set_and_log_cache
+
+ _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=10)
+ cache_instance = _make_cache_instance(mocker)
+ mock_logger = mocker.patch("superset.utils.cache.logger")
+
+ set_and_log_cache(
+ cache_instance,
+ "my_key",
+ {"df": "a value large enough to exceed the tiny threshold"},
+ )
+
+ mock_logger.warning.assert_called_once()
+ warning_args = mock_logger.warning.call_args.args
+ assert "exceeds DATA_CACHE_MAX_VALUE_SIZE" in warning_args[0]
+ assert "my_key" in warning_args
+
+
+def test_set_and_log_cache_under_threshold_metadata_db(mocker: MockerFixture)
-> None:
+ """Under-threshold with datasource_uid + metadata-DB storage writes a
CacheKey."""
+ from superset.utils.cache import set_and_log_cache
+
+ config = _patch_config(
+ mocker,
+ DATA_CACHE_MAX_VALUE_SIZE=10 * 1024 * 1024,
+ STORE_CACHE_KEYS_IN_METADATA_DB=True,
+ )
+ cache_instance = _make_cache_instance(mocker)
+ mock_session = mocker.patch("superset.utils.cache.db.session")
+ mock_cache_key = mocker.patch("superset.utils.cache.CacheKey")
+
+ set_and_log_cache(
+ cache_instance,
+ "my_key",
+ {"df": "small"},
+ cache_timeout=42,
+ datasource_uid="1__table",
+ )
+
+ cache_instance.set.assert_called_once()
+ config["STATS_LOGGER"].incr.assert_any_call("set_cache_key")
+ mock_cache_key.assert_called_once_with(
+ cache_key="my_key",
+ cache_timeout=42,
+ datasource_uid="1__table",
+ )
+ mock_session.add.assert_called_once_with(mock_cache_key.return_value)
+
+
+def test_set_and_log_cache_set_failure_logs(mocker: MockerFixture) -> None:
+ """A failure inside the try block is caught and logged as 'Could not cache
key'."""
+ from superset.utils.cache import set_and_log_cache
+
+ _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=None)
+ cache_instance = _make_cache_instance(mocker)
+ boom = RuntimeError("backend down")
+ cache_instance.set.side_effect = boom
+ mock_logger = mocker.patch("superset.utils.cache.logger")
+
+ # Should not raise despite the backend failure.
+ set_and_log_cache(cache_instance, "my_key", {"df": "small"})
+
+ mock_logger.warning.assert_called_once_with("Could not cache key %s",
"my_key")
+ mock_logger.exception.assert_called_once_with(boom)