You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by el...@apache.org on 2026/10/22 00:31:44 UTC

(superset) branch fix-cache-max-value-size-default updated (c3d465d4827 -> e00514eecf0)

This is an automated email from the ASF dual-hosted git repository.

eschutho pushed a change to branch fix-cache-max-value-size-default
in repository https://gitbox.apache.org/repos/asf/superset.git


    omit c3d465d4827 fix(async): leave contribution queries to the sync follow-up when totals are not cached
    omit f086c6e0bdd fix(cache): remove the older cached value when an oversized write is skipped
    omit 96dca118bce fix(cache): drop the async cache-persistence check; skip size check on NullCache
    omit 4c732660abe fix(cache): default DATA_CACHE_MAX_VALUE_SIZE to 10 MB and apply it to every data-cache writer
     add a23199762c5 fix(mypy): make UUIDMixin.uuid's nullable type resolve deterministically (#44817)
     add e734cd00d43 chore(deps): bump react-syntax-highlighter from 16.1.1 to 16.1.2 in /superset-frontend (#45370)
     add f2b71475ce2 chore(deps): bump @googleapis/sheets from 18.1.0 to 18.1.1 in /superset-frontend (#45369)
     add 3dfb42a2937 chore(deps): bump astral-sh/setup-uv from 10.2.0 to 10.3.0 (#45368)
     add 576cc9e0952 chore(deps): bump helm/chart-testing-action from 2.8.0 to 2.9.0 (#45367)
     add 50d34f8aaa2 chore(deps-dev): bump syntaqlite from 0.9.0 to 0.12.1 (#45320)
     add ad63b3eb7ab fix(impala): stop SQL Lab queries through the live cursor only (#45232)
     add 86e186b5f60 fix(tests): stop the unit suite from retaining one Superset app per module (#45364)
     add 19bb09461dc fix(examples): rewrite chart datasource references on import (#45378)
     add 9c7ca9ba107 fix(docs): improve Kapa AI widget dark mode compatibility (#39102)
     add 61d27b4aebe fix(sql): handle trailing comments after empty statements (#45274)
     add 58b1de82815 fix(datasets): don't call physical datasets "virtual" in sync columns toast (#45376)
     add 853f207c668 fix(dashboard): apply temporal x-axis cross-filters to target charts (#45310)
     add c650cbd3c61 fix(import): return 422 instead of 500 for corrupt ZIP uploads (#45380)
     add a9a7a35c4b4 fix(dashboard): show an access error for charts the viewer cannot read (#45398)
     add 44eac5b37b2 fix(soft-delete): align dashboard card dialogs and archive permission hints (#45302)
     add 21fad62645a fix(semantic-layer): resolve grain variants deterministically (#44454)
     add a13af92ff34 fix(semantic-layer): return 400 for mapper validation refusals (#45292)
     add a5d29a9d27d test(e2e): add a stub semantic-layer provider for Playwright (#45103)
     add 84be38cfc90 chore(deps): update pygithub requirement from >=2.1.1 to >=2.10.0 (#45413)
     add 31ee5719f77 fix(semantic layers): reject unknown compatibility selections (#45359)
     add e336cd04c3c fix(mcp): keep refusal and rejection categories in get_chart_data (#45360)
     add 5e1962e9a1d test(mcp): correct Dublin fold timestamp expectations (#45358)
     add ae71281c519 fix(dashboard): resolve to a fixed point when un-clipping scrollable chart content (#44102)
     add 4569608fc44 fix(pivot-table): keep MIN/MAX of non-numeric columns off the additive fast-path (#45408)
     add dfd13baab0e fix(versioning): don't record dataset column and metric extra rewrites as edits (#45349)
     add 4f49d24151d test(i18n): run the babel_update.sh msgcat tests on CI (#45247)
     add e837c37bc29 fix(semantic-layer): raise typed rejections from the E2E stub provider (#45401)
     add 4ccd337b932 chore(deps-dev): bump oracledb from 4.0.2 to 26.0.0 (#44443)
     add 297449d68e7 fix(semantic layers): return 400 for ambiguous variants in compatibility name check (#45426)
     add 663d691c311 fix(embedded): apply the chart-data guest redaction to samples and the legacy query endpoint (#45273)
     add acdd2d27667 chore(deps-dev): bump oxlint-tsgolint in /docs (#45450)
     add b8a4a5e20ad chore(deps): bump the swc group across 2 directories with 1 update (#45449)
     add c812be8019f chore(deps): bump EnricoMi/publish-unit-test-result-action from 2.24.0 to 2.25.0 (#45448)
     add 22654af3337 chore(deps): bump express from 5.2.1 to 5.3.0 in /superset-websocket/utils/client-ws-app (#45447)
     add 118acbcde35 chore(deps): bump pydantic from 2.13.5 to 2.14.0 (#45417)
     add 56ff8920385 fix(security): don't let dataset editorship substitute for data access (#45266)
     add 99ecfe5801c chore(deps): bump sqlalchemy-utils from 0.42.1 to 0.43.0 (#45416)
     add f0cfaff7881 fix(sql): decode async SQLExecutor results from the results backend (#45348)
     add 8e6e91e4dc0 feat(semantic-layers): add the provider execution context contract (#45251)
     add 53f14e7b7b1 fix(semantic-layer): refuse a chart's own filter on a column the semantic view lacks (#45294)
     add 98d5fa50b4c fix(semantic-layer): validate views before creation (#45287)
     add d36febc96ec fix(versioning): don't record save-path defaults as user changes (#45352)
     add 5a1831000a9 fix: add dblink and pg_* state-mutating functions to SQL denylist (#45089)
     add 88e92663b8f fix(semantic-layers): restore mocking compatibility after implementation became a plain property (#45501)
     add 152e1e9de9a fix(dataset): convert timezone-aware values with the dataset timezone (#45344)
     add 25e94d00d37 fix(pivot-table): stop the grand-total corner from silently picking one metric (#44657)
     add d285a1abae8 fix(dashboards): prevent horizontal filter bar from vanishing on cross-filter (#45050) (#45051)
     add 15f55686ea9 fix(dashboard): respect DASHBOARD_AUTO_REFRESH_INTERVALS configuration in refresh dropdown (#45039)
     add 1e18d01d76d feat(mcp): add caller-scoped PNG chart previews (#44558)
     add 9c287d989a8 feat(build): migrate to Typescript 6 (#44726)
     add 7b23ddea9f6 chore(build): migrate from external `copy-webpack-plugin` to Webpack-native `output.copy` (#44965)
     add 0a17a75f9a8 chore(deps): bump the storybook group across 2 directories with 4 updates (#45513)
     add d5bccecf581 chore(deps): bump chromaui/action from 18.11.0 to 18.12.1 (#45512)
     add 77b0fd62ea7 docs: add Plum to In the Wild (#45518)
     add 3cf94c87aef chore(deps-dev): bump @types/node from 26.6.4 to 26.6.5 in /superset-embedded-sdk (#45514)
     add 1df897b3bba fix(pivot-table): blank mixed-metric totals in exports and reports (#45311)
     add 38353434467 fix(cache): honor disabled datasource suggestion caches (#45288)
     add ad30c898f86 fix(dashboard): copy a layout that omits the source's current charts (#45472)
     add 3988a771f37 fix(dataset): warn immediately when normalize_columns is toggled on (#44617)
     add 3c42a68d439 test(mcp): refresh native tool inventory for PNG chart previews (#45511)
     add 1811fe5167f fix(dashboard): stop Edit Properties hanging on dashboards with many charts (#45354)
     add 65cfc1ed18c fix(deck.gl): set maplibre-gl 6's worker URL so vector basemaps load (#45396)
     add 973ac9b8efb fix(export): honor CSV_EXPORT encoding in streaming CSV exports (#45351)
     add a708a08cdb0 feat(semantic-layers): declare provider obligations and add a registration test fixture (#45393)
     add 488a3c53b45 fix(soft-delete): show Recently Archived in the menu to everyone who can open it (#45523)
     add 82a7ec91d6e feat(semantic-layers): apply a default chronological order for time-series queries (#45389)
     add fe79ab48e93 fix(tags): make owner-tag creation safe under concurrent creates (#45498)
     add e315ed365e8 test(reports): cover scheduled reports on semantic-view charts (#45526)
     add 036d1dafe2c fix(semantic-layers): report async chart query validation errors as failures (#45525)
     add d73c9b93398 fix(alerts): don't flag "Every minute" schedule as invalid in cron picker (#45468)
     add 63c6d273616 chore(plugin-chart-ag-grid-table): remove unused useTableTheme hook (#45469)
     add aa3df7caf77 fix(explore): hide empty Actual range for comparison header (#44996)
     add f0b43df751a fix(dashboard): remap native filter scopes when duplicating charts on save as (#45038)
     add b1fe28b5791 fix(ci): narrow nullable filters in metric_filter_test and resync oxfmt formatting (#45239)
     add 875dcbb1bb0 feat(action-log): open JSON payloads in a tree modal (#45337)
     add c01e784fc64 fix(ci): restore the full-repo mypy check on two semantic-layer and sqla test files (#45315)
     add 293f159deaf fix(sqllab): keep the results grid header opaque when rows scroll under it (#45480)
     add 25186586572 fix(chart): keep the chart on screen during a dashboard auto-refresh (#45234)
     add fcff5931594 fix(sqllab): ignore a Run/Stop click whose gesture started on the other action (#45336)
     add 87dc979e986 fix(table-chart): show the record count in the search box (#45497)
     add ec40973ae81 fix(semantic-layer): reject SQL extras in direct mapper calls (#45534)
     add ddbf152ac92 fix(dashboard): keep archived charts' membership when restoring a version (#45482)
     add 208438b18b6 test(tasks): cover the embedded-guest error text on task show and list (#45533)
     add 2d0203e3699 fix(plugin-chart-echarts): merge a series object in ECharts Options into every series (#45421)
     add 77ded19ba5c fix(sqllab): commit the recomputed row limit before async dispatch (#45178)
     add a1fd5b1f81e fix(sqllab): resolve query datasources when opening SQL Lab (#45374)
     add c06005afb1e fix(dashboard): load the saved description into dashboard info (#45298)
     add 2978799419e fix(models): keep NULL rows in !=, NOT LIKE and NOT ILIKE filters (#45317)
     add 33687492472 test(explore): cover chart header actions, chart pills, and view container wiring (#44848)
     add 304f5a38b58 test(playwright): add Featured Charts render smoke and Explore image download (#44844)
     add 6842e2f76b3 test(explore): cover cartodiagram and primitive explore controls (#44850)
     add c807ee7f909 fix(native-filters): keep pending removal timers when another filter is removed (#44837)
     add 7ff5709882f fix(candlestick): give OHLC labels their own translatable strings (#45458)
     add 0f376ba9f97 fix(semantic-layers): show per-view errors when adding semantic views (#45524)
     add 190873e9832 fix(version-history): preview banner, history panel layout, scope labels and current-version marking (#45471)
     add 6dc0641c91a fix(reports): keep the first column header in XLSX exports of semantic-view tables (#45527)
     add 4e7e2b7390d fix(tests): narrow Optional StatementResult.data in async result tests (#45517)
     add 836c0f3f95d fix(table): reset default margins on the pagination list (#45365)
     add 8d3ade7e0a8 test(trino): close db_engine_spec test gaps (#45361)
     add 3d902d9eb3a chore(deps): bump the security group across 2 directories with 2 updates (#45542)
     add 527021d5819 chore(deps): bump react-virtualized-auto-sizer from 1.0.26 to 2.0.3 in /superset-frontend (#45197)
     add c11f993af37 fix(auth): close forced-password-change redirect loop and sweep stale legacy password perms from all roles (#44857)
     add 7d0e1c939ee fix(frontend): retry a failed lazy import before surfacing ChunkLoadError (#43215)
     add abc1b50f65d fix(mcp): clean up nits from PNG chart preview review (#45509)
     add b6ef0bf48d8 chore(tags): stop auto-generating type:/editor:/favorited_by: tags (#43471)
     add cb04282f501 chore(deps): bump swagger-ui-react from 5.33.1 to 5.33.2 in /docs (#45567)
     add f1d8161777c chore(deps): bump chromaui/action from 18.12.1 to 18.12.2 (#45564)
     add f9aec7515b4 chore(deps): bump actions/setup-java from 6.0.1 to 6.1.0 in the gh-actions group (#45563)
     add 75be8f981a6 feat(improvements): let deployments withhold assets from list queries and commands (#45451)
     add f9a0d8af493 chore(deps-dev): bump oxlint from 1.87.0 to 1.88.0 in /docs (#45566)
     add fa8a035fcf6 fix(mcp): stop rejecting titles that contain "data:" in prose (#45485)
     add e8824351f19 fix(mcp): isolate dashboard copies and clarify chart evidence (#45504)
     add cdc9430597c fix(db_engine_specs): patch oceanbase_py's dialect for SQLAlchemy 2.0 (#45507)
     add 00688c7871b chore(deps-dev): update pinotdb requirement from <10.0.0,>=8.0.0 to >=9.2.2,<10.0.0 (#45414)
     add 73737afbfbb chore(deps): bump pyarrow from 25.0.1 to 26.0.0 (#45412)
     add 067d536e216 chore(deps-dev): bump fastmcp from 3.4.7 to 4.1.0 (#45415)
     add b7bfbd365e6 chore(deps-dev): bump oxfmt in /docs (#45565)
     add 2b1046959d2 chore: bump pillow, certifi and geopy pins, tidy two small code nits (#45578)
     add 54aaf3ad7d7 fix(mcp): surface missing query_context warning in get_chart_data result (#45250)
     add fdb22260e25 fix(number-format): make the adaptive formatters follow D3_FORMAT (#45241)
     add c3279f01921 ci: skip jobs that cannot complete on forks and private mirrors (#45035)
     add 385844237e6 fix(explore): preserve zero thresholds in contour controls (#44966)
     add 3de22376c5c fix(dashboards): update last modified timestamp when adding chart to dashboard (#44305) (#44358)
     add 77ebc0e55f3 fix(chart): keep dataset_uuid in exports when the dataset is deleted (#44347)
     add 452dea81f23 chore(deps-dev): bump @playwright/test from 1.62.1 to 1.64.0 in /superset-frontend (#45371)
     add bc83e3e6fdb fix(mcp): propose a matching value when a dashboard filter value is rejected (#45547)
     add b129d6a4c12 fix(semantic-layer): narrow Filter|OrFilter unions in test helpers for mypy (#45268)
     add ff214bdfb14 fix(sqllab): clip multi-line result values to their row (#45487)
     add 62d22e0697c fix(semantic-layer): type the E2E stub filters for the full filter union (#45419)
     add 5eaad449f5c fix(import): return 422 instead of 500 for unsafe ZIP import bundles (#45440)
     add 676af61b1c9 fix(jinja): keep filters with 0 or false values in get_filters and fi… (#45437)
     new a9827ed6a71 fix(cache): default DATA_CACHE_MAX_VALUE_SIZE to 10 MB and apply it to every data-cache writer
     new bce26e7085c fix(cache): drop the async cache-persistence check; skip size check on NullCache
     new 0417a0b593e fix(cache): remove the older cached value when an oversized write is skipped
     new e00514eecf0 fix(async): leave contribution queries to the sync follow-up when totals are not cached

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (c3d465d4827)
            \
             N -- N -- N   refs/heads/fix-cache-max-value-size-default (e00514eecf0)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .github/workflows/bashlib.sh                       |   13 +
 .github/workflows/bump-python-package.yml          |    2 +-
 .github/workflows/chromatic.yml                    |    2 +-
 .github/workflows/codeql-analysis.yml              |   19 +-
 .github/workflows/docker.yml                       |   25 +-
 .github/workflows/frontend-bundle-size-nightly.yml |    3 +
 .github/workflows/generate-FOSSA-report.yml        |    2 +-
 .github/workflows/github-action-validator.yml      |   14 +
 .github/workflows/license-check.yml                |    2 +-
 .github/workflows/pre-commit.yml                   |   14 +
 .github/workflows/python-nightly-canary.yml        |    9 +
 .../scripts/check-code-scanning-available.sh       |   64 +
 .../scripts/check-code-scanning-available.test.sh  |  118 ++
 .github/workflows/showtime-cleanup.yml             |    3 +
 .github/workflows/superset-docs-deploy.yml         |    2 +-
 .github/workflows/superset-docs-verify.yml         |    5 +
 .github/workflows/superset-e2e.yml                 |   14 +
 .github/workflows/superset-frontend.yml            |    3 +
 .github/workflows/superset-helm-lint-test.yml      |    2 +-
 .../workflows/superset-python-unittest-report.yml  |    2 +-
 .github/workflows/superset-python-unittest.yml     |   13 +
 .github/workflows/superset-translations.yml        |    9 +-
 .github/workflows/testcontainers.yml               |    7 +-
 .gitignore                                         |    2 +
 .pre-commit-config.yaml                            |   39 +-
 RELEASING/requirements.txt                         |    2 +-
 RESOURCES/INTHEWILD.yaml                           |    5 +
 UPDATING.md                                        |  237 +++-
 docs/admin_docs/configuration/cache.mdx            |   16 +
 docs/admin_docs/configuration/mcp-server.mdx       |   82 +-
 docs/admin_docs/security/security.mdx              |   63 +
 .../extensions/contribution-types.md               |   83 +-
 docs/developer_docs/extensions/development.md      |   37 +
 .../extensions/semantic-execution-context.md       |  102 ++
 docs/developer_docs/semantic-layer-cache-clear.md  |   17 +-
 docs/developer_docs/semantic-query-errors.md       |   21 +
 .../creating-your-first-dashboard.mdx              |   43 +-
 docs/docs/using-superset/exploring-data.mdx        |   13 +
 docs/docs/using-superset/recently-archived.mdx     |    4 +
 .../using-superset/semantic-layer-connections.mdx  |    6 +
 docs/docs/using-superset/version-history.mdx       |    8 +-
 docs/docusaurus.config.ts                          |   53 +-
 docs/package.json                                  |   14 +-
 docs/static/img/logos/plum.svg                     |  Bin 0 -> 4215 bytes
 docs/static/resources/openapi.json                 |    5 +
 docs/yarn.lock                                     |  756 +++++-----
 pyproject.toml                                     |   18 +-
 requirements/base.in                               |    2 +-
 requirements/base.txt                              |   18 +-
 requirements/development.in                        |    2 +-
 requirements/development.txt                       |   60 +-
 scripts/change_detector.py                         |    1 +
 superset-core/pyproject.toml                       |    5 +-
 .../src/superset_core/semantic_layers/context.py   |   62 +
 .../src/superset_core/semantic_layers/layer.py     |   45 +-
 .../src/superset_core/semantic_layers/testing.py   |   61 +
 .../src/superset_core/semantic_layers/types.py     |   42 +-
 .../src/superset_core/semantic_layers/view.py      |   25 +-
 .../tests/semantic_layers/example_provider.py      |  167 +++
 .../semantic_layers/test_execution_context.py      |   71 +
 .../tests/semantic_layers/test_registration.py     |  153 ++
 superset-embedded-sdk/package-lock.json            |   34 +-
 superset-embedded-sdk/package.json                 |    2 +-
 .../templates/frontend/package.json.j2             |    1 -
 superset-frontend/.storybook/main.mjs              |   10 +-
 superset-frontend/jest.config.js                   |    3 +-
 superset-frontend/package-lock.json                | 1238 +++++++---------
 superset-frontend/package.json                     |   25 +-
 .../packages/superset-core/package.json            |    2 +-
 .../packages/superset-core/tsconfig.json           |   18 +-
 .../src/operators/utils/extractExtraMetrics.ts     |    4 +-
 .../components/RadioButtonControl.tsx              |    9 +-
 .../src/utils/getTemporalColumns.ts                |    4 +-
 .../src/utils/metricColumnFilter.ts                |    4 +-
 .../superset-ui-chart-controls/tsconfig.json       |   18 +-
 .../packages/superset-ui-core/package.json         |    4 +-
 .../src/components/CronPicker/CronPicker.test.tsx  |   28 +-
 .../src/components/CronPicker/index.tsx            |    6 +-
 .../DropdownContainer/DropdownContainer.test.tsx   |   28 +
 .../src/components/DropdownContainer/types.ts      |    4 +-
 .../src/components/SafeMarkdown/SafeMarkdown.tsx   |    2 +-
 .../src/components/Typography/index.tsx            |    3 +-
 .../src/number-format/NumberFormatterRegistry.ts   |   28 +-
 .../factories/createSmartNumberFormatter.ts        |   56 +-
 .../number-format/NumberFormatterRegistry.test.ts  |   18 +
 .../factories/createSmartNumberFormatter.test.ts   |   14 +
 .../packages/superset-ui-core/tsconfig.json        |   18 +-
 .../packages/superset-ui-switchboard/tsconfig.json |   18 +-
 .../playwright/helpers/api/assertions.ts           |   16 +-
 .../playwright/helpers/api/dashboard.ts            |   28 +
 .../playwright/helpers/api/semanticLayer.test.ts   |   65 +
 .../playwright/helpers/api/semanticLayer.ts        |  136 ++
 .../playwright/helpers/fixtures/testAssets.ts      |   25 +-
 superset-frontend/playwright/pages/ExplorePage.ts  |   23 +-
 .../playwright/tests/chart/chart-list.spec.ts      |    4 +-
 .../tests/dashboard/dashboard-list.spec.ts         |    4 +-
 .../tests/dashboard/dashboard-test-helpers.ts      |   20 +-
 .../tests/dashboard/featured-charts-render.spec.ts |  234 +++
 .../dashboard/version-history-placement.spec.ts    |   39 +-
 .../playwright/tests/dataset/dataset-list.spec.ts  |    4 +-
 ...ults.spec.ts => explore-download-image.spec.ts} |   44 +-
 .../semantic-view/semantic-view-dashboard.spec.ts  |  204 +++
 .../tests/version-history/activity-log.spec.ts     |   14 +-
 .../src/utils/useTableTheme.ts                     |   42 -
 .../plugin-chart-ag-grid-table/tsconfig.json       |   18 +-
 .../plugins/plugin-chart-calendar/tsconfig.json    |   18 +-
 .../src/components/OlChartMap.tsx                  |   18 +-
 .../test/components/OlChartMap.test.tsx            |  108 ++
 .../plugin-chart-cartodiagram/tsconfig.json        |   18 +-
 .../plugin-chart-cartodiagram/types/external.d.ts  |    2 +
 .../plugins/plugin-chart-chord/tsconfig.json       |   18 +-
 .../plugins/plugin-chart-country-map/tsconfig.json |   18 +-
 .../src/Candlestick/constants.ts                   |   12 +-
 .../src/Candlestick/controlPanel.tsx               |    8 +-
 .../src/Timeseries/EchartsTimeseries.tsx           |    4 +
 .../src/utils/mergeCustomEChartOptions.test.ts     |   67 +
 .../src/utils/mergeCustomEChartOptions.ts          |   17 +
 .../test/Candlestick/controlPanel.test.ts          |   10 +
 .../test/Candlestick/transformProps.test.ts        |   17 +
 .../test/Rose/buildQuery.test.ts                   |   12 +-
 .../test/Timeseries/EchartsTimeseries.test.tsx     |    6 +
 .../test/Timeseries/transformProps.test.ts         |    4 +
 .../plugins/plugin-chart-echarts/tsconfig.json     |   18 +-
 .../plugins/plugin-chart-handlebars/tsconfig.json  |   18 +-
 .../plugins/plugin-chart-horizon/tsconfig.json     |   18 +-
 .../plugin-chart-paired-t-test/tsconfig.json       |   18 +-
 .../tsconfig.json                                  |   18 +-
 .../plugins/plugin-chart-partition/tsconfig.json   |   18 +-
 .../src/PivotTableChart.tsx                        |    2 +
 .../src/plugin/buildQuery.ts                       |   35 +-
 .../src/plugin/controlPanel.tsx                    |   54 +-
 .../src/plugin/resultAggregation.ts                |  121 ++
 .../src/plugin/transformProps.ts                   |   37 +-
 .../src/plugin/utilities.ts                        |   42 +-
 .../src/react-pivottable/TableRenderers.tsx        |    9 +-
 .../src/react-pivottable/utilities.ts              |  739 +++++++++-
 .../plugins/plugin-chart-pivot-table/src/types.ts  |    1 +
 .../test/PivotTableChart.test.tsx                  |   22 +
 .../test/plugin/buildQuery.test.ts                 |   21 +
 .../test/plugin/transformProps.test.ts             |   82 +-
 .../test/plugin/utilities.test.ts                  |   49 +-
 .../test/react-pivottable/tableRenders.test.tsx    |   68 +-
 .../test/react-pivottable/utilities.test.ts        |  890 +++++++++++-
 .../plugins/plugin-chart-pivot-table/tsconfig.json |   18 +-
 .../src/MapLibre.tsx                               |   35 +-
 .../test/MapLibre.test.tsx                         |   69 +-
 .../plugin-chart-point-cluster-map/tsconfig.json   |   16 +-
 .../types/external.d.ts                            |    2 +
 .../plugins/plugin-chart-table/src/Styles.tsx      |    4 +
 .../plugins/plugin-chart-table/src/TableChart.tsx  |    7 +-
 .../plugin-chart-table/test/TableChart.test.tsx    |  129 +-
 .../plugins/plugin-chart-table/tsconfig.json       |   18 +-
 .../plugins/plugin-chart-word-cloud/tsconfig.json  |   18 +-
 .../plugins/plugin-chart-world-map/tsconfig.json   |   18 +-
 .../src/CategoricalDeckGLContainer.test.tsx        |  213 ++-
 .../src/CategoricalDeckGLContainer.tsx             |   73 +-
 .../src/DeckGLContainer.test.tsx                   |   28 +
 .../preset-chart-deckgl/src/DeckGLContainer.tsx    |   11 +
 .../preset-chart-deckgl/src/Multi/Multi.tsx        |   25 +-
 .../preset-chart-deckgl/src/Multi/MultiV1.test.tsx |   66 +-
 .../plugins/preset-chart-deckgl/src/Multi/index.ts |    2 +-
 .../src/Multi}/transformProps.test.ts              |   36 +-
 .../src/Multi}/transformProps.ts                   |   16 +-
 .../preset-chart-deckgl/src/factory.test.tsx       |  117 ++
 .../plugins/preset-chart-deckgl/src/factory.tsx    |   25 +
 .../preset-chart-deckgl/src/layers/Arc/Arc.test.ts |    6 +
 .../src/layers/Contour/Contour.test.ts             |   56 +
 .../src/layers/Contour/Contour.tsx                 |    2 +-
 .../src/layers/Polygon/Polygon.test.tsx            |  100 ++
 .../src/layers/Polygon/Polygon.tsx                 |   22 +
 .../plugins/preset-chart-deckgl/tsconfig.json      |   16 +-
 .../preset-chart-deckgl/types/external.d.ts        |    2 +
 .../types/global.d.ts                              |    2 +-
 superset-frontend/spec/helpers/runStopButton.ts    |   55 +
 .../src/SqlLab/actions/sqlLab.test.ts              |  251 ++++
 superset-frontend/src/SqlLab/actions/sqlLab.ts     |   36 +-
 .../SqlLab/components/ResultSet/ResultSet.test.tsx |   13 +-
 .../src/SqlLab/components/ResultSet/index.tsx      |   10 +-
 .../RunQueryActionButton.test.tsx                  |  545 ++++++-
 .../components/RunQueryActionButton/index.tsx      |   37 +-
 .../SqlLab/components/SqlEditor/SqlEditor.test.tsx |   17 +-
 .../src/SqlLab/components/SqlEditor/index.tsx      |   10 +-
 .../TableExploreTree/TableExploreTree.test.tsx     |   13 +-
 .../SqlLab/components/TableExploreTree/index.tsx   |   10 +-
 .../components/TablePreview/TablePreview.test.tsx  |   13 +-
 .../src/SqlLab/components/TablePreview/index.tsx   |    8 +-
 .../src/components/Chart/Chart.test.tsx            |   38 +
 superset-frontend/src/components/Chart/Chart.tsx   |    8 +
 .../Chart/ChartRenderer.refresh.test.tsx           |  302 ++++
 .../src/components/Chart/ChartRenderer.test.tsx    |   19 +
 .../src/components/Chart/ChartRenderer.tsx         |   90 +-
 .../src/components/Chart/chartAction.ts            |   34 +
 .../src/components/Chart/chartActions.test.ts      |   62 +
 .../FoldersEditor/FoldersEditor.test.tsx           |   17 +-
 .../components/Datasource/FoldersEditor/index.tsx  |   12 +-
 .../DatasourceEditor/DatasourceEditor.tsx          |   45 +-
 ...atasourceEditorNormalizeColumnsWarning.test.tsx |  146 ++
 .../src/components/Datasource/utils/index.ts       |   12 +-
 .../src/components/Datasource/utils/utils.test.tsx |   28 +-
 .../src/components/GridTable/GridTable.test.tsx    |   46 +-
 .../src/components/GridTable/index.tsx             |   19 +-
 .../src/components/JsonModal/JsonModal.test.tsx    |   18 +
 .../src/components/JsonModal/utils.ts              |   10 +-
 .../src/dashboard/actions/hydrate.test.ts          |   43 +
 superset-frontend/src/dashboard/actions/hydrate.ts |   32 +-
 .../components/AnchorLink/AnchorLink.test.tsx      |    2 +-
 .../DashboardBuilder/DashboardBuilder.test.tsx     |  137 +-
 .../DashboardBuilder/DashboardBuilder.tsx          |  155 +-
 .../DashboardBuilder/DashboardWrapper.tsx          |    2 +-
 .../src/dashboard/components/Header/index.tsx      |    4 +-
 .../PropertiesModal.manyCharts.test.tsx            |  107 ++
 .../dashboard/components/PropertiesModal/index.tsx |    9 +-
 .../PropertiesModal/sections/AccessSection.tsx     |    2 +-
 .../RefreshFrequencySelect.test.tsx                |   99 +-
 .../RefreshFrequency/RefreshFrequencySelect.tsx    |   43 +-
 .../src/dashboard/components/SliceAdder.test.tsx   |    2 +-
 .../src/dashboard/components/SliceAdder.tsx        |    8 +-
 .../components/gridComponents/Header/Header.tsx    |    2 +-
 .../DownloadMenuItems/DownloadMenuItems.test.tsx   |    4 +-
 .../FilterControls.overflow.test.tsx               |  269 ++++
 .../FilterBar/FilterControls/FilterControls.tsx    |    4 +-
 .../FilterBar/FilterControls/FilterValue.test.tsx  |   20 +
 .../FilterBar/FilterControls/FilterValue.tsx       |   12 +-
 .../hooks/useFilterOperations.test.ts              |  145 +-
 .../hooks/useItemStateManager.ts                   |   28 +-
 .../src/dashboard/containers/DashboardPage.tsx     |    5 +-
 superset-frontend/src/dashboard/types.ts           |    2 +-
 .../util/charts/getFormDataWithExtraFilters.ts     |    7 +-
 .../util/getFormDataWithExtraFilters.test.ts       |   49 +
 superset-frontend/src/embedded/index.tsx           |    5 +-
 .../src/explore/actions/saveModalActions.test.ts   |  212 +++
 .../src/explore/actions/saveModalActions.ts        |   14 +
 .../components/ChartPills.behavior.test.tsx        |  158 +++
 .../DatasourcePanel.real-autosizer.test.tsx        |    7 +-
 .../DatasourcePanel/DatasourcePanel.test.tsx       |   13 +-
 .../explore/components/DatasourcePanel/index.tsx   |   10 +-
 .../ExploreChartHeader/ExploreChartHeader.test.tsx |  289 ++++
 .../ExploreChartPanel/ExploreChartPanel.test.tsx   |   28 +-
 .../explore/components/ExploreChartPanel/index.tsx |    6 +
 .../ExploreViewContainer.test.tsx                  |  276 +++-
 .../components/ExploreViewContainer/index.tsx      |    6 +-
 .../components/LegacyAggregationAlert.test.tsx     |  302 ++++
 .../explore/components/LegacyAggregationAlert.tsx  |  115 ++
 .../RunQueryButton/RunQueryButton.test.tsx         |  361 ++++-
 .../explore/components/RunQueryButton/index.tsx    |    6 +-
 .../controls/ComparisonRangeLabel.test.tsx         |   38 +-
 .../components/controls/ComparisonRangeLabel.tsx   |   24 +-
 .../ConditionalFormattingControl.test.tsx          |  112 +-
 .../ConditionalFormattingControl.tsx               |   12 +-
 .../ContourControl/ContourControl.test.tsx         |  128 ++
 .../controls/ContourControl/ContourOption.tsx      |    8 +-
 .../ContourControl/ContourPopoverControl.tsx       |   17 +-
 .../index.test.tsx}                                |   39 +-
 .../DatasourceControl/DatasourceControl.test.tsx   |    2 +-
 .../controls/FixedOrMetricControl/index.tsx        |    2 +-
 .../components/controls/JSEditorControl.test.tsx   |    9 +-
 .../components/controls/JSEditorControl.tsx        |   10 +-
 .../LayerConfigsControl.test.tsx                   |  271 ++++
 .../MapViewControl/MapViewControl.test.tsx         |  134 ++
 .../MapViewControl/MapViewPopoverContent.test.tsx  |   78 +
 .../components/controls/SliderControl.test.tsx     |   71 +
 .../components/controls/SwitchControl.test.tsx     |   57 +
 .../controls/TimeRangeControl/index.test.tsx       |   55 +
 .../controls/VerticalRadioControl.test.tsx         |   83 ++
 .../controls/ViewQueryModalFooter.test.tsx         |  142 +-
 .../controls/VizTypeControl/VizTypeGallery.tsx     |   24 +-
 .../ZoomConfigControl/ZoomConfigControl.test.tsx   |  195 +--
 .../ZoomConfigControl/ZoomConfigsChart.test.tsx    |  178 +++
 superset-frontend/src/explore/constants.ts         |   10 +
 .../exploreUtils/legacyAggregationTag.test.ts      |  137 ++
 .../explore/exploreUtils/legacyAggregationTag.ts   |   93 ++
 superset-frontend/src/explore/types.ts             |    2 +
 .../src/features/charts/ChartCard.test.tsx         |   32 +
 .../src/features/charts/ChartCard.tsx              |   10 +-
 .../src/features/dashboards/DashboardCard.test.tsx |   47 +-
 .../src/features/dashboards/DashboardCard.tsx      |   14 +-
 .../src/features/home/DashboardTable.test.tsx      |   58 +-
 .../src/features/home/DashboardTable.tsx           |   31 +-
 .../src/features/home/RightMenu.test.tsx           |   76 +
 superset-frontend/src/features/home/RightMenu.tsx  |   16 +
 .../semanticViews/AddSemanticViewModal.test.tsx    |  251 +++-
 .../semanticViews/AddSemanticViewModal.tsx         |  121 +-
 .../src/features/semanticViews/creationResult.ts   |   90 ++
 superset-frontend/src/features/tags/tags.ts        |    2 +-
 .../src/features/versionHistory/ActionRow.tsx      |    5 +-
 .../versionHistory/CurrentVersionSection.tsx       |   12 +-
 .../DashboardVersionHistory.test.tsx               |    4 +-
 .../versionHistory/RelatedUpdateRow.test.tsx       |   27 +
 .../features/versionHistory/RelatedUpdateRow.tsx   |    5 +-
 .../features/versionHistory/SaveGroupItem.test.tsx |   88 +-
 .../src/features/versionHistory/SaveGroupItem.tsx  |   66 +-
 .../versionHistory/VersionHistoryPanel.test.tsx    |  139 +-
 .../versionHistory/VersionHistoryPanel.tsx         |   54 +-
 .../src/features/versionHistory/display.test.ts    |   75 +
 .../src/features/versionHistory/display.ts         |   61 +-
 .../versionHistory/useVersionActivity.test.ts      |    4 +-
 .../src/hooks/apiResources/dashboards.test.ts      |   48 +
 .../src/hooks/apiResources/dashboards.ts           |    1 +
 superset-frontend/src/hooks/useRunStopButton.ts    |  181 +++
 .../src/pages/ActionLog/ActionLog.test.tsx         |   76 +
 superset-frontend/src/pages/ActionLog/index.tsx    |   67 +-
 .../pages/ChartList/ChartList.permissions.test.tsx |   27 +-
 superset-frontend/src/pages/ChartList/index.tsx    |   10 +-
 .../DashboardList/DashboardList.cardview.test.tsx  |   77 +-
 .../DashboardList.permissions.test.tsx             |   34 +-
 .../src/pages/DashboardList/index.tsx              |   31 +-
 .../DatasetList/DatasetList.permissions.test.tsx   |   30 +
 superset-frontend/src/pages/DatasetList/index.tsx  |   10 +-
 superset-frontend/src/types/Dashboard.ts           |    1 +
 superset-frontend/src/types/bootstrapTypes.ts      |    1 +
 superset-frontend/src/utils/lazyWithRetry.test.tsx |  140 ++
 superset-frontend/src/utils/lazyWithRetry.ts       |  135 ++
 superset-frontend/src/views/routes.tsx             |   83 +-
 superset-frontend/tsconfig.json                    |   18 +-
 superset-frontend/webpack.config.js                |   36 +-
 superset-websocket/package-lock.json               |  388 ++---
 superset-websocket/package.json                    |    8 +-
 .../utils/client-ws-app/package-lock.json          |  147 +-
 .../utils/client-ws-app/package.json               |    2 +-
 superset/app.py                                    |   36 +-
 superset/charts/api.py                             |    7 +-
 superset/charts/client_processing.py               |  302 +++-
 superset/charts/data/api.py                        |   48 +-
 superset/charts/data/dashboard_filter_context.py   |   22 +
 superset/charts/filters.py                         |    8 +
 superset/cli/update.py                             |   17 -
 superset/commands/chart/create.py                  |    4 +
 superset/commands/chart/data/get_data_command.py   |   37 +-
 superset/commands/chart/exceptions.py              |    4 +
 superset/commands/chart/export.py                  |   34 +-
 superset/commands/chart/update.py                  |   19 +
 superset/commands/chart/utils.py                   |   39 +
 superset/commands/dashboard/export.py              |   51 +-
 superset/commands/dataset/create.py                |   69 +-
 superset/commands/dataset/duplicate.py             |   48 +-
 superset/commands/dataset/exceptions.py            |   46 +
 superset/commands/dataset/importers/v1/utils.py    |  194 ++-
 superset/commands/dataset/update.py                |  127 +-
 superset/commands/export/assets.py                 |   75 +-
 superset/commands/export/models.py                 |   18 +-
 superset/commands/importers/v1/examples.py         |    8 +-
 superset/commands/importers/v1/utils.py            |   57 +-
 superset/commands/report/base.py                   |   73 +
 superset/commands/semantic_layer/create.py         |   44 +-
 superset/commands/semantic_layer/utils.py          |    2 +-
 superset/commands/sql_lab/execute.py               |   12 +
 superset/commands/streaming_export/base.py         |   34 +-
 superset/common/db_query_status.py                 |    6 +
 superset/common/form_data_query_context.py         |   30 +
 superset/common/query_context_factory.py           |   20 +-
 superset/common/query_context_processor.py         |   26 +-
 superset/common/query_object_factory.py            |   31 +-
 superset/common/tags.py                            |  496 -------
 superset/common/utils/time_range_utils.py          |   32 +-
 superset/config.py                                 |   52 +
 superset/daos/dashboard.py                         |  158 ++-
 superset/dashboards/api.py                         |   11 +-
 superset/dashboards/filters.py                     |    7 +
 superset/dashboards/schemas.py                     |    3 +
 superset/databases/api.py                          |    7 +-
 superset/datasets/api.py                           |    7 +-
 superset/datasource/api.py                         |  150 +-
 superset/db_engine_specs/impala.py                 |   47 +-
 superset/db_engine_specs/oceanbase.py              |   86 ++
 superset/db_engine_specs/trino.py                  |   33 +-
 superset/errors.py                                 |    1 +
 superset/exceptions.py                             |   27 +
 superset/extensions/metadb.py                      |    3 +-
 superset/importexport/api.py                       |    7 +-
 superset/initialization/__init__.py                |   27 +-
 superset/jinja_context.py                          |   21 +-
 superset/key_value/types.py                        |    3 +
 superset/mcp_service/README.md                     |   41 +
 superset/mcp_service/app.py                        |    4 +-
 superset/mcp_service/chart/chart_helpers.py        |    7 +
 .../chart/prompts/create_chart_guided.py           |    4 +-
 .../mcp_service/chart/resources/chart_configs.py   |   14 +-
 superset/mcp_service/chart/schemas.py              |  133 +-
 superset/mcp_service/chart/tool/get_chart_data.py  |  183 ++-
 .../mcp_service/chart/tool/get_chart_preview.py    |  145 +-
 superset/mcp_service/constants.py                  |    1 +
 superset/mcp_service/dashboard/layout_placement.py |   21 +-
 superset/mcp_service/dashboard/schemas.py          |  139 +-
 .../dashboard/tool/apply_dashboard_filters.py      |   34 +-
 .../dashboard/tool/duplicate_dashboard.py          |   14 +-
 .../dashboard/tool/get_dashboard_data.py           |   96 +-
 superset/mcp_service/dashboard_scope_sql.py        |    7 +-
 superset/mcp_service/database/schemas.py           |    8 +-
 superset/mcp_service/dataset/schemas.py            |    9 +-
 .../dataset/tool/update_dataset_metric.py          |    2 +-
 superset/mcp_service/mcp_config.py                 |    3 +
 superset/mcp_service/middleware.py                 |   54 +
 superset/mcp_service/report/schemas.py             |    4 +
 superset/mcp_service/tag/tool/get_tag_info.py      |    6 +-
 superset/mcp_service/tag/tool/list_tags.py         |    6 +-
 superset/mcp_service/utils/response_size_utils.py  |    4 +
 superset/mcp_service/utils/sanitization.py         |  114 +-
 superset/mcp_service/utils/schema_utils.py         |   19 +
 superset/mcp_service/utils/validation.py           |   89 +-
 ...141b8ada7731_tag_pivot_tables_with_restored_.py |  334 +++++
 ...ep_legacy_password_view_perms_from_all_roles.py |  120 ++
 superset/models/core.py                            |    5 +-
 superset/models/dashboard.py                       |   13 +
 superset/models/helpers.py                         |  175 ++-
 superset/queries/saved_queries/api.py              |    7 +-
 superset/reports/api.py                            |   15 +-
 superset/reports/filters.py                        |   37 +
 superset/result_set.py                             |    5 +
 superset/security/manager.py                       |   65 +-
 superset/security/password_change.py               |  101 +-
 superset/semantic_layers/api.py                    |    8 +-
 superset/semantic_layers/cache_host.py             |   19 +-
 superset/semantic_layers/cache_policy.py           |   14 +-
 superset/semantic_layers/cache_repository.py       |   14 +-
 superset/semantic_layers/cache_transform.py        |   17 +-
 superset/semantic_layers/dimension_resolution.py   |  117 ++
 superset/semantic_layers/execution.py              |  286 ++++
 superset/semantic_layers/mapper.py                 |  341 +++--
 superset/semantic_layers/models.py                 |  164 +--
 superset/semantic_layers/ordering.py               |   59 +
 superset/sql/execution/celery_task.py              |   79 +-
 superset/sql/execution/executor.py                 |  233 ++-
 superset/sql/execution/serialization.py            |  130 ++
 superset/sql/parse.py                              |   54 +-
 superset/sql_lab.py                                |   10 +-
 superset/sqllab/api.py                             |    4 +-
 superset/tags/core.py                              |   98 +-
 superset/tags/models.py                            |  311 +---
 superset/tasks/async_queries.py                    |   34 +-
 superset/tasks/schemas.py                          |    9 +
 superset/themes/api.py                             |    7 +-
 superset/translations/ar/LC_MESSAGES/messages.po   |  353 ++++-
 superset/translations/ca/LC_MESSAGES/messages.po   |  327 ++++-
 superset/translations/cs/LC_MESSAGES/messages.po   |  335 ++++-
 superset/translations/de/LC_MESSAGES/messages.po   |  327 ++++-
 superset/translations/en/LC_MESSAGES/messages.po   |  309 +++-
 superset/translations/es/LC_MESSAGES/messages.po   |  327 ++++-
 superset/translations/fa/LC_MESSAGES/messages.po   |  321 ++++-
 superset/translations/fi/LC_MESSAGES/messages.po   |  343 ++++-
 superset/translations/fr/LC_MESSAGES/messages.po   |  329 ++++-
 superset/translations/it/LC_MESSAGES/messages.po   |  349 ++++-
 superset/translations/ja/LC_MESSAGES/messages.po   |  323 ++++-
 superset/translations/ko/LC_MESSAGES/messages.po   |  336 ++++-
 superset/translations/lv/LC_MESSAGES/messages.po   |  335 ++++-
 superset/translations/messages.pot                 |  326 +++--
 superset/translations/mi/LC_MESSAGES/messages.po   |  327 ++++-
 superset/translations/nl/LC_MESSAGES/messages.po   |  321 ++++-
 superset/translations/pl/LC_MESSAGES/messages.po   |  324 ++++-
 superset/translations/pt/LC_MESSAGES/messages.po   |  349 ++++-
 .../translations/pt_BR/LC_MESSAGES/messages.po     |  321 ++++-
 superset/translations/ro/LC_MESSAGES/messages.po   |  338 ++++-
 superset/translations/ru/LC_MESSAGES/messages.po   |  336 ++++-
 superset/translations/sk/LC_MESSAGES/messages.po   |  335 ++++-
 superset/translations/sl/LC_MESSAGES/messages.po   |  343 ++++-
 superset/translations/sr/LC_MESSAGES/messages.po   |  357 ++++-
 .../translations/sr_Latn/LC_MESSAGES/messages.po   |  357 ++++-
 superset/translations/ta/LC_MESSAGES/messages.po   |  327 ++++-
 superset/translations/th/LC_MESSAGES/messages.po   |  335 ++++-
 superset/translations/tr/LC_MESSAGES/messages.po   |  322 ++++-
 superset/translations/uk/LC_MESSAGES/messages.po   |  343 ++++-
 superset/translations/zh/LC_MESSAGES/messages.po   |  313 +++-
 .../translations/zh_TW/LC_MESSAGES/messages.po     |  313 +++-
 superset/utils/core.py                             |   11 +-
 superset/utils/date_parser.py                      |    7 +-
 superset/utils/error_sanitization.py               |   38 +
 superset/utils/excel.py                            |    7 +-
 superset/utils/filters.py                          |  111 +-
 superset/utils/screenshot_utils.py                 |   34 +-
 superset/utils/webdriver.py                        |    4 +-
 superset/versioning/changes/shadow_queries.py      |    9 +-
 superset/versioning/diff.py                        |  124 +-
 superset/versioning/restore.py                     |   41 +-
 superset/views/api.py                              |    5 +
 superset/views/archived_assets.py                  |   18 +-
 superset/views/base.py                             |    4 +
 superset/views/datasource/views.py                 |   80 +-
 .../src/superset_e2e_semantic_stub/__init__.py     |  294 ++++
 .../semantic_stub/dist/manifest.json               |   10 +
 tests/integration_tests/charts/api_tests.py        |    8 +-
 tests/integration_tests/dashboards/api_tests.py    |  119 +-
 .../dashboards/version_restore_tests.py            |  358 +++++
 tests/integration_tests/datasource/api_tests.py    |  237 +++-
 tests/integration_tests/security_tests.py          |  113 +-
 ...ist_off.py => superset_test_config_semantic.py} |   24 +-
 tests/integration_tests/tagging_tests.py           |  294 ----
 tests/integration_tests/tags/dao_tests.py          |   94 +-
 tests/integration_tests/tags/race_tests.py         |   94 ++
 tests/integration_tests/tasks/api_tests.py         |   78 +-
 .../common => tests/mypy_fixtures}/__init__.py     |    8 +-
 tests/mypy_fixtures/uuid_mixin_nullable.py         |   44 +
 .../db_engine_specs/test_oceanbase.py              |   94 +-
 .../charts/commands/data/test_get_data_command.py  |   63 +-
 .../charts/commands/importers/v1/import_test.py    |   46 +
 .../charts/data/streaming_filename_test.py         |   18 +-
 tests/unit_tests/charts/test_client_processing.py  |  822 ++++++++++-
 tests/unit_tests/charts/test_filters.py            |  110 ++
 tests/unit_tests/commands/chart/create_test.py     |  252 +++-
 tests/unit_tests/commands/chart/export_test.py     |  576 ++++++++
 tests/unit_tests/commands/chart/update_test.py     |  266 +++-
 tests/unit_tests/commands/dashboard/export_test.py |    8 +-
 tests/unit_tests/commands/dataset/test_create.py   |  268 +++-
 .../unit_tests/commands/dataset/test_duplicate.py  |  136 +-
 tests/unit_tests/commands/dataset/update_test.py   |  475 ++++++-
 .../commands/importers/v1/examples_test.py         |   51 +
 .../unit_tests/commands/importers/v1/utils_test.py |  210 +++
 tests/unit_tests/commands/report/base_test.py      |  239 ++++
 .../unit_tests/commands/report/conftest.py         |    6 +-
 .../commands/report/semantic_view_report_test.py   |  232 +++
 .../commands/semantic_layer/create_test.py         |  148 +-
 .../commands/semantic_layer/update_test.py         |   12 +-
 tests/unit_tests/commands/sql_lab/test_estimate.py |   27 +
 tests/unit_tests/commands/sql_lab/test_execute.py  |   92 ++
 .../commands/streaming_export}/__init__.py         |    0
 .../commands/streaming_export/base_test.py         |   51 +
 .../common/test_query_context_factory.py           |   35 +
 .../common/test_query_context_processor.py         |   76 +-
 tests/unit_tests/common/test_time_range_utils.py   |   27 +
 tests/unit_tests/conftest.py                       |   53 +-
 tests/unit_tests/dao/dashboard_test.py             |  581 ++++++++
 tests/unit_tests/dao/query_stop_isolation_test.py  |  126 ++
 .../dao/test_copy_dashboard_legacy_ids.py          |   81 +-
 tests/unit_tests/daos/test_query_dao.py            |   21 +-
 tests/unit_tests/dashboards/datasets_test.py       |   86 ++
 .../dashboards/semantic_view_access_test.py        |  141 +-
 tests/unit_tests/databases/api_test.py             |   47 +-
 .../datasets/commands/importers/v1/import_test.py  |  608 +++++++-
 tests/unit_tests/datasource/test_compatible_api.py |   98 ++
 .../db_engine_specs/test_impala_pending.py         |  129 +-
 tests/unit_tests/db_engine_specs/test_oceanbase.py |  228 +++
 tests/unit_tests/db_engine_specs/test_trino.py     | 1104 +++++++++++++-
 tests/unit_tests/exceptions_test.py                |   46 +
 tests/unit_tests/importexport/api_test.py          |   86 +-
 tests/unit_tests/initialization_test.py            |    9 +-
 tests/unit_tests/jinja_context_test.py             |   74 +
 .../mcp_service/chart/test_bullet_chart.py         |   18 +-
 .../mcp_service/chart/test_bullet_tool_paths.py    |   24 +-
 .../mcp_service/chart/test_chart_examples.py       |   48 +
 .../mcp_service/chart/test_chart_helpers.py        |   25 +
 .../mcp_service/chart/test_chart_schemas.py        |   21 +
 .../chart/test_native_chart_regressions.py         |   24 +
 .../mcp_service/chart/test_new_chart_types.py      |   26 +-
 .../mcp_service/chart/tool/test_get_chart_data.py  |  693 ++++++++-
 .../chart/tool/test_get_chart_preview.py           |  453 ++++++
 .../common/test_list_scope_validation.py           |  105 ++
 .../dashboard/test_dashboard_schemas.py            |   39 +
 .../dashboard/tool/test_apply_dashboard_filters.py |   67 +-
 .../dashboard/tool/test_duplicate_dashboard.py     |   86 +-
 .../dashboard/tool/test_get_dashboard_data.py      |  507 +++++++
 .../dashboard/tool/test_list_dashboards.py         |   85 ++
 .../mcp_service/dataset/tool/test_query_dataset.py |   44 +-
 .../mcp_service/native_tool_inventory.json         |  112 +-
 .../test_compatibility_selection_validation.py     |    9 +-
 .../semantic_layer/tool/test_get_table.py          |   27 +-
 .../semantic_layer/tool/test_list_metrics.py       |   25 +-
 .../mcp_service/test_dashboard_scope_sql.py        |   24 +
 tests/unit_tests/mcp_service/test_mcp_config.py    |    9 +
 .../mcp_service/test_mcp_tool_registration.py      |   23 +
 tests/unit_tests/mcp_service/test_middleware.py    |    5 +-
 .../mcp_service/test_native_tool_surface.py        |   10 +-
 .../test_sanitization_warning_inputs.py            |  450 +++++-
 .../unit_tests/mcp_service/test_tool_inventory.py  |   21 +-
 .../mcp_service/test_validation_contract.py        |   42 +-
 .../mcp_service/utils/test_sanitization.py         |  163 +++
 .../test_sweep_legacy_password_view_perms.py       |  190 +++
 ...t_tag_pivot_tables_with_restored_aggregation.py |  461 ++++++
 tests/unit_tests/models/helpers_test.py            |  622 +++++++-
 tests/unit_tests/models/template_params_test.py    |   68 +
 tests/unit_tests/models/test_multivalue_filter.py  |  138 ++
 tests/unit_tests/reports/filters_test.py           |   53 +
 tests/unit_tests/scripts/change_detector_test.py   |   13 +
 .../scripts/translations/babel_update_test.py      |  105 +-
 tests/unit_tests/security/manager_test.py          |  174 +++
 tests/unit_tests/security/test_password_change.py  |   63 +
 .../unit_tests/security/test_password_recovery.py  |   12 +-
 tests/unit_tests/semantic_layers/api_test.py       |  123 +-
 .../unit_tests/semantic_layers/cache_host_test.py  |    6 +
 .../semantic_layers/cache_policy_test.py           |    3 +-
 .../semantic_layers/cache_transform_test.py        |    8 +-
 tests/unit_tests/semantic_layers/conftest.py       |  171 +++
 .../semantic_layers/default_order_test.py          |  376 +++++
 .../semantic_layers/dimension_resolution_test.py   |  161 +++
 .../semantic_layers/e2e_stub_extension_test.py     |  246 ++++
 .../semantic_layers/execution_context_test.py      | 1141 +++++++++++++++
 .../semantic_layers/grain_validation_test.py       |    6 +-
 .../semantic_layers/import_export_test.py          |    6 +-
 .../semantic_layers/integer_precision_test.py      |   14 +-
 tests/unit_tests/semantic_layers/mapper_test.py    |  989 +++++++++++--
 .../semantic_layers/metadata_contract_test.py      |   92 +-
 .../semantic_layers/metric_filter_test.py          |  143 +-
 tests/unit_tests/semantic_layers/models_test.py    |  252 +++-
 .../semantic_layers/query_errors_test.py           |  301 +++-
 .../semantic_layers/simple_cache_clear_test.py     |  106 +-
 .../semantic_layers/values_endpoint_test.py        |  212 +++
 tests/unit_tests/sql/execution/conftest.py         |    5 +-
 .../unit_tests/sql/execution/test_async_results.py | 1501 ++++++++++++++++++++
 tests/unit_tests/sql/execution/test_celery_task.py |  136 +-
 tests/unit_tests/sql/execution/test_executor.py    |   39 +
 tests/unit_tests/sql/parse_tests.py                |  110 ++
 tests/unit_tests/sqllab/api_test.py                |   24 +-
 tests/unit_tests/tags/models_test.py               |    6 +-
 tests/unit_tests/tags/race_test.py                 |   84 ++
 tests/unit_tests/tasks/test_async_queries.py       |  139 +-
 tests/unit_tests/tasks/test_schemas.py             |   62 +
 tests/unit_tests/test_app_reinitialization.py      |  114 ++
 tests/unit_tests/themes/api_test.py                |   33 +
 tests/unit_tests/utils/excel_tests.py              |   13 +
 tests/unit_tests/utils/filters_test.py             |  260 +++-
 tests/unit_tests/utils/test_core.py                |   20 +
 tests/unit_tests/utils/test_error_sanitization.py  |   94 ++
 tests/unit_tests/utils/test_screenshot_utils.py    |  134 ++
 .../versioning/test_dataset_child_extra.py         |  148 ++
 .../versioning/test_diff_save_defaults.py          |  264 ++++
 tests/unit_tests/versioning/test_shadow_queries.py |   79 ++
 tests/unit_tests/views/datasource/views_test.py    |  440 +++++-
 tests/unit_tests/views/test_archived_assets.py     |   86 ++
 tests/unit_tests/views/test_base.py                |   72 +
 616 files changed, 52429 insertions(+), 6762 deletions(-)
 create mode 100755 .github/workflows/scripts/check-code-scanning-available.sh
 create mode 100755 .github/workflows/scripts/check-code-scanning-available.test.sh
 create mode 100644 docs/developer_docs/extensions/semantic-execution-context.md
 create mode 100644 docs/static/img/logos/plum.svg
 create mode 100644 superset-core/src/superset_core/semantic_layers/context.py
 create mode 100644 superset-core/src/superset_core/semantic_layers/testing.py
 create mode 100644 superset-core/tests/semantic_layers/example_provider.py
 create mode 100644 superset-core/tests/semantic_layers/test_execution_context.py
 create mode 100644 superset-core/tests/semantic_layers/test_registration.py
 create mode 100644 superset-frontend/playwright/helpers/api/semanticLayer.test.ts
 create mode 100644 superset-frontend/playwright/helpers/api/semanticLayer.ts
 create mode 100644 superset-frontend/playwright/tests/dashboard/featured-charts-render.spec.ts
 copy superset-frontend/playwright/tests/explore/{explore-no-results.spec.ts => explore-download-image.spec.ts} (53%)
 create mode 100644 superset-frontend/playwright/tests/semantic-view/semantic-view-dashboard.spec.ts
 delete mode 100644 superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useTableTheme.ts
 create mode 100644 superset-frontend/plugins/plugin-chart-cartodiagram/test/components/OlChartMap.test.tsx
 create mode 100644 superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/resultAggregation.ts
 copy superset-frontend/plugins/{plugin-chart-parallel-coordinates/test => preset-chart-deckgl/src/Multi}/transformProps.test.ts (52%)
 copy superset-frontend/{src/chartCustomizations/components/DeckglLayerVisibility => plugins/preset-chart-deckgl/src/Multi}/transformProps.ts (72%)
 create mode 100644 superset-frontend/plugins/preset-chart-deckgl/src/factory.test.tsx
 create mode 100644 superset-frontend/plugins/preset-chart-deckgl/src/layers/Contour/Contour.test.ts
 copy superset-frontend/plugins/{plugin-chart-point-cluster-map => preset-chart-deckgl}/types/global.d.ts (92%)
 create mode 100644 superset-frontend/spec/helpers/runStopButton.ts
 create mode 100644 superset-frontend/src/components/Chart/ChartRenderer.refresh.test.tsx
 create mode 100644 superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditorNormalizeColumnsWarning.test.tsx
 create mode 100644 superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.manyCharts.test.tsx
 create mode 100644 superset-frontend/src/explore/components/ChartPills.behavior.test.tsx
 create mode 100644 superset-frontend/src/explore/components/LegacyAggregationAlert.test.tsx
 create mode 100644 superset-frontend/src/explore/components/LegacyAggregationAlert.tsx
 create mode 100644 superset-frontend/src/explore/components/controls/ContourControl/ContourControl.test.tsx
 copy superset-frontend/src/explore/components/controls/{DateFilterControl/tests/CurrentCalendarFrame.test.tsx => CustomListItem/index.test.tsx} (50%)
 create mode 100644 superset-frontend/src/explore/components/controls/LayerConfigsControl/LayerConfigsControl.test.tsx
 create mode 100644 superset-frontend/src/explore/components/controls/MapViewControl/MapViewControl.test.tsx
 create mode 100644 superset-frontend/src/explore/components/controls/MapViewControl/MapViewPopoverContent.test.tsx
 create mode 100644 superset-frontend/src/explore/components/controls/SliderControl.test.tsx
 create mode 100644 superset-frontend/src/explore/components/controls/SwitchControl.test.tsx
 create mode 100644 superset-frontend/src/explore/components/controls/TimeRangeControl/index.test.tsx
 create mode 100644 superset-frontend/src/explore/components/controls/VerticalRadioControl.test.tsx
 create mode 100644 superset-frontend/src/explore/components/controls/ZoomConfigControl/ZoomConfigsChart.test.tsx
 create mode 100644 superset-frontend/src/explore/exploreUtils/legacyAggregationTag.test.ts
 create mode 100644 superset-frontend/src/explore/exploreUtils/legacyAggregationTag.ts
 create mode 100644 superset-frontend/src/features/semanticViews/creationResult.ts
 create mode 100644 superset-frontend/src/hooks/useRunStopButton.ts
 create mode 100644 superset-frontend/src/utils/lazyWithRetry.test.tsx
 create mode 100644 superset-frontend/src/utils/lazyWithRetry.ts
 delete mode 100644 superset/common/tags.py
 create mode 100644 superset/migrations/versions/2026-09-25_00-00_141b8ada7731_tag_pivot_tables_with_restored_.py
 create mode 100644 superset/migrations/versions/2026-09-30_00-00_d623a0cb6bb0_sweep_legacy_password_view_perms_from_all_roles.py
 create mode 100644 superset/semantic_layers/dimension_resolution.py
 create mode 100644 superset/semantic_layers/execution.py
 create mode 100644 superset/semantic_layers/ordering.py
 create mode 100644 superset/sql/execution/serialization.py
 create mode 100644 tests/e2e_extensions/semantic_stub/dist/backend/src/superset_e2e_semantic_stub/__init__.py
 create mode 100644 tests/e2e_extensions/semantic_stub/dist/manifest.json
 copy tests/integration_tests/{superset_test_config_sqllab_backend_persist_off.py => superset_test_config_semantic.py} (52%)
 delete mode 100644 tests/integration_tests/tagging_tests.py
 create mode 100644 tests/integration_tests/tags/race_tests.py
 copy {superset-core/src/superset_core/common => tests/mypy_fixtures}/__init__.py (75%)
 create mode 100644 tests/mypy_fixtures/uuid_mixin_nullable.py
 create mode 100644 tests/unit_tests/commands/chart/export_test.py
 copy superset-extensions-cli/src/superset_extensions_cli/constants.py => tests/unit_tests/commands/report/conftest.py (82%)
 create mode 100644 tests/unit_tests/commands/report/semantic_view_report_test.py
 copy {scripts => tests/unit_tests/commands/streaming_export}/__init__.py (100%)
 create mode 100644 tests/unit_tests/commands/streaming_export/base_test.py
 create mode 100644 tests/unit_tests/dao/query_stop_isolation_test.py
 create mode 100644 tests/unit_tests/datasource/test_compatible_api.py
 create mode 100644 tests/unit_tests/exceptions_test.py
 create mode 100644 tests/unit_tests/mcp_service/common/test_list_scope_validation.py
 create mode 100644 tests/unit_tests/mcp_service/dashboard/tool/test_list_dashboards.py
 create mode 100644 tests/unit_tests/migrations/test_sweep_legacy_password_view_perms.py
 create mode 100644 tests/unit_tests/migrations/test_tag_pivot_tables_with_restored_aggregation.py
 create mode 100644 tests/unit_tests/models/template_params_test.py
 create mode 100644 tests/unit_tests/semantic_layers/default_order_test.py
 create mode 100644 tests/unit_tests/semantic_layers/dimension_resolution_test.py
 create mode 100644 tests/unit_tests/semantic_layers/e2e_stub_extension_test.py
 create mode 100644 tests/unit_tests/semantic_layers/execution_context_test.py
 create mode 100644 tests/unit_tests/sql/execution/test_async_results.py
 create mode 100644 tests/unit_tests/tags/race_test.py
 create mode 100644 tests/unit_tests/test_app_reinitialization.py
 create mode 100644 tests/unit_tests/versioning/test_dataset_child_extra.py
 create mode 100644 tests/unit_tests/versioning/test_diff_save_defaults.py
 create mode 100644 tests/unit_tests/views/test_archived_assets.py


(superset) 04/04: fix(async): leave contribution queries to the sync follow-up when totals are not cached

Posted by el...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

eschutho pushed a commit to branch fix-cache-max-value-size-default
in repository https://gitbox.apache.org/repos/asf/superset.git

commit e00514eecf06e74fb3365a93ee84953162b8ab35
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Wed Oct 7 20:59:33 2026 +0000

    fix(async): leave contribution queries to the sync follow-up when totals are not cached
    
    An async contribution chart runs its totals query as a prerequisite task
    and reads that task's result from the data cache. When the totals result
    is larger than DATA_CACHE_MAX_VALUE_SIZE it is not cached, so the
    contribution task raised "Contribution totals not found in cache" and the
    chart failed instead of reaching the client's synchronous follow-up
    request.
    
    When the totals are not in the cache, the contribution task now finishes
    without running or caching its query and without publishing a cache key.
    The synchronous follow-up computes the totals itself
    (ensure_totals_available) and runs the contribution query, so the chart
    renders. No contribution result computed without its totals is cached.
    
    Add a test that runs the real task body for the totals task and the
    dependent contribution task against a real cache, for totals that fit and
    totals that are too large.
    
    Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
 UPDATING.md                                        |   4 +-
 docs/admin_docs/configuration/cache.mdx            |   2 +
 superset/tasks/async_queries.py                    |  32 ++++--
 .../common/test_oversized_result_readback.py       | 114 ++++++++++++++++++++-
 4 files changed, 136 insertions(+), 16 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index dd7341b9399..2fc3ddc7e37 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -671,7 +671,9 @@ the next time it is requested. This includes the follow-up request a chart sends
 after a background (async) query finishes: that request recomputes the result
 instead of reading it from the cache, so an oversized async chart runs its query
 twice. That recompute happens inside the web request itself, so a very slow, very
-large query is subject to the web server's request timeout.
+large query is subject to the web server's request timeout. For an async chart
+that shows contributions (percent of total), if the totals result is too large to
+cache, the follow-up request computes the totals and the chart in the same way.
 
 The limit applies to every writer to the data cache: chart results, SQL query
 results from the SQL executor, filter dropdown column values, and compatible
diff --git a/docs/admin_docs/configuration/cache.mdx b/docs/admin_docs/configuration/cache.mdx
index a7b84823306..56104fe4a9d 100644
--- a/docs/admin_docs/configuration/cache.mdx
+++ b/docs/admin_docs/configuration/cache.mdx
@@ -198,6 +198,8 @@ returns its data, and the value is recomputed from the datasource the next time
 With `GLOBAL_ASYNC_QUERIES`, that includes the follow-up request a chart sends after its background
 query finishes, so an oversized async chart runs its query twice. That recompute happens inside the
 web request itself, so a very slow, very large query is subject to the web server's request timeout.
+For an async chart that shows contributions (percent of total), if the totals result is too large to
+cache, the follow-up request computes the totals and the chart in the same way.
 Each skip emits a
 WARNING log naming the key and byte size, and increments the `skip_cache_value_too_large` statsd
 metric. Set to `None` to disable the check entirely (no serialization overhead is then incurred).
diff --git a/superset/tasks/async_queries.py b/superset/tasks/async_queries.py
index 7b2dcc5f828..aacddc67ce7 100644
--- a/superset/tasks/async_queries.py
+++ b/superset/tasks/async_queries.py
@@ -177,7 +177,7 @@ def _resolve_user(user_id: int | None, guest_token: "GuestToken | None") -> User
 
 def _inject_contribution_totals(
     query_obj: "QueryObject", totals_cache_key: str
-) -> None:
+) -> bool:
     """Inject ``contribution_totals`` from the cached totals query into ``query_obj``.
 
     A contribution query normalizes its metrics against column sums from a separate
@@ -188,26 +188,24 @@ def _inject_contribution_totals(
     ``ensure_totals_available`` produces, but reading the cache the prerequisite
     populated instead of re-running the totals query. ``contribution_totals`` is
     stripped from the cache key, so this affects only the result, not the key.
+
+    :returns: ``False`` when the totals are not in the cache, in which case nothing
+        is injected. The totals task can succeed without caching its result: a value
+        larger than ``DATA_CACHE_MAX_VALUE_SIZE`` is not stored, and an entry can be
+        evicted before this task reads it.
     """
     from superset.common.query_context_processor import is_summable
     from superset.common.utils.query_cache_manager import QueryCacheManager
 
     cache = QueryCacheManager.get(key=totals_cache_key, region=CacheRegion.DATA)
     if not cache.is_loaded or cache.df is None:
-        # The depends_on prerequisite guarantees the totals task succeeded and wrote
-        # this cache entry, so a miss is unexpected (e.g. it was evicted between the
-        # totals task finishing and this task reading). Fail loudly rather than
-        # caching a silently un-normalized result the client would then re-request:
-        # this task's single query cannot reproduce the synchronous path's
-        # ensure_totals_available (it has no totals query to run).
-        raise SupersetException(
-            f"Contribution totals not found in cache under {totals_cache_key}"
-        )
+        return False
     df = cache.df
     totals = {col: df[col].sum() for col in df.columns if is_summable(df[col])}
     for post_processing in query_obj.post_processing or []:
         if post_processing.get("operation") == "contribution":
             post_processing.setdefault("options", {})["contribution_totals"] = totals
+    return True
 
 
 def _get_dependency_cache_key() -> str:
@@ -324,7 +322,19 @@ def execute_chart_query(
         # task reads back under the same id. Only consulted when force is true.
         query_obj.force_nonce = str(get_context().task_uuid)
         if requires_totals:
-            _inject_contribution_totals(query_obj, _get_dependency_cache_key())
+            totals_cache_key = _get_dependency_cache_key()
+            if not _inject_contribution_totals(query_obj, totals_cache_key):
+                # Without the totals this task cannot produce a correct result,
+                # and this query alone cannot recompute them. Finish without
+                # running or caching the query and without publishing a cache key:
+                # the client's synchronous follow-up request then computes the
+                # totals itself (``ensure_totals_available``) and runs this query.
+                logger.warning(
+                    "Contribution totals not found in cache under %s; "
+                    "leaving the query to the synchronous follow-up request",
+                    totals_cache_key,
+                )
+                return
         # Executes on cache miss and writes CacheRegion.DATA under query_cache_key.
         try:
             with _capture_query_cancellation(query_context):
diff --git a/tests/unit_tests/common/test_oversized_result_readback.py b/tests/unit_tests/common/test_oversized_result_readback.py
index 45f806e6655..7fb7878c43a 100644
--- a/tests/unit_tests/common/test_oversized_result_readback.py
+++ b/tests/unit_tests/common/test_oversized_result_readback.py
@@ -22,10 +22,13 @@ re-sends the same request synchronously. When the result is larger than
 nothing in the cache and runs the query again. These tests drive
 ``get_df_payload_result`` twice against a real in-memory cache -- once as the
 background task, once as the follow-up -- for a normal load and for a forced
-refresh whose follow-up carries the task's id as its ``force_nonce``.
+refresh whose follow-up carries the task's id as its ``force_nonce``. The
+contribution tests run the real async task body for a totals task and the
+contribution task that depends on it.
 """
 
 from collections.abc import Iterator
+from contextlib import nullcontext
 from typing import Any
 from unittest.mock import MagicMock
 
@@ -65,8 +68,11 @@ def data_cache(mocker: MockerFixture) -> Iterator[Cache]:
     cache.clear()
 
 
-def _processor(mocker: MockerFixture, rows: int, force: bool) -> Any:
-    """A processor whose datasource returns a ``rows``-row dataframe."""
+def _processor(
+    mocker: MockerFixture, rows: int, force: bool, cache_key: str = CACHE_KEY
+) -> Any:
+    """A processor whose datasource returns a ``rows``-row dataframe, cached
+    under ``cache_key``."""
     query_context = MagicMock()
     query_context.force = force
     query_context.force_nonce = None
@@ -85,7 +91,7 @@ def _processor(mocker: MockerFixture, rows: int, force: bool) -> Any:
     query_result.semantic_cache_status = None
     mocker.patch.object(processor, "get_query_result", return_value=query_result)
     mocker.patch.object(processor, "get_annotation_data", return_value={})
-    mocker.patch.object(processor, "query_cache_key", return_value=CACHE_KEY)
+    mocker.patch.object(processor, "query_cache_key", return_value=cache_key)
     mocker.patch.object(processor, "get_cache_timeout", return_value=300)
     return processor
 
@@ -171,3 +177,103 @@ def test_oversized_refresh_removes_older_cached_result(
     processor.get_query_result.assert_called_once()
     assert not payload["is_cached"]
     assert payload["rowcount"] == 2000
+
+
+def _run_chart_task(
+    mocker: MockerFixture,
+    processor: Any,
+    query_obj: Any,
+    requires_totals: bool = False,
+    dependency_payloads: list[dict[str, Any]] | None = None,
+) -> MagicMock:
+    """Run the real ``execute_chart_query`` task body for one query.
+
+    Only the worker plumbing (user, task context, form data, cancellation hook) is
+    stubbed; the query runs through ``processor`` against the real cache.
+
+    :returns: the task context, whose ``update_task`` records the published payload
+    """
+    from superset.tasks.async_queries import execute_chart_query
+
+    query_context = MagicMock()
+    query_context.queries = [query_obj]
+    query_context.get_df_payload_result.side_effect = processor.get_df_payload_result
+    task_context = MagicMock(task_uuid=TASK_ID)
+    task_context.get_dependency_payloads.return_value = dependency_payloads or []
+    mocker.patch("superset.tasks.async_queries._resolve_user", return_value=MagicMock())
+    mocker.patch("superset.tasks.async_queries.override_user")
+    mocker.patch(
+        "superset.tasks.async_queries.load_serialized_query",
+        return_value=query_context,
+    )
+    mocker.patch("superset.tasks.async_queries.get_context", return_value=task_context)
+    mocker.patch("superset.charts.data.form_data.set_query_context_form_data")
+    mocker.patch(
+        "superset.tasks.async_queries._capture_query_cancellation",
+        return_value=nullcontext(),
+    )
+    execute_chart_query.func(
+        {"datasource": {}, "query": {}},  # type: ignore[typeddict-item]
+        user_id=7,
+        requires_totals=requires_totals,
+    )
+    return task_context
+
+
+def _contribution_query_object() -> Any:
+    from superset.common.query_object import QueryObject
+
+    query_obj = QueryObject(
+        datasource=MagicMock(),
+        columns=["name"],
+        post_processing=[{"operation": "contribution", "options": {}}],
+    )
+    query_obj.force_nonce = None
+    return query_obj
+
+
+@pytest.mark.parametrize(
+    "totals_rows,totals_cached", [(2, True), (2000, False)], ids=["fits", "oversized"]
+)
+def test_contribution_task_after_totals_task(
+    mocker: MockerFixture, data_cache: Cache, totals_rows: int, totals_cached: bool
+) -> None:
+    """The async totals task feeds the contribution task through the data cache.
+
+    When the totals result fits, the contribution task reads it, runs, and
+    publishes its own cache key. When the totals result is too large to cache, the
+    contribution task must not fail: it leaves its query uncached and publishes
+    nothing, so the client's synchronous follow-up request (which computes the
+    totals itself in ``ensure_totals_available``) renders the chart.
+    """
+    totals = _processor(mocker, totals_rows, force=False, cache_key="totals-key")
+    totals_task = _run_chart_task(mocker, totals, _query_object(None))
+    published = totals_task.update_task.call_args.kwargs["payload"]
+    assert published == {"cache_key": "totals-key"}
+    assert (data_cache.get("totals-key") is not None) is totals_cached
+
+    contribution = _processor(mocker, 2, force=False, cache_key="contribution-key")
+    contribution_task = _run_chart_task(
+        mocker,
+        contribution,
+        _contribution_query_object(),
+        requires_totals=True,
+        dependency_payloads=[published],
+    )
+
+    if totals_cached:
+        contribution.get_query_result.assert_called_once()
+        contribution_task.update_task.assert_called_once_with(
+            payload={"cache_key": "contribution-key"}, immediate=True
+        )
+        assert data_cache.get("contribution-key") is not None
+        return
+
+    contribution.get_query_result.assert_not_called()
+    contribution_task.update_task.assert_not_called()
+    assert data_cache.get("contribution-key") is None
+    # The synchronous follow-up misses the cache and computes the query.
+    payload = contribution.get_df_payload_result(_contribution_query_object()).payload
+    contribution.get_query_result.assert_called_once()
+    assert payload["error"] is None
+    assert payload["rowcount"] == 2


(superset) 01/04: fix(cache): default DATA_CACHE_MAX_VALUE_SIZE to 10 MB and apply it to every data-cache writer

Posted by el...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

eschutho pushed a commit to branch fix-cache-max-value-size-default
in repository https://gitbox.apache.org/repos/asf/superset.git

commit a9827ed6a713794c9a33d4791d7b51bf095f490f
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Fri Sep 25 19:00:05 2026 +0000

    fix(cache): default DATA_CACHE_MAX_VALUE_SIZE to 10 MB and apply it to every data-cache writer
    
    - Default DATA_CACHE_MAX_VALUE_SIZE to 10 MB so a single oversized value
      cannot flood the data cache backend.
    - Extract the size check into exceeds_max_cache_value_size() and use it in
      set_and_log_cache and in the writers that bypassed it: the SQL executor's
      result cache and the datasource API's column-values and compatible
      metrics/dimensions caches. Each skip logs a WARNING and increments
      skip_cache_value_too_large.
    - Async chart queries: when the query succeeds but the result is not in the
      data cache (e.g. skipped for size), fail the task with a clear error instead
      of reporting success, which made the client resubmit indefinitely.
    - Update config comment, docs and UPDATING.md; add tests.
    
    Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
 UPDATING.md                                        |  20 +++
 docs/admin_docs/configuration/cache.mdx            |  14 +-
 superset/config.py                                 |  23 ++-
 superset/datasource/api.py                         |  14 +-
 superset/sql/execution/executor.py                 |   6 +
 superset/tasks/async_queries.py                    |  20 +++
 superset/utils/cache.py                            |  50 ++++--
 tests/unit_tests/datasource/cache_size_cap_test.py | 160 ++++++++++++++++++
 tests/unit_tests/sql/execution/test_executor.py    |  73 ++++++++
 tests/unit_tests/tasks/test_async_queries.py       |  61 +++++++
 tests/unit_tests/utils/cache_test.py               | 183 +++++++++++++++++++++
 11 files changed, 594 insertions(+), 30 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index dfc7fd18b6f..e7fe64fc79b 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -660,6 +660,26 @@ Parent and child snapshots are rebuilt in the save transaction after a captured
 edit. If that rebuild fails, the save fails and must be rolled back, so an
 incomplete snapshot is not exposed as restorable history.
 
+### `DATA_CACHE_MAX_VALUE_SIZE` now defaults to 10 MB
+
+The upper bound on the serialized size of a single value written to the data
+cache now defaults to `10 * 1024 * 1024` (10 MB) instead of `None`. Values whose
+serialized size exceeds the limit are no longer written to the data cache — the
+request still succeeds, but the next load re-queries the datasource instead of
+getting a cache hit. The limit applies to every data-cache writer: chart results,
+SQL query results from the SQL executor, filter dropdown column values, and
+compatible metrics/dimensions. Each skip emits a WARNING log
+naming the key and byte size and increments the `skip_cache_value_too_large`
+statsd counter. This protects the cache backend (e.g. Redis) from being driven
+toward its memory limit by a heavy tail of very large results. To restore the
+previous unbounded behavior, set `DATA_CACHE_MAX_VALUE_SIZE = None`; to allow
+larger cached results, raise the limit further.
+
+With `GLOBAL_ASYNC_QUERIES` enabled, a chart whose result exceeds the limit now
+fails with a clear error instead of being retried indefinitely, because async
+chart delivery depends on reading the result back from the data cache. Raise the
+limit, narrow the query, or disable async queries for such charts.
+
 ### Guest token RLS rules without a dataset apply inside sub-queries
 
 A guest token RLS rule with no `dataset` key applies to every dataset. Such
diff --git a/docs/admin_docs/configuration/cache.mdx b/docs/admin_docs/configuration/cache.mdx
index e30c454a9ff..805d49cd0d3 100644
--- a/docs/admin_docs/configuration/cache.mdx
+++ b/docs/admin_docs/configuration/cache.mdx
@@ -183,17 +183,19 @@ retention policy, it keeps async results in the cache longer and modestly increa
 ## 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`:
+smaller useful entries or exhausting memory. The size of any single value written to the data cache
+(chart and SQL query results, filter dropdown values, and compatible metrics/dimensions) is bounded by `DATA_CACHE_MAX_VALUE_SIZE` (in bytes), which defaults to `10 * 1024 * 1024` (10 MB). To
+change it, set it in `superset_config.py`:
 
 ```python
-DATA_CACHE_MAX_VALUE_SIZE = 10 * 1024 * 1024  # 10 MB
+DATA_CACHE_MAX_VALUE_SIZE = 20 * 1024 * 1024  # 20 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.
+chart still renders, but the next load re-queries the datasource instead of getting a cache hit. Each
+skip emits a WARNING log naming the key and byte size, and increments the `skip_cache_value_too_large`
+statsd metric. Set to `None` to disable the check entirely (no serialization overhead is then
+incurred).
 
 ## SQL Lab Query Results
 
diff --git a/superset/config.py b/superset/config.py
index 723c366e4c7..358cf89572c 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1505,13 +1505,22 @@ CACHE_CONFIG: CacheConfig = {"CACHE_TYPE": "NullCache"}
 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
+# data cache (chart and SQL query results, filter dropdown values, and compatible
+# metrics/dimensions). When a value's pickled size exceeds this threshold it is
+# NOT written to the cache: the request still succeeds, 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 a heavy tail of very
+# large result sets, which can drive the backend toward its memory limit and
+# evict many smaller, useful entries. Each skip emits a WARNING log naming the key
+# and byte size and increments the ``skip_cache_value_too_large`` statsd counter.
+# With GLOBAL_ASYNC_QUERIES, async chart delivery reads the result back from this
+# cache, so an oversized async chart result fails with a clear error instead.
+# The default of 10 MB comfortably exceeds typical chart/query payloads while
+# excluding the multi-tens-of-MB outliers responsible for cache pressure; raise it
+# if legitimate results are being skipped, or set it to ``None`` to disable the
+# check entirely (no serialization overhead is then incurred). Example:
+# 20 * 1024 * 1024 for 20 MB.
+DATA_CACHE_MAX_VALUE_SIZE: int | None = 10 * 1024 * 1024
 
 # Include per-query lifecycle timing in /api/v1/chart/data JSON responses.
 # The default keeps the public response contract unchanged.
diff --git a/superset/datasource/api.py b/superset/datasource/api.py
index d2f3dd62d17..9d38dd732c9 100644
--- a/superset/datasource/api.py
+++ b/superset/datasource/api.py
@@ -58,6 +58,7 @@ from superset.semantic_layers.mapper import SUPPORTED_FILTER_OPERATORS
 from superset.semantic_layers.models import SemanticView
 from superset.superset_typing import FlaskResponse
 from superset.utils import json
+from superset.utils.cache import exceeds_max_cache_value_size
 from superset.utils.core import (
     apply_max_row_limit,
     DatasourceType,
@@ -70,6 +71,15 @@ from superset.views.base_api import BaseSupersetApi, protect_read, statsd_metric
 
 logger = logging.getLogger(__name__)
 
+
+def _set_data_cache(cache_key: str, value: Any, timeout: int) -> None:
+    """Write ``value`` to the data cache unless it exceeds
+    ``DATA_CACHE_MAX_VALUE_SIZE``. An oversized value is left uncached, so the next
+    request misses and recomputes it from the datasource."""
+    if not exceeds_max_cache_value_size(cache_key, value):
+        cache_manager.data_cache.set(cache_key, value, timeout=timeout)
+
+
 # Cache lifetime for search-filtered column values, in seconds.
 SEARCH_CACHE_TIMEOUT = 60
 
@@ -189,7 +199,7 @@ def get_cached_column_values(
     # entry per keystroke for the full default timeout.
     timeout = min(timeout, SEARCH_CACHE_TIMEOUT) if search else timeout
     if timeout != CACHE_DISABLED_TIMEOUT:
-        cache_manager.data_cache.set(cache_key, payload, timeout=timeout)
+        _set_data_cache(cache_key, payload, timeout)
     logger.debug("column-values cache MISS: uid=%s col=%s", datasource.uid, column_name)
     return payload, False
 
@@ -760,7 +770,7 @@ class DatasourceRestApi(BaseSupersetApi):
             return self.response(400, message=str(ex))
 
         if timeout != CACHE_DISABLED_TIMEOUT:
-            cache_manager.data_cache.set(cache_key, result, timeout=timeout)
+            _set_data_cache(cache_key, result, timeout)
 
         return self.response(200, result=result)
 
diff --git a/superset/sql/execution/executor.py b/superset/sql/execution/executor.py
index 9aa7b262c2c..67431428625 100644
--- a/superset/sql/execution/executor.py
+++ b/superset/sql/execution/executor.py
@@ -87,6 +87,7 @@ from superset.sql.execution.serialization import deserialize_nested_columns
 from superset.sql.parse import SQLScript
 from superset.superset_typing import FetchedRows
 from superset.utils import core as utils, json
+from superset.utils.cache import exceeds_max_cache_value_size
 
 if TYPE_CHECKING:
     from superset_core.queries.types import (
@@ -1322,6 +1323,11 @@ class SQLExecutor:
             "total_execution_time_ms": result.total_execution_time_ms,
         }
 
+        # An oversized result is left uncached; ``_get_from_cache`` then misses and
+        # the query re-runs on the next request.
+        if exceeds_max_cache_value_size(cache_key, cached_data):
+            return
+
         cache_manager.data_cache.set(
             cache_key,
             cached_data,
diff --git a/superset/tasks/async_queries.py b/superset/tasks/async_queries.py
index 7b2dcc5f828..9004f438b32 100644
--- a/superset/tasks/async_queries.py
+++ b/superset/tasks/async_queries.py
@@ -40,6 +40,7 @@ from superset.exceptions import (
     SupersetException,
 )
 from superset.extensions import (
+    cache_manager,
     security_manager,
 )
 from superset.tasks.ambient_context import get_context
@@ -351,6 +352,25 @@ def execute_chart_query(
         ):
             raise SupersetException(result.payload["error"])
         if cache_key := result.payload.get(CACHE_KEY_PAYLOAD_KEY):
+            # Async delivery works by the client reading this result back from the
+            # DATA cache under ``cache_key``. When the query succeeded but the value
+            # was not persisted -- most commonly because its serialized size exceeds
+            # DATA_CACHE_MAX_VALUE_SIZE and the oversized-value skip dropped it -- the
+            # read-back would miss and the client would resubmit this task without
+            # end (its force_cached read raises CacheLoadError, which the chart-data
+            # endpoint treats as "not ready yet" and reschedules). Fail loudly here
+            # instead so the user sees a clear terminal error rather than an
+            # invisible retry loop. (Async is never scheduled against a NullCache
+            # DATA backend, so a missing entry here means a real skipped write.)
+            if not result.payload.get("error") and not cache_manager.data_cache.has(
+                cache_key
+            ):
+                raise SupersetException(
+                    "The query result could not be cached and cannot be returned "
+                    "asynchronously. This usually means it exceeds "
+                    "DATA_CACHE_MAX_VALUE_SIZE; narrow the query, raise the limit, "
+                    "or run the request without async."
+                )
             # Write synchronously: a dependent contribution query reads this
             # cache key via get_dependency_payloads once the DAG gate releases,
             # so it must not sit in the throttle buffer.
diff --git a/superset/utils/cache.py b/superset/utils/cache.py
index fb0d31811c8..46522e8ecde 100644
--- a/superset/utils/cache.py
+++ b/superset/utils/cache.py
@@ -54,6 +54,38 @@ def generate_cache_key(values_dict: dict[str, Any], key_prefix: str = "") -> str
     return cache_key
 
 
+def exceeds_max_cache_value_size(cache_key: str, value: Any) -> bool:
+    """Check a value against ``DATA_CACHE_MAX_VALUE_SIZE`` before a data-cache write.
+
+    Every writer to the data cache calls this so that one oversized value cannot
+    flood the cache backend (e.g. Redis/Memcached) and evict many smaller entries.
+    When the serialized (pickled) size of ``value`` is larger than the limit, a
+    WARNING naming the key and size is logged and the
+    ``skip_cache_value_too_large`` statsd counter is incremented; the caller must
+    then skip the write. A skipped write is an ordinary cache miss: the next read
+    re-runs the query.
+
+    :returns: ``True`` when the value is too large and must not be cached. Always
+        ``False`` when ``DATA_CACHE_MAX_VALUE_SIZE`` is ``None``, in which case the
+        value is not serialized and no overhead is incurred.
+    """
+    max_value_size = app.config.get("DATA_CACHE_MAX_VALUE_SIZE")
+    if max_value_size is None:
+        return False
+    value_size = len(pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL))
+    if value_size <= max_value_size:
+        return False
+    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 True
+
+
 def set_and_log_cache(
     cache_instance: Cache,
     cache_key: str,
@@ -91,21 +123,9 @@ def set_and_log_cache(
         # 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 False
+        # next load instead of a cache hit.
+        if exceeds_max_cache_value_size(cache_key, value):
+            return False
 
         # Flask-Caching's set() returns bool | None: cachelib backends can report
         # a failed write by returning False without raising, while some backends
diff --git a/tests/unit_tests/datasource/cache_size_cap_test.py b/tests/unit_tests/datasource/cache_size_cap_test.py
new file mode 100644
index 00000000000..b8c6d4d161d
--- /dev/null
+++ b/tests/unit_tests/datasource/cache_size_cap_test.py
@@ -0,0 +1,160 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""``DATA_CACHE_MAX_VALUE_SIZE`` applies to the datasource API's cache writes.
+
+The column-values endpoint (filter dropdown values) and the compatible
+metrics/dimensions endpoint write to the data cache directly. An oversized
+payload is left uncached and still returned to the caller; a normal payload is
+cached as usual.
+"""
+
+from typing import Any
+from unittest.mock import call, MagicMock
+
+import pytest
+from flask import current_app
+from pytest_mock import MockerFixture
+
+# A cap small enough for the "large" payloads below to exceed it, while the
+# "small" payloads stay under it.
+MAX_VALUE_SIZE = 1024
+
+
+@pytest.fixture
+def datasource(mocker: MockerFixture) -> MagicMock:
+    datasource = MagicMock()
+    datasource.uid = "1__table"
+    datasource.cache_timeout = None
+    datasource.normalize_columns = False
+    datasource.changed_on = "2024-01-01"
+    mocker.patch(
+        "superset.datasource.api.DatasourceDAO.get_datasource",
+        return_value=datasource,
+    )
+    mocker.patch(
+        "superset.datasource.api.security_manager.get_rls_cache_key",
+        return_value=[],
+    )
+    return datasource
+
+
+@pytest.fixture
+def data_cache(mocker: MockerFixture) -> MagicMock:
+    cache = mocker.patch("superset.datasource.api.cache_manager").data_cache
+    cache.get.return_value = None
+    return cache
+
+
+@pytest.fixture
+def stats_logger(mocker: MockerFixture) -> MagicMock:
+    stats_logger = MagicMock()
+    mocker.patch.dict(
+        current_app.config,
+        {
+            "DATA_CACHE_MAX_VALUE_SIZE": MAX_VALUE_SIZE,
+            "STATS_LOGGER": stats_logger,
+        },
+    )
+    return stats_logger
+
+
+def _get_values(client: Any) -> Any:
+    return client.get("/api/v1/datasource/table/1/column/name/values/")
+
+
+def _post_compatible(client: Any) -> Any:
+    return client.post(
+        "/api/v1/datasource/table/1/compatible",
+        json={"selected_metrics": [], "selected_dimensions": []},
+    )
+
+
+def test_column_values_oversized_payload_is_not_cached(
+    client: Any,
+    full_api_access: None,
+    datasource: MagicMock,
+    data_cache: MagicMock,
+    stats_logger: MagicMock,
+    mocker: MockerFixture,
+) -> None:
+    values = [f"value-{i:05d}" for i in range(500)]
+    datasource.values_for_column.return_value = values
+    mock_logger = mocker.patch("superset.utils.cache.logger")
+
+    response = _get_values(client)
+
+    assert response.status_code == 200
+    assert response.json["result"] == values
+    data_cache.set.assert_not_called()
+    stats_logger.incr.assert_any_call("skip_cache_value_too_large")
+    mock_logger.warning.assert_called_once()
+
+
+def test_column_values_normal_payload_is_cached(
+    client: Any,
+    full_api_access: None,
+    datasource: MagicMock,
+    data_cache: MagicMock,
+    stats_logger: MagicMock,
+) -> None:
+    datasource.values_for_column.return_value = ["a", "b"]
+
+    response = _get_values(client)
+
+    assert response.status_code == 200
+    data_cache.set.assert_called_once()
+    assert data_cache.set.call_args.args[1] == ["a", "b"]
+    assert call("skip_cache_value_too_large") not in stats_logger.incr.mock_calls
+
+
+def test_compatible_oversized_result_is_not_cached(
+    client: Any,
+    full_api_access: None,
+    datasource: MagicMock,
+    data_cache: MagicMock,
+    stats_logger: MagicMock,
+) -> None:
+    metrics = [f"metric_{i:05d}" for i in range(500)]
+    datasource.get_compatible_metrics.return_value = metrics
+    datasource.get_compatible_dimensions.return_value = []
+
+    response = _post_compatible(client)
+
+    assert response.status_code == 200
+    assert response.json["result"]["compatible_metrics"] == metrics
+    data_cache.set.assert_not_called()
+    stats_logger.incr.assert_any_call("skip_cache_value_too_large")
+
+
+def test_compatible_normal_result_is_cached(
+    client: Any,
+    full_api_access: None,
+    datasource: MagicMock,
+    data_cache: MagicMock,
+    stats_logger: MagicMock,
+) -> None:
+    datasource.get_compatible_metrics.return_value = ["count"]
+    datasource.get_compatible_dimensions.return_value = ["country"]
+
+    response = _post_compatible(client)
+
+    assert response.status_code == 200
+    data_cache.set.assert_called_once()
+    assert data_cache.set.call_args.args[1] == {
+        "compatible_metrics": ["count"],
+        "compatible_dimensions": ["country"],
+    }
diff --git a/tests/unit_tests/sql/execution/test_executor.py b/tests/unit_tests/sql/execution/test_executor.py
index 0ecd0109149..c193dd19cdf 100644
--- a/tests/unit_tests/sql/execution/test_executor.py
+++ b/tests/unit_tests/sql/execution/test_executor.py
@@ -3660,3 +3660,76 @@ def test_truncation_survives_cache_round_trip(
     cached = executor._get_from_cache("SELECT 1", QueryOptions())
     assert cached is not None
     assert cached.statements[0].truncated is truncated
+
+
+def _store_in_cache_with_cap(
+    mocker: MockerFixture, database: Database, row_count: int
+) -> tuple[MagicMock, MagicMock, MagicMock]:
+    """Run ``_store_in_cache`` for a ``row_count``-row result under a 1 KB cap.
+
+    :returns: the mocked ``data_cache.set``, stats logger, and cache-module logger
+    """
+    from superset_core.queries.types import (
+        QueryResult as QueryResultType,
+        StatementResult,
+    )
+
+    from superset.extensions import cache_manager
+    from superset.sql.execution.executor import SQLExecutor
+
+    stats_logger = MagicMock()
+    mocker.patch.dict(
+        current_app.config,
+        {
+            "DATA_CACHE_MAX_VALUE_SIZE": 1024,
+            "STATS_LOGGER": stats_logger,
+            "CACHE_DEFAULT_TIMEOUT": 300,
+        },
+    )
+    mock_cache_set = mocker.patch.object(cache_manager.data_cache, "set")
+    mock_logger = mocker.patch("superset.utils.cache.logger")
+
+    result = QueryResultType(
+        status=QueryStatus.SUCCESS,
+        statements=[
+            StatementResult(
+                original_sql="SELECT name FROM users",
+                executed_sql="SELECT name FROM users",
+                data=pd.DataFrame(
+                    {"name": [f"user-{i:05d}" for i in range(row_count)]}
+                ),
+                row_count=row_count,
+            )
+        ],
+    )
+    SQLExecutor(database)._store_in_cache(
+        result, "SELECT name FROM users", QueryOptions()
+    )
+    return mock_cache_set, stats_logger, mock_logger
+
+
+def test_store_in_cache_skips_oversized_result(
+    mocker: MockerFixture, database: Database, app_context: None
+) -> None:
+    """A result larger than ``DATA_CACHE_MAX_VALUE_SIZE`` is not written; the skip
+    is logged and counted. A later ``_get_from_cache`` simply misses and the query
+    re-runs."""
+    mock_cache_set, stats_logger, mock_logger = _store_in_cache_with_cap(
+        mocker, database, row_count=500
+    )
+
+    mock_cache_set.assert_not_called()
+    stats_logger.incr.assert_called_once_with("skip_cache_value_too_large")
+    mock_logger.warning.assert_called_once()
+
+
+def test_store_in_cache_writes_result_under_cap(
+    mocker: MockerFixture, database: Database, app_context: None
+) -> None:
+    """A result under ``DATA_CACHE_MAX_VALUE_SIZE`` is cached as usual."""
+    mock_cache_set, stats_logger, _ = _store_in_cache_with_cap(
+        mocker, database, row_count=2
+    )
+
+    mock_cache_set.assert_called_once()
+    stats_logger.incr.assert_not_called()
diff --git a/tests/unit_tests/tasks/test_async_queries.py b/tests/unit_tests/tasks/test_async_queries.py
index 068b6803059..3e58afdf6e0 100644
--- a/tests/unit_tests/tasks/test_async_queries.py
+++ b/tests/unit_tests/tasks/test_async_queries.py
@@ -306,6 +306,11 @@ def test_execute_chart_query_publishes_cache_key_payload(
         "superset.tasks.async_queries.get_context",
         return_value=task_context,
     )
+    # The result was persisted, so the client's read-back will hit.
+    mocker.patch(
+        "superset.tasks.async_queries.cache_manager.data_cache.has",
+        return_value=True,
+    )
 
     execute_chart_query.func(_serialized_query(), user_id=7)
 
@@ -315,6 +320,58 @@ def test_execute_chart_query_publishes_cache_key_payload(
     )
 
 
+def test_execute_chart_query_fails_loudly_when_result_not_cached(
+    mocker: MockerFixture,
+) -> None:
+    """A successful-but-unpersisted result fails the task instead of resubmitting.
+
+    When a result exceeds ``DATA_CACHE_MAX_VALUE_SIZE`` the cache write is skipped
+    but the query still succeeds. Async delivery relies on the client reading the
+    result back from the DATA cache, so a missing entry makes the endpoint's
+    ``force_cached`` read raise ``CacheLoadError`` and reschedule this task. If the
+    task reported success, the client would resubmit it endlessly. The task must
+    raise so the client sees a clear terminal error, and must not publish a
+    cache-key payload that would drive another read-back/resubmit cycle.
+    """
+    from superset.exceptions import SupersetException
+    from superset.tasks.async_queries import execute_chart_query
+
+    query_obj = mocker.MagicMock()
+    query_context = mocker.MagicMock()
+    query_context.queries = [query_obj]
+    # Query succeeded (no "error"), but the value was skipped for size.
+    query_context.get_df_payload_result.return_value.payload = {
+        "cache_key": "oversized-cache-key",
+        "error": None,
+    }
+    task_context = mocker.MagicMock()
+
+    mocker.patch(
+        "superset.tasks.async_queries._resolve_user",
+        return_value=mocker.MagicMock(),
+    )
+    mocker.patch("superset.tasks.async_queries.override_user")
+    mocker.patch(
+        "superset.tasks.async_queries.load_serialized_query",
+        return_value=query_context,
+    )
+    mocker.patch(
+        "superset.tasks.async_queries.get_context",
+        return_value=task_context,
+    )
+    # The oversized value was never written, so the read-back would miss.
+    mocker.patch(
+        "superset.tasks.async_queries.cache_manager.data_cache.has",
+        return_value=False,
+    )
+
+    with pytest.raises(SupersetException, match="DATA_CACHE_MAX_VALUE_SIZE"):
+        execute_chart_query.func(_serialized_query(), user_id=7)
+
+    # It must NOT publish a cache-key payload the client would resubmit against.
+    task_context.update_task.assert_not_called()
+
+
 def test_execute_chart_query_reestablishes_form_data(
     mocker: MockerFixture,
 ) -> None:
@@ -430,6 +487,10 @@ def test_execute_chart_query_reads_totals_key_from_dependency_payload(
         "superset.tasks.async_queries.get_context",
         return_value=task_context,
     )
+    mocker.patch(
+        "superset.tasks.async_queries.cache_manager.data_cache.has",
+        return_value=True,
+    )
 
     execute_chart_query.func(_serialized_query(), user_id=7, requires_totals=True)
 
diff --git a/tests/unit_tests/utils/cache_test.py b/tests/unit_tests/utils/cache_test.py
index c1fbafb7cf4..6adac429d22 100644
--- a/tests/unit_tests/utils/cache_test.py
+++ b/tests/unit_tests/utils/cache_test.py
@@ -397,6 +397,40 @@ def test_set_and_log_cache_under_threshold_metadata_db(mocker: MockerFixture) ->
     mock_session.add.assert_called_once_with(mock_cache_key.return_value)
 
 
+def test_data_cache_max_value_size_default_is_protective() -> None:
+    """The shipped default caps oversized data-cache entries out of the box.
+
+    The oversized-value skip is only effective when ``DATA_CACHE_MAX_VALUE_SIZE``
+    has a value; a ``None`` default disables it and lets very large results pile
+    up in the cache backend. This asserts the default is a positive cap that still
+    exceeds ordinary chart/query payloads. Operators can raise it or set it to
+    ``None`` explicitly.
+    """
+    import superset.config as config
+
+    assert config.DATA_CACHE_MAX_VALUE_SIZE is not None
+    assert isinstance(config.DATA_CACHE_MAX_VALUE_SIZE, int)
+    # Comfortably above typical payloads, well below the multi-tens-of-MB outliers.
+    assert 1024 * 1024 <= config.DATA_CACHE_MAX_VALUE_SIZE <= 20 * 1024 * 1024
+
+
+def test_set_and_log_cache_applies_default_timeout(mocker: MockerFixture) -> None:
+    """Every persisted value carries a TTL: an unset timeout falls back to
+    ``CACHE_DEFAULT_TIMEOUT`` and is passed atomically to ``cache.set`` (SETEX),
+    never written TTL-less. Guards against a code path that could leave a data-cache
+    key with no expiry, which would let it linger in Redis indefinitely."""
+    from superset.utils.cache import set_and_log_cache
+
+    _patch_config(mocker)  # CACHE_DEFAULT_TIMEOUT == 100, no explicit timeout
+    cache_instance = _make_cache_instance(mocker)
+
+    set_and_log_cache(cache_instance, "my_key", {"df": "small"})
+
+    cache_instance.set.assert_called_once()
+    _, kwargs = cache_instance.set.call_args
+    assert kwargs["timeout"] == 100
+
+
 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
@@ -412,3 +446,152 @@ def test_set_and_log_cache_set_failure_logs(mocker: MockerFixture) -> None:
 
     mock_logger.warning.assert_called_once_with("Could not cache key %s", "my_key")
     mock_logger.exception.assert_called_once_with(boom)
+
+
+def test_set_and_log_cache_shipped_default_caches_normal_payload(
+    mocker: MockerFixture,
+) -> None:
+    """With the *shipped* default cap active, an ordinary payload still caches.
+
+    Uses the real ``config.DATA_CACHE_MAX_VALUE_SIZE`` rather than a patched value,
+    so it proves the value we actually ship does not wrongly skip typical
+    chart/query results (which sit far below the cap).
+    """
+    import superset.config as config
+    from superset.utils.cache import set_and_log_cache
+
+    cfg = _patch_config(
+        mocker, DATA_CACHE_MAX_VALUE_SIZE=config.DATA_CACHE_MAX_VALUE_SIZE
+    )
+    cache_instance = _make_cache_instance(mocker)
+
+    set_and_log_cache(cache_instance, "my_key", {"df": "a normal-sized result"})
+
+    cache_instance.set.assert_called_once()
+    cfg["STATS_LOGGER"].incr.assert_any_call("set_cache_key")
+    assert (
+        mocker.call("skip_cache_value_too_large")
+        not in cfg["STATS_LOGGER"].incr.mock_calls
+    )
+
+
+def test_set_and_log_cache_shipped_default_skips_oversized_payload(
+    mocker: MockerFixture,
+) -> None:
+    """With the *shipped* default cap active, an over-cap payload is skipped.
+
+    Builds a payload larger than the real ``config.DATA_CACHE_MAX_VALUE_SIZE`` and
+    asserts the end-to-end skip behavior the default exists to provide: no cache
+    write, a WARNING, and the ``skip_cache_value_too_large`` counter.
+    """
+    import superset.config as config
+    from superset.utils.cache import set_and_log_cache
+
+    max_size = config.DATA_CACHE_MAX_VALUE_SIZE
+    assert max_size is not None
+    cfg = _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=max_size)
+    cache_instance = _make_cache_instance(mocker)
+    mock_logger = mocker.patch("superset.utils.cache.logger")
+
+    # A string this long pickles to more than ``max_size`` bytes.
+    set_and_log_cache(cache_instance, "my_key", {"df": "x" * (max_size + 1024)})
+
+    cache_instance.set.assert_not_called()
+    cfg["STATS_LOGGER"].incr.assert_called_once_with("skip_cache_value_too_large")
+    mock_logger.warning.assert_called_once()
+
+
+def test_set_and_log_cache_one_byte_over_threshold(mocker: MockerFixture) -> None:
+    """A value one byte OVER the threshold is skipped (guard is strict ``>``).
+
+    Complements ``test_set_and_log_cache_equal_threshold`` (== is cached) by pinning
+    the threshold to one below the exact serialized size.
+    """
+    import pickle
+
+    from superset.utils.cache import set_and_log_cache
+
+    cache_value = {"df": "boundary"}
+    dttm = "2021-01-01T00:00:00"
+    value = {**cache_value, "dttm": dttm}
+    exact_size = len(pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL))
+
+    cfg = _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=exact_size - 1)
+    cache_instance = _make_cache_instance(mocker)
+    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_not_called()
+    cfg["STATS_LOGGER"].incr.assert_called_once_with("skip_cache_value_too_large")
+
+
+def test_set_and_log_cache_explicit_timeout_passthrough(mocker: MockerFixture) -> None:
+    """An explicit ``cache_timeout`` is forwarded verbatim to ``cache.set``,
+    taking precedence over ``CACHE_DEFAULT_TIMEOUT``."""
+    from superset.utils.cache import set_and_log_cache
+
+    _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_timeout=4242)
+
+    cache_instance.set.assert_called_once()
+    _, kwargs = cache_instance.set.call_args
+    assert kwargs["timeout"] == 4242
+
+
+def test_set_and_log_cache_zero_timeout_never_expires(mocker: MockerFixture) -> None:
+    """A ``cache_timeout`` of 0 ("never expires") is preserved, not treated as
+    disabled. Only ``CACHE_DISABLED_TIMEOUT`` (-1) skips the write; 0 must pass
+    through to ``cache.set`` so the backend stores the key without expiry."""
+    from superset.utils.cache import set_and_log_cache
+
+    _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_timeout=0)
+
+    cache_instance.set.assert_called_once()
+    _, kwargs = cache_instance.set.call_args
+    assert kwargs["timeout"] == 0
+
+
+def test_exceeds_max_cache_value_size_disabled_skips_serialization(
+    mocker: MockerFixture,
+) -> None:
+    """With the cap set to ``None`` nothing is too large and nothing is pickled."""
+    from superset.utils.cache import exceeds_max_cache_value_size
+
+    _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=None)
+    mock_dumps = mocker.patch("superset.utils.cache.pickle.dumps")
+
+    assert exceeds_max_cache_value_size("my_key", ["x" * 1024]) is False
+    mock_dumps.assert_not_called()
+
+
+def test_exceeds_max_cache_value_size_boundary(mocker: MockerFixture) -> None:
+    """A value exactly at the cap fits; one byte over does not and is reported."""
+    import pickle
+
+    from superset.utils.cache import exceeds_max_cache_value_size
+
+    value = ["a", "b", "c"]
+    exact_size = len(pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL))
+    mock_logger = mocker.patch("superset.utils.cache.logger")
+
+    config = _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=exact_size)
+    assert exceeds_max_cache_value_size("my_key", value) is False
+    config["STATS_LOGGER"].incr.assert_not_called()
+    mock_logger.warning.assert_not_called()
+
+    config = _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=exact_size - 1)
+    assert exceeds_max_cache_value_size("my_key", value) is True
+    config["STATS_LOGGER"].incr.assert_called_once_with("skip_cache_value_too_large")
+    mock_logger.warning.assert_called_once()
+    assert mock_logger.warning.call_args.args[1:] == (
+        "my_key",
+        exact_size,
+        exact_size - 1,
+    )


(superset) 02/04: fix(cache): drop the async cache-persistence check; skip size check on NullCache

Posted by el...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

eschutho pushed a commit to branch fix-cache-max-value-size-default
in repository https://gitbox.apache.org/repos/asf/superset.git

commit bce26e7085c411f0de664a99dfaa47f9039a36f5
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Fri Sep 25 19:20:55 2026 +0000

    fix(cache): drop the async cache-persistence check; skip size check on NullCache
    
    - Remove the post-task data_cache.has() check in execute_chart_query and its
      test. A result too large to cache is recomputed by the client's synchronous
      follow-up request, so async charts render as before.
    - Add tests that drive the task run and the follow-up against a real
      in-memory cache: an oversized result is recomputed on the follow-up for
      both a normal load and a forced refresh (task-id force nonce), with no
      force marker written.
    - In the datasource API's _set_data_cache and SQLExecutor._store_in_cache,
      return before the size check when the data cache is a NullCache, so the
      value is never serialized. Add tests for this and give the executor tests
      a non-null data cache fixture.
    - Update UPDATING.md, the config comment and the caching docs.
    
    Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
 UPDATING.md                                        |  32 +++--
 docs/admin_docs/configuration/cache.mdx            |  16 ++-
 superset/config.py                                 |  27 ++--
 superset/datasource/api.py                         |  10 +-
 superset/sql/execution/executor.py                 |   6 +
 superset/tasks/async_queries.py                    |  20 ---
 .../common/test_oversized_result_readback.py       | 146 +++++++++++++++++++++
 tests/unit_tests/datasource/cache_size_cap_test.py |  52 ++++++++
 .../dashboard/tool/test_apply_dashboard_filters.py |   8 ++
 tests/unit_tests/sql/execution/conftest.py         |  16 +++
 .../unit_tests/sql/execution/test_async_results.py |  27 ++--
 tests/unit_tests/sql/execution/test_executor.py    | 115 ++++++++++++----
 tests/unit_tests/tasks/test_async_queries.py       |  61 ---------
 13 files changed, 373 insertions(+), 163 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index e7fe64fc79b..9ef9a9aae76 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -662,23 +662,21 @@ incomplete snapshot is not exposed as restorable history.
 
 ### `DATA_CACHE_MAX_VALUE_SIZE` now defaults to 10 MB
 
-The upper bound on the serialized size of a single value written to the data
-cache now defaults to `10 * 1024 * 1024` (10 MB) instead of `None`. Values whose
-serialized size exceeds the limit are no longer written to the data cache — the
-request still succeeds, but the next load re-queries the datasource instead of
-getting a cache hit. The limit applies to every data-cache writer: chart results,
-SQL query results from the SQL executor, filter dropdown column values, and
-compatible metrics/dimensions. Each skip emits a WARNING log
-naming the key and byte size and increments the `skip_cache_value_too_large`
-statsd counter. This protects the cache backend (e.g. Redis) from being driven
-toward its memory limit by a heavy tail of very large results. To restore the
-previous unbounded behavior, set `DATA_CACHE_MAX_VALUE_SIZE = None`; to allow
-larger cached results, raise the limit further.
-
-With `GLOBAL_ASYNC_QUERIES` enabled, a chart whose result exceeds the limit now
-fails with a clear error instead of being retried indefinitely, because async
-chart delivery depends on reading the result back from the data cache. Raise the
-limit, narrow the query, or disable async queries for such charts.
+`DATA_CACHE_MAX_VALUE_SIZE` limits the serialized size of a single value written
+to the data cache. It defaults to `10 * 1024 * 1024` (10 MB) instead of `None`.
+Values larger than the limit are not stored in the cache; the request still
+returns its data, and the value is recomputed from the datasource the next time
+it is requested. This includes the follow-up request a chart sends after a
+background (async) query finishes: that request recomputes the result instead of
+reading it from the cache, so an oversized async chart runs its query twice.
+
+The limit applies to every writer to the data cache: chart results, SQL query
+results from the SQL executor, filter dropdown column values, and compatible
+metrics/dimensions. Each skipped write logs a WARNING naming the key and byte size
+and increments the `skip_cache_value_too_large` statsd counter. The limit keeps a
+few very large results from filling the cache backend (e.g. Redis) and pushing out
+many smaller entries. To keep the previous unlimited behavior, set
+`DATA_CACHE_MAX_VALUE_SIZE = None`; to cache larger results, raise the limit.
 
 ### Guest token RLS rules without a dataset apply inside sub-queries
 
diff --git a/docs/admin_docs/configuration/cache.mdx b/docs/admin_docs/configuration/cache.mdx
index 805d49cd0d3..bf6e7cdd48b 100644
--- a/docs/admin_docs/configuration/cache.mdx
+++ b/docs/admin_docs/configuration/cache.mdx
@@ -184,18 +184,20 @@ retention policy, it keeps async results in the cache longer and modestly increa
 
 Very large chart or SQL query results can flood the cache backend (Redis/Memcached), evicting many
 smaller useful entries or exhausting memory. The size of any single value written to the data cache
-(chart and SQL query results, filter dropdown values, and compatible metrics/dimensions) is bounded by `DATA_CACHE_MAX_VALUE_SIZE` (in bytes), which defaults to `10 * 1024 * 1024` (10 MB). To
-change it, set it in `superset_config.py`:
+(chart and SQL query results, filter dropdown values, and compatible metrics/dimensions) is bounded
+by `DATA_CACHE_MAX_VALUE_SIZE` (in bytes), which defaults to `10 * 1024 * 1024` (10 MB). To change it,
+set it in `superset_config.py`:
 
 ```python
 DATA_CACHE_MAX_VALUE_SIZE = 20 * 1024 * 1024  # 20 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. Each
-skip emits a WARNING log naming the key and byte size, and increments the `skip_cache_value_too_large`
-statsd metric. Set to `None` to disable the check entirely (no serialization overhead is then
-incurred).
+When a value's serialized size exceeds this threshold it is not written to the data cache. The
+request still returns its data, and the value is recomputed from the datasource the next time it is
+requested. With `GLOBAL_ASYNC_QUERIES`, that includes the follow-up request a chart sends after its
+background query finishes, so an oversized async chart runs its query twice. Each skip emits a
+WARNING log naming the key and byte size, and increments the `skip_cache_value_too_large` statsd
+metric. Set to `None` to disable the check entirely (no serialization overhead is then incurred).
 
 ## SQL Lab Query Results
 
diff --git a/superset/config.py b/superset/config.py
index 358cf89572c..4eb9a9eaa94 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1506,20 +1506,19 @@ 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, filter dropdown values, and compatible
-# metrics/dimensions). When a value's pickled size exceeds this threshold it is
-# NOT written to the cache: the request still succeeds, 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 a heavy tail of very
-# large result sets, which can drive the backend toward its memory limit and
-# evict many smaller, useful entries. Each skip emits a WARNING log naming the key
-# and byte size and increments the ``skip_cache_value_too_large`` statsd counter.
-# With GLOBAL_ASYNC_QUERIES, async chart delivery reads the result back from this
-# cache, so an oversized async chart result fails with a clear error instead.
-# The default of 10 MB comfortably exceeds typical chart/query payloads while
-# excluding the multi-tens-of-MB outliers responsible for cache pressure; raise it
-# if legitimate results are being skipped, or set it to ``None`` to disable the
-# check entirely (no serialization overhead is then incurred). Example:
-# 20 * 1024 * 1024 for 20 MB.
+# metrics/dimensions). A value whose pickled size exceeds this threshold is NOT
+# written to the cache: the request still succeeds, and the value is recomputed
+# the next time it is requested, including the follow-up request a chart sends
+# after a background (async) query. This protects the cache backend (e.g.
+# Redis/Memcached) from being flooded by a heavy tail of very large result sets,
+# which can drive the backend toward its memory limit and evict many smaller,
+# useful entries. Each skip emits a WARNING log naming the key and byte size and
+# increments the ``skip_cache_value_too_large`` statsd counter. The default of
+# 10 MB comfortably exceeds typical chart/query payloads while excluding the
+# multi-tens-of-MB outliers responsible for cache pressure; raise it if legitimate
+# results are being skipped, or set it to ``None`` to disable the check entirely
+# (no serialization overhead is then incurred). Example: 20 * 1024 * 1024 for
+# 20 MB.
 DATA_CACHE_MAX_VALUE_SIZE: int | None = 10 * 1024 * 1024
 
 # Include per-query lifecycle timing in /api/v1/chart/data JSON responses.
diff --git a/superset/datasource/api.py b/superset/datasource/api.py
index 9d38dd732c9..1fa53c081f7 100644
--- a/superset/datasource/api.py
+++ b/superset/datasource/api.py
@@ -21,6 +21,7 @@ from typing import Any, cast
 from flask import current_app as app, make_response, request, Response
 from flask_appbuilder.api import expose, protect, rison, safe
 from flask_appbuilder.api.schemas import get_list_schema
+from flask_caching.backends import NullCache
 from marshmallow import ValidationError
 
 from superset import event_logger, is_feature_enabled, security_manager
@@ -75,9 +76,14 @@ logger = logging.getLogger(__name__)
 def _set_data_cache(cache_key: str, value: Any, timeout: int) -> None:
     """Write ``value`` to the data cache unless it exceeds
     ``DATA_CACHE_MAX_VALUE_SIZE``. An oversized value is left uncached, so the next
-    request misses and recomputes it from the datasource."""
+    request misses and recomputes it from the datasource. With caching disabled
+    (``NullCache``) nothing is written and the size check is skipped, so the value
+    is never serialized."""
+    data_cache = cache_manager.data_cache
+    if isinstance(data_cache.cache, NullCache):
+        return
     if not exceeds_max_cache_value_size(cache_key, value):
-        cache_manager.data_cache.set(cache_key, value, timeout=timeout)
+        data_cache.set(cache_key, value, timeout=timeout)
 
 
 # Cache lifetime for search-filtered column values, in seconds.
diff --git a/superset/sql/execution/executor.py b/superset/sql/execution/executor.py
index 67431428625..3f6249f01d0 100644
--- a/superset/sql/execution/executor.py
+++ b/superset/sql/execution/executor.py
@@ -71,6 +71,7 @@ import pandas as pd
 import pyarrow as pa
 from flask import current_app as app, g, has_app_context
 from flask_babel import gettext as __
+from flask_caching.backends import NullCache
 
 from superset import db
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
@@ -1280,6 +1281,11 @@ class SQLExecutor:
         if result.status != QueryStatus.SUCCESS:
             return
 
+        # With caching disabled there is nothing to write, so skip building and
+        # measuring the serialized value.
+        if isinstance(cache_manager.data_cache.cache, NullCache):
+            return
+
         cache_key = self._generate_cache_key(sql, opts)
         if cache_key is None:
             return
diff --git a/superset/tasks/async_queries.py b/superset/tasks/async_queries.py
index 9004f438b32..7b2dcc5f828 100644
--- a/superset/tasks/async_queries.py
+++ b/superset/tasks/async_queries.py
@@ -40,7 +40,6 @@ from superset.exceptions import (
     SupersetException,
 )
 from superset.extensions import (
-    cache_manager,
     security_manager,
 )
 from superset.tasks.ambient_context import get_context
@@ -352,25 +351,6 @@ def execute_chart_query(
         ):
             raise SupersetException(result.payload["error"])
         if cache_key := result.payload.get(CACHE_KEY_PAYLOAD_KEY):
-            # Async delivery works by the client reading this result back from the
-            # DATA cache under ``cache_key``. When the query succeeded but the value
-            # was not persisted -- most commonly because its serialized size exceeds
-            # DATA_CACHE_MAX_VALUE_SIZE and the oversized-value skip dropped it -- the
-            # read-back would miss and the client would resubmit this task without
-            # end (its force_cached read raises CacheLoadError, which the chart-data
-            # endpoint treats as "not ready yet" and reschedules). Fail loudly here
-            # instead so the user sees a clear terminal error rather than an
-            # invisible retry loop. (Async is never scheduled against a NullCache
-            # DATA backend, so a missing entry here means a real skipped write.)
-            if not result.payload.get("error") and not cache_manager.data_cache.has(
-                cache_key
-            ):
-                raise SupersetException(
-                    "The query result could not be cached and cannot be returned "
-                    "asynchronously. This usually means it exceeds "
-                    "DATA_CACHE_MAX_VALUE_SIZE; narrow the query, raise the limit, "
-                    "or run the request without async."
-                )
             # Write synchronously: a dependent contribution query reads this
             # cache key via get_dependency_payloads once the DAG gate releases,
             # so it must not sit in the throttle buffer.
diff --git a/tests/unit_tests/common/test_oversized_result_readback.py b/tests/unit_tests/common/test_oversized_result_readback.py
new file mode 100644
index 00000000000..3d04af05e96
--- /dev/null
+++ b/tests/unit_tests/common/test_oversized_result_readback.py
@@ -0,0 +1,146 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""An oversized chart result is recomputed on the follow-up request.
+
+With async chart queries, a background task runs the query and the client then
+re-sends the same request synchronously. When the result is larger than
+``DATA_CACHE_MAX_VALUE_SIZE`` it is not stored, so that follow-up request finds
+nothing in the cache and runs the query again. These tests drive
+``get_df_payload_result`` twice against a real in-memory cache -- once as the
+background task, once as the follow-up -- for a normal load and for a forced
+refresh whose follow-up carries the task's id as its ``force_nonce``.
+"""
+
+from collections.abc import Iterator
+from typing import Any
+from unittest.mock import MagicMock
+
+import pandas as pd
+import pytest
+from flask import current_app
+from flask_caching import Cache
+from pytest_mock import MockerFixture
+
+from superset.common.db_query_status import QueryStatus
+from superset.common.query_context_processor import QueryContextProcessor
+from superset.constants import CacheRegion
+
+CACHE_KEY = "chart-result-key"
+TASK_ID = "task-uuid-1"
+
+
+@pytest.fixture
+def data_cache(mocker: MockerFixture) -> Iterator[Cache]:
+    """A real in-memory data cache wired into the query cache manager, the
+    forced-refresh marker lookups, and ``set_and_log_cache``."""
+    cache = Cache()
+    cache.init_app(current_app, config={"CACHE_TYPE": "SimpleCache"})
+    mocker.patch.dict(
+        "superset.common.utils.query_cache_manager._cache",
+        {CacheRegion.DATA: cache},
+    )
+    mocker.patch(
+        "superset.common.query_context_processor.cache_manager",
+        MagicMock(data_cache=cache),
+    )
+    mocker.patch.dict(
+        current_app.config,
+        {"DATA_CACHE_MAX_VALUE_SIZE": 2048, "STATS_LOGGER": MagicMock()},
+    )
+    yield cache
+    cache.clear()
+
+
+def _processor(mocker: MockerFixture, rows: int, force: bool) -> Any:
+    """A processor whose datasource returns a ``rows``-row dataframe."""
+    query_context = MagicMock()
+    query_context.force = force
+    query_context.force_nonce = None
+    processor = QueryContextProcessor(query_context)
+    processor._qc_datasource = MagicMock(column_names=["name"], uid="1__table")
+
+    query_result = MagicMock()
+    query_result.status = QueryStatus.SUCCESS
+    query_result.df = pd.DataFrame({"name": [f"row-{i:06d}" for i in range(rows)]})
+    query_result.error_message = None
+    query_result.applied_template_filters = []
+    query_result.applied_filter_columns = []
+    query_result.rejected_filter_columns = []
+    query_result.sql_rowcount = rows
+    query_result.query = "SELECT name FROM t"
+    query_result.semantic_cache_status = None
+    mocker.patch.object(processor, "get_query_result", return_value=query_result)
+    mocker.patch.object(processor, "get_annotation_data", return_value={})
+    mocker.patch.object(processor, "query_cache_key", return_value=CACHE_KEY)
+    mocker.patch.object(processor, "get_cache_timeout", return_value=300)
+    return processor
+
+
+def _query_object(force_nonce: str | None) -> Any:
+    from superset.common.query_object import QueryObject
+
+    query_obj = QueryObject(datasource=MagicMock(), columns=["name"])
+    query_obj.force_nonce = force_nonce
+    return query_obj
+
+
+def _run_task_then_follow_up(
+    mocker: MockerFixture, rows: int, force: bool
+) -> tuple[Any, dict[str, Any], dict[str, Any]]:
+    processor = _processor(mocker, rows, force)
+    # The background task stamps the query with its own id as the nonce.
+    task_payload = processor.get_df_payload_result(_query_object(TASK_ID)).payload
+    # The client's synchronous re-send carries the task id only when forcing.
+    follow_up_nonce = TASK_ID if force else None
+    follow_up_payload = processor.get_df_payload_result(
+        _query_object(follow_up_nonce)
+    ).payload
+    return processor, task_payload, follow_up_payload
+
+
+@pytest.mark.parametrize("force", [False, True], ids=["normal", "forced"])
+def test_oversized_result_is_recomputed_on_follow_up(
+    mocker: MockerFixture, data_cache: Cache, force: bool
+) -> None:
+    processor, task_payload, follow_up_payload = _run_task_then_follow_up(
+        mocker, rows=2000, force=force
+    )
+
+    # Too large to store, and no forced-refresh marker claiming it was stored.
+    assert data_cache.get(CACHE_KEY) is None
+    assert data_cache.get(f"gtf-force-nonce:{TASK_ID}:{CACHE_KEY}") is None
+    # The follow-up re-ran the query and returned the full result.
+    assert processor.get_query_result.call_count == 2
+    for payload in (task_payload, follow_up_payload):
+        assert payload["error"] is None
+        assert payload["status"] == QueryStatus.SUCCESS
+        assert not payload["is_cached"]
+        assert payload["rowcount"] == 2000
+
+
+@pytest.mark.parametrize("force", [False, True], ids=["normal", "forced"])
+def test_normal_size_result_is_read_from_cache_on_follow_up(
+    mocker: MockerFixture, data_cache: Cache, force: bool
+) -> None:
+    processor, _, follow_up_payload = _run_task_then_follow_up(
+        mocker, rows=2, force=force
+    )
+
+    assert data_cache.get(CACHE_KEY) is not None
+    assert processor.get_query_result.call_count == 1
+    assert follow_up_payload["is_cached"] is True
+    assert follow_up_payload["rowcount"] == 2
diff --git a/tests/unit_tests/datasource/cache_size_cap_test.py b/tests/unit_tests/datasource/cache_size_cap_test.py
index b8c6d4d161d..175f7433494 100644
--- a/tests/unit_tests/datasource/cache_size_cap_test.py
+++ b/tests/unit_tests/datasource/cache_size_cap_test.py
@@ -158,3 +158,55 @@ def test_compatible_normal_result_is_cached(
         "compatible_metrics": ["count"],
         "compatible_dimensions": ["country"],
     }
+
+
+@pytest.fixture
+def null_data_cache(mocker: MockerFixture) -> MagicMock:
+    """A data cache backed by ``NullCache`` (caching disabled)."""
+    from flask_caching.backends import NullCache
+
+    cache = mocker.patch("superset.datasource.api.cache_manager").data_cache
+    cache.get.return_value = None
+    cache.cache = NullCache()
+    return cache
+
+
+def test_column_values_null_cache_skips_serialization(
+    client: Any,
+    full_api_access: None,
+    datasource: MagicMock,
+    null_data_cache: MagicMock,
+    stats_logger: MagicMock,
+    mocker: MockerFixture,
+) -> None:
+    """With caching disabled nothing is written and the payload is never pickled
+    to measure it against ``DATA_CACHE_MAX_VALUE_SIZE``."""
+    datasource.values_for_column.return_value = ["a", "b"]
+    mock_dumps = mocker.patch("superset.utils.cache.pickle.dumps")
+
+    response = _get_values(client)
+
+    assert response.status_code == 200
+    assert response.json["result"] == ["a", "b"]
+    mock_dumps.assert_not_called()
+    null_data_cache.set.assert_not_called()
+
+
+def test_compatible_null_cache_skips_serialization(
+    client: Any,
+    full_api_access: None,
+    datasource: MagicMock,
+    null_data_cache: MagicMock,
+    stats_logger: MagicMock,
+    mocker: MockerFixture,
+) -> None:
+    """With caching disabled nothing is written and the result is never pickled."""
+    datasource.get_compatible_metrics.return_value = ["count"]
+    datasource.get_compatible_dimensions.return_value = ["country"]
+    mock_dumps = mocker.patch("superset.utils.cache.pickle.dumps")
+
+    response = _post_compatible(client)
+
+    assert response.status_code == 200
+    mock_dumps.assert_not_called()
+    null_data_cache.set.assert_not_called()
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py b/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
index 33523f49f81..2570bc8093c 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
@@ -1250,6 +1250,10 @@ def _mock_values_dataset(
 class _FakeDataCache:
     """A dict-backed stand-in for ``cache_manager.data_cache``."""
 
+    # The backend behind a Flask-Caching ``Cache``; anything but ``NullCache``
+    # means caching is enabled.
+    cache: object = object()
+
     def __init__(self) -> None:
         self.entries: dict[str, Any] = {}
         self.timeouts: dict[str, int] = {}
@@ -1261,6 +1265,10 @@ class _FakeDataCache:
         self.entries[key] = value
         self.timeouts[key] = timeout
 
+    def delete(self, key: str) -> bool:
+        self.timeouts.pop(key, None)
+        return self.entries.pop(key, None) is not None
+
 
 @pytest.fixture(autouse=True)
 def data_cache() -> Iterator[_FakeDataCache]:
diff --git a/tests/unit_tests/sql/execution/conftest.py b/tests/unit_tests/sql/execution/conftest.py
index dfdd64e254d..fc7b17a6665 100644
--- a/tests/unit_tests/sql/execution/conftest.py
+++ b/tests/unit_tests/sql/execution/conftest.py
@@ -309,6 +309,22 @@ def default_sql_config(mocker: MockerFixture) -> None:
     )
 
 
+@pytest.fixture
+def enabled_data_cache(mocker: MockerFixture) -> MagicMock:
+    """Give the executor a data cache that is not a ``NullCache``.
+
+    The unit-test app configures no data cache, so ``_store_in_cache`` would
+    return before writing. Returns the mocked data cache; its ``get`` misses.
+    """
+    data_cache = MagicMock()
+    data_cache.get.return_value = None
+    mocker.patch(
+        "superset.sql.execution.executor.cache_manager",
+        MagicMock(data_cache=data_cache),
+    )
+    return data_cache
+
+
 @pytest.fixture
 def mock_celery_task(mocker: MockerFixture) -> MagicMock:
     """Mock the Celery task for SQL execution."""
diff --git a/tests/unit_tests/sql/execution/test_async_results.py b/tests/unit_tests/sql/execution/test_async_results.py
index 40e66ccb4ab..92ae3cc7221 100644
--- a/tests/unit_tests/sql/execution/test_async_results.py
+++ b/tests/unit_tests/sql/execution/test_async_results.py
@@ -380,7 +380,9 @@ class DictCache:
         return True
 
 
-def test_cache_hit_returns_dataframes(mocker: MockerFixture, app_context: None) -> None:
+def test_cache_hit_returns_dataframes(
+    mocker: MockerFixture, app_context: None, enabled_data_cache: MagicMock
+) -> None:
     """
     Results served from the data cache keep the ``StatementResult`` contract:
     SELECT data is a DataFrame (empty ones keep their columns, integer nulls
@@ -393,12 +395,11 @@ def test_cache_hit_returns_dataframes(mocker: MockerFixture, app_context: None)
         StatementResult,
     )
 
-    from superset.extensions import cache_manager
     from superset.models.core import Database
 
     cache = DictCache()
-    mocker.patch.object(cache_manager.data_cache, "get", side_effect=cache.get)
-    mocker.patch.object(cache_manager.data_cache, "set", side_effect=cache.set)
+    enabled_data_cache.get.side_effect = cache.get
+    enabled_data_cache.set.side_effect = cache.set
     database = Database(id=1, database_name="db", sqlalchemy_uri="sqlite://")
     executor = SQLExecutor(database)
     sql = "SELECT a FROM t; SELECT b FROM t WHERE 1 = 0; UPDATE t SET a = 1"
@@ -510,15 +511,16 @@ def test_async_temporal_results_match_sync(
 
 
 def test_temporal_cache_hit_matches_sync(
-    mocker: MockerFixture, app_context: None, database: Database
+    mocker: MockerFixture,
+    app_context: None,
+    database: Database,
+    enabled_data_cache: MagicMock,
 ) -> None:
     """The shared records reader keeps Python temporal values on cache hits."""
     from datetime import date, datetime
 
     from superset_core.queries.types import QueryOptions, QueryResult, StatementResult
 
-    from superset.extensions import cache_manager
-
     sql = "SELECT timestamp_value, date_value FROM t"
     expected = _result_set(
         ["timestamp_value", "date_value"],
@@ -527,8 +529,8 @@ def test_temporal_cache_hit_matches_sync(
     executor = SQLExecutor(database)
     mocker.patch.object(executor, "_generate_cache_key", return_value="temporal-key")
     cache = DictCache()
-    mocker.patch.object(cache_manager.data_cache, "get", side_effect=cache.get)
-    mocker.patch.object(cache_manager.data_cache, "set", side_effect=cache.set)
+    enabled_data_cache.get.side_effect = cache.get
+    enabled_data_cache.set.side_effect = cache.set
     executor._store_in_cache(
         QueryResult(
             status=QueryStatus.SUCCESS,
@@ -636,14 +638,13 @@ def test_timezone_cache_hit_dtype_matches_sync(
     mocker: MockerFixture,
     app_context: None,
     database: Database,
+    enabled_data_cache: MagicMock,
     tz: Any,
     data_kind: str,
 ) -> None:
     """Sync and async cache hits keep the original timezone and timestamp unit."""
     from superset_core.queries.types import QueryOptions
 
-    from superset.extensions import cache_manager
-
     sql = "SELECT value FROM t"
     rows = [
         (datetime(2026, 1, 2, 3, tzinfo=tz),),
@@ -658,8 +659,8 @@ def test_timezone_cache_hit_dtype_matches_sync(
     # Cache records carry no DataFrame index.
     sync.statements[0].data = sync.statements[0].data.reset_index(drop=True)
     cache = DictCache()
-    mocker.patch.object(cache_manager.data_cache, "get", side_effect=cache.get)
-    mocker.patch.object(cache_manager.data_cache, "set", side_effect=cache.set)
+    enabled_data_cache.get.side_effect = cache.get
+    enabled_data_cache.set.side_effect = cache.set
     executor = SQLExecutor(database)
     mocker.patch.object(executor, "_generate_cache_key", return_value="tz-key")
     executor._store_in_cache(sync, sql, QueryOptions())
diff --git a/tests/unit_tests/sql/execution/test_executor.py b/tests/unit_tests/sql/execution/test_executor.py
index c193dd19cdf..dd61879af94 100644
--- a/tests/unit_tests/sql/execution/test_executor.py
+++ b/tests/unit_tests/sql/execution/test_executor.py
@@ -2721,7 +2721,10 @@ def test_async_handle_cancel_query_not_found(
 
 
 def test_execute_uses_database_cache_timeout(
-    mocker: MockerFixture, database: Database, app_context: None
+    mocker: MockerFixture,
+    database: Database,
+    app_context: None,
+    enabled_data_cache: MagicMock,
 ) -> None:
     """Test that database cache timeout is used when available."""
     from superset.sql.execution.executor import SQLExecutor
@@ -2742,19 +2745,21 @@ def test_execute_uses_database_cache_timeout(
 
     # Mock cache operations
     mocker.patch.object(SQLExecutor, "_get_from_cache", return_value=None)
-    mock_cache_set = mocker.patch("superset.extensions.cache_manager.data_cache.set")
+    mock_cache_set = enabled_data_cache.set
 
     result = database.execute("SELECT * FROM users")
 
     assert result.status == QueryStatus.SUCCESS
     # Verify cache timeout used
-    if mock_cache_set.called:
-        call_kwargs = mock_cache_set.call_args[1]
-        assert call_kwargs.get("timeout") == 600
+    mock_cache_set.assert_called_once()
+    assert mock_cache_set.call_args.kwargs["timeout"] == 600
 
 
 def test_execute_uses_custom_cache_timeout_option(
-    mocker: MockerFixture, database: Database, app_context: None
+    mocker: MockerFixture,
+    database: Database,
+    app_context: None,
+    enabled_data_cache: MagicMock,
 ) -> None:
     """Test that custom cache timeout from options is used."""
     from superset.sql.execution.executor import SQLExecutor
@@ -2773,16 +2778,15 @@ def test_execute_uses_custom_cache_timeout_option(
 
     # Mock cache operations
     mocker.patch.object(SQLExecutor, "_get_from_cache", return_value=None)
-    mock_cache_set = mocker.patch("superset.extensions.cache_manager.data_cache.set")
+    mock_cache_set = enabled_data_cache.set
 
     options = QueryOptions(cache=CacheOptions(timeout=1200))
     result = database.execute("SELECT * FROM users", options=options)
 
     assert result.status == QueryStatus.SUCCESS
     # Verify custom timeout used
-    if mock_cache_set.called:
-        call_kwargs = mock_cache_set.call_args[1]
-        assert call_kwargs.get("timeout") == 1200
+    mock_cache_set.assert_called_once()
+    assert mock_cache_set.call_args.kwargs["timeout"] == 1200
 
 
 # =============================================================================
@@ -2923,7 +2927,10 @@ def test_try_get_cached_result_with_mutation(
 
 
 def test_store_in_cache_with_failed_status(
-    mocker: MockerFixture, database: Database, app_context: None
+    mocker: MockerFixture,
+    database: Database,
+    app_context: None,
+    enabled_data_cache: MagicMock,
 ) -> None:
     """Test that failed queries are not cached."""
     from superset_core.queries.types import QueryResult as QueryResultType
@@ -2937,7 +2944,7 @@ def test_store_in_cache_with_failed_status(
         error_message="Test error",
     )
 
-    mock_cache_set = mocker.patch("superset.extensions.cache_manager.data_cache.set")
+    mock_cache_set = enabled_data_cache.set
 
     executor._store_in_cache(failed_result, "SELECT 1", QueryOptions())
 
@@ -2946,7 +2953,10 @@ def test_store_in_cache_with_failed_status(
 
 
 def test_store_in_cache_with_no_data(
-    mocker: MockerFixture, database: Database, app_context: None
+    mocker: MockerFixture,
+    database: Database,
+    app_context: None,
+    enabled_data_cache: MagicMock,
 ) -> None:
     """Test that DML queries (with no data) are cached."""
     from superset_core.queries.types import (
@@ -2970,7 +2980,7 @@ def test_store_in_cache_with_no_data(
         ],
     )
 
-    mock_cache_set = mocker.patch("superset.extensions.cache_manager.data_cache.set")
+    mock_cache_set = enabled_data_cache.set
 
     executor._store_in_cache(result_no_data, "INSERT INTO t VALUES (1)", QueryOptions())
 
@@ -3382,7 +3392,10 @@ def test_get_from_cache_skips_when_identity_unknown(
 
 
 def test_store_in_cache_skips_when_identity_unknown(
-    mocker: MockerFixture, database: Database, app_context: None
+    mocker: MockerFixture,
+    database: Database,
+    app_context: None,
+    enabled_data_cache: MagicMock,
 ) -> None:
     """
     ``_store_in_cache`` must not write to the cache backend at all when
@@ -3393,13 +3406,12 @@ def test_store_in_cache_skips_when_identity_unknown(
         StatementResult,
     )
 
-    from superset.extensions import cache_manager
     from superset.sql.execution.executor import SQLExecutor
 
     database.impersonate_user = True
     executor = SQLExecutor(database)
     mocker.patch("superset.sql.execution.executor.utils.get_user_id", return_value=None)
-    mock_cache_set = mocker.patch.object(cache_manager.data_cache, "set")
+    mock_cache_set = enabled_data_cache.set
 
     result = QueryResultType(
         status=QueryStatus.SUCCESS,
@@ -3631,12 +3643,12 @@ def test_truncation_survives_cache_round_trip(
     mocker: MockerFixture,
     database: Database,
     app_context: None,
+    enabled_data_cache: MagicMock,
     truncated: bool,
 ) -> None:
     """Cache serialization retains fetch truncation independently of row count."""
     from superset_core.queries.types import QueryResult, StatementResult
 
-    from superset.extensions import cache_manager
     from superset.sql.execution.executor import SQLExecutor
 
     executor = SQLExecutor(database)
@@ -3652,18 +3664,19 @@ def test_truncation_survives_cache_round_trip(
             )
         ],
     )
-    cache_set = mocker.patch.object(cache_manager.data_cache, "set")
+    cache_set = enabled_data_cache.set
     executor._store_in_cache(result, "SELECT 1", QueryOptions())
-    mocker.patch.object(
-        cache_manager.data_cache, "get", return_value=cache_set.call_args.args[1]
-    )
+    enabled_data_cache.get.return_value = cache_set.call_args.args[1]
     cached = executor._get_from_cache("SELECT 1", QueryOptions())
     assert cached is not None
     assert cached.statements[0].truncated is truncated
 
 
 def _store_in_cache_with_cap(
-    mocker: MockerFixture, database: Database, row_count: int
+    mocker: MockerFixture,
+    database: Database,
+    data_cache: MagicMock,
+    row_count: int,
 ) -> tuple[MagicMock, MagicMock, MagicMock]:
     """Run ``_store_in_cache`` for a ``row_count``-row result under a 1 KB cap.
 
@@ -3674,7 +3687,6 @@ def _store_in_cache_with_cap(
         StatementResult,
     )
 
-    from superset.extensions import cache_manager
     from superset.sql.execution.executor import SQLExecutor
 
     stats_logger = MagicMock()
@@ -3686,7 +3698,7 @@ def _store_in_cache_with_cap(
             "CACHE_DEFAULT_TIMEOUT": 300,
         },
     )
-    mock_cache_set = mocker.patch.object(cache_manager.data_cache, "set")
+    mock_cache_set = data_cache.set
     mock_logger = mocker.patch("superset.utils.cache.logger")
 
     result = QueryResultType(
@@ -3709,13 +3721,16 @@ def _store_in_cache_with_cap(
 
 
 def test_store_in_cache_skips_oversized_result(
-    mocker: MockerFixture, database: Database, app_context: None
+    mocker: MockerFixture,
+    database: Database,
+    app_context: None,
+    enabled_data_cache: MagicMock,
 ) -> None:
     """A result larger than ``DATA_CACHE_MAX_VALUE_SIZE`` is not written; the skip
     is logged and counted. A later ``_get_from_cache`` simply misses and the query
     re-runs."""
     mock_cache_set, stats_logger, mock_logger = _store_in_cache_with_cap(
-        mocker, database, row_count=500
+        mocker, database, enabled_data_cache, row_count=500
     )
 
     mock_cache_set.assert_not_called()
@@ -3724,12 +3739,54 @@ def test_store_in_cache_skips_oversized_result(
 
 
 def test_store_in_cache_writes_result_under_cap(
-    mocker: MockerFixture, database: Database, app_context: None
+    mocker: MockerFixture,
+    database: Database,
+    app_context: None,
+    enabled_data_cache: MagicMock,
 ) -> None:
     """A result under ``DATA_CACHE_MAX_VALUE_SIZE`` is cached as usual."""
     mock_cache_set, stats_logger, _ = _store_in_cache_with_cap(
-        mocker, database, row_count=2
+        mocker, database, enabled_data_cache, row_count=2
     )
 
     mock_cache_set.assert_called_once()
     stats_logger.incr.assert_not_called()
+
+
+def test_store_in_cache_null_cache_skips_serialization(
+    mocker: MockerFixture, database: Database, app_context: None
+) -> None:
+    """With caching disabled (``NullCache``) nothing is written and the result is
+    never pickled to measure it against ``DATA_CACHE_MAX_VALUE_SIZE``."""
+    from flask_caching.backends import NullCache
+    from superset_core.queries.types import (
+        QueryResult as QueryResultType,
+        StatementResult,
+    )
+
+    from superset.sql.execution.executor import SQLExecutor
+
+    data_cache = MagicMock()
+    data_cache.cache = NullCache()
+    mocker.patch(
+        "superset.sql.execution.executor.cache_manager",
+        MagicMock(data_cache=data_cache),
+    )
+    mocker.patch.dict(current_app.config, {"DATA_CACHE_MAX_VALUE_SIZE": 1024})
+    mock_dumps = mocker.patch("superset.utils.cache.pickle.dumps")
+
+    result = QueryResultType(
+        status=QueryStatus.SUCCESS,
+        statements=[
+            StatementResult(
+                original_sql="SELECT 1",
+                executed_sql="SELECT 1",
+                data=pd.DataFrame({"a": [1]}),
+                row_count=1,
+            )
+        ],
+    )
+    SQLExecutor(database)._store_in_cache(result, "SELECT 1", QueryOptions())
+
+    mock_dumps.assert_not_called()
+    data_cache.set.assert_not_called()
diff --git a/tests/unit_tests/tasks/test_async_queries.py b/tests/unit_tests/tasks/test_async_queries.py
index 3e58afdf6e0..068b6803059 100644
--- a/tests/unit_tests/tasks/test_async_queries.py
+++ b/tests/unit_tests/tasks/test_async_queries.py
@@ -306,11 +306,6 @@ def test_execute_chart_query_publishes_cache_key_payload(
         "superset.tasks.async_queries.get_context",
         return_value=task_context,
     )
-    # The result was persisted, so the client's read-back will hit.
-    mocker.patch(
-        "superset.tasks.async_queries.cache_manager.data_cache.has",
-        return_value=True,
-    )
 
     execute_chart_query.func(_serialized_query(), user_id=7)
 
@@ -320,58 +315,6 @@ def test_execute_chart_query_publishes_cache_key_payload(
     )
 
 
-def test_execute_chart_query_fails_loudly_when_result_not_cached(
-    mocker: MockerFixture,
-) -> None:
-    """A successful-but-unpersisted result fails the task instead of resubmitting.
-
-    When a result exceeds ``DATA_CACHE_MAX_VALUE_SIZE`` the cache write is skipped
-    but the query still succeeds. Async delivery relies on the client reading the
-    result back from the DATA cache, so a missing entry makes the endpoint's
-    ``force_cached`` read raise ``CacheLoadError`` and reschedule this task. If the
-    task reported success, the client would resubmit it endlessly. The task must
-    raise so the client sees a clear terminal error, and must not publish a
-    cache-key payload that would drive another read-back/resubmit cycle.
-    """
-    from superset.exceptions import SupersetException
-    from superset.tasks.async_queries import execute_chart_query
-
-    query_obj = mocker.MagicMock()
-    query_context = mocker.MagicMock()
-    query_context.queries = [query_obj]
-    # Query succeeded (no "error"), but the value was skipped for size.
-    query_context.get_df_payload_result.return_value.payload = {
-        "cache_key": "oversized-cache-key",
-        "error": None,
-    }
-    task_context = mocker.MagicMock()
-
-    mocker.patch(
-        "superset.tasks.async_queries._resolve_user",
-        return_value=mocker.MagicMock(),
-    )
-    mocker.patch("superset.tasks.async_queries.override_user")
-    mocker.patch(
-        "superset.tasks.async_queries.load_serialized_query",
-        return_value=query_context,
-    )
-    mocker.patch(
-        "superset.tasks.async_queries.get_context",
-        return_value=task_context,
-    )
-    # The oversized value was never written, so the read-back would miss.
-    mocker.patch(
-        "superset.tasks.async_queries.cache_manager.data_cache.has",
-        return_value=False,
-    )
-
-    with pytest.raises(SupersetException, match="DATA_CACHE_MAX_VALUE_SIZE"):
-        execute_chart_query.func(_serialized_query(), user_id=7)
-
-    # It must NOT publish a cache-key payload the client would resubmit against.
-    task_context.update_task.assert_not_called()
-
-
 def test_execute_chart_query_reestablishes_form_data(
     mocker: MockerFixture,
 ) -> None:
@@ -487,10 +430,6 @@ def test_execute_chart_query_reads_totals_key_from_dependency_payload(
         "superset.tasks.async_queries.get_context",
         return_value=task_context,
     )
-    mocker.patch(
-        "superset.tasks.async_queries.cache_manager.data_cache.has",
-        return_value=True,
-    )
 
     execute_chart_query.func(_serialized_query(), user_id=7, requires_totals=True)
 


(superset) 03/04: fix(cache): remove the older cached value when an oversized write is skipped

Posted by el...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

eschutho pushed a commit to branch fix-cache-max-value-size-default
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 0417a0b593e2d10ff8f1ad0d93f1b492463bf9f6
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Fri Sep 25 19:37:04 2026 +0000

    fix(cache): remove the older cached value when an oversized write is skipped
    
    When a value is too large for DATA_CACHE_MAX_VALUE_SIZE, any older value
    under the same key stayed in the cache and the next ordinary load served it,
    even after a forced refresh had computed a fresher, larger result.
    
    - Add skip_oversized_cache_value() in superset/utils/cache.py: on a size
      skip it deletes the existing key (best-effort; a failure is logged, never
      raised, and it is a no-op for NullCache). Keep the WARNING and the
      skip_cache_value_too_large counter.
    - Use it in set_and_log_cache, the datasource API's _set_data_cache and
      SQLExecutor._store_in_cache.
    - Add an end-to-end test (cache small result, force-refresh an oversized one,
      normal load recomputes) and writer-level delete tests.
    - Docs/UPDATING: state that an oversized result removes the older cached copy
      and that the recompute after an async query runs inside the web request.
    
    Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
 UPDATING.md                                        | 13 ++--
 docs/admin_docs/configuration/cache.mdx            | 11 ++--
 superset/config.py                                 |  7 ++-
 superset/datasource/api.py                         |  7 ++-
 superset/sql/execution/executor.py                 |  9 +--
 superset/utils/cache.py                            | 50 ++++++++++++---
 .../common/test_oversized_result_readback.py       | 27 ++++++++
 tests/unit_tests/datasource/cache_size_cap_test.py | 30 ++++++++-
 tests/unit_tests/sql/execution/test_executor.py    |  4 ++
 tests/unit_tests/utils/cache_test.py               | 73 ++++++++++++++++++++++
 10 files changed, 200 insertions(+), 31 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index 9ef9a9aae76..dd7341b9399 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -664,11 +664,14 @@ incomplete snapshot is not exposed as restorable history.
 
 `DATA_CACHE_MAX_VALUE_SIZE` limits the serialized size of a single value written
 to the data cache. It defaults to `10 * 1024 * 1024` (10 MB) instead of `None`.
-Values larger than the limit are not stored in the cache; the request still
-returns its data, and the value is recomputed from the datasource the next time
-it is requested. This includes the follow-up request a chart sends after a
-background (async) query finishes: that request recomputes the result instead of
-reading it from the cache, so an oversized async chart runs its query twice.
+Values larger than the limit are not stored in the cache, and any older cached
+copy stored under the same key is removed, so stale data is not shown. The
+request still returns its data, and the value is recomputed from the datasource
+the next time it is requested. This includes the follow-up request a chart sends
+after a background (async) query finishes: that request recomputes the result
+instead of reading it from the cache, so an oversized async chart runs its query
+twice. That recompute happens inside the web request itself, so a very slow, very
+large query is subject to the web server's request timeout.
 
 The limit applies to every writer to the data cache: chart results, SQL query
 results from the SQL executor, filter dropdown column values, and compatible
diff --git a/docs/admin_docs/configuration/cache.mdx b/docs/admin_docs/configuration/cache.mdx
index bf6e7cdd48b..a7b84823306 100644
--- a/docs/admin_docs/configuration/cache.mdx
+++ b/docs/admin_docs/configuration/cache.mdx
@@ -192,10 +192,13 @@ set it in `superset_config.py`:
 DATA_CACHE_MAX_VALUE_SIZE = 20 * 1024 * 1024  # 20 MB
 ```
 
-When a value's serialized size exceeds this threshold it is not written to the data cache. The
-request still returns its data, and the value is recomputed from the datasource the next time it is
-requested. With `GLOBAL_ASYNC_QUERIES`, that includes the follow-up request a chart sends after its
-background query finishes, so an oversized async chart runs its query twice. Each skip emits a
+When a value's serialized size exceeds this threshold it is not written to the data cache, and any
+older cached copy stored under the same key is removed, so stale data is not shown. The request still
+returns its data, and the value is recomputed from the datasource the next time it is requested.
+With `GLOBAL_ASYNC_QUERIES`, that includes the follow-up request a chart sends after its background
+query finishes, so an oversized async chart runs its query twice. That recompute happens inside the
+web request itself, so a very slow, very large query is subject to the web server's request timeout.
+Each skip emits a
 WARNING log naming the key and byte size, and increments the `skip_cache_value_too_large` statsd
 metric. Set to `None` to disable the check entirely (no serialization overhead is then incurred).
 
diff --git a/superset/config.py b/superset/config.py
index 4eb9a9eaa94..07b28e4b4cf 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1507,9 +1507,10 @@ 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, filter dropdown values, and compatible
 # metrics/dimensions). A value whose pickled size exceeds this threshold is NOT
-# written to the cache: the request still succeeds, and the value is recomputed
-# the next time it is requested, including the follow-up request a chart sends
-# after a background (async) query. This protects the cache backend (e.g.
+# written to the cache, and any older value under the same key is removed so it is
+# not served: the request still succeeds, and the value is recomputed the next
+# time it is requested, including the follow-up request a chart sends after a
+# background (async) query. This protects the cache backend (e.g.
 # Redis/Memcached) from being flooded by a heavy tail of very large result sets,
 # which can drive the backend toward its memory limit and evict many smaller,
 # useful entries. Each skip emits a WARNING log naming the key and byte size and
diff --git a/superset/datasource/api.py b/superset/datasource/api.py
index 1fa53c081f7..23d87cb9024 100644
--- a/superset/datasource/api.py
+++ b/superset/datasource/api.py
@@ -59,7 +59,7 @@ from superset.semantic_layers.mapper import SUPPORTED_FILTER_OPERATORS
 from superset.semantic_layers.models import SemanticView
 from superset.superset_typing import FlaskResponse
 from superset.utils import json
-from superset.utils.cache import exceeds_max_cache_value_size
+from superset.utils.cache import skip_oversized_cache_value
 from superset.utils.core import (
     apply_max_row_limit,
     DatasourceType,
@@ -76,13 +76,14 @@ logger = logging.getLogger(__name__)
 def _set_data_cache(cache_key: str, value: Any, timeout: int) -> None:
     """Write ``value`` to the data cache unless it exceeds
     ``DATA_CACHE_MAX_VALUE_SIZE``. An oversized value is left uncached, so the next
-    request misses and recomputes it from the datasource. With caching disabled
+    request misses and recomputes it from the datasource; any older value under
+    the key is removed so it is not served instead. With caching disabled
     (``NullCache``) nothing is written and the size check is skipped, so the value
     is never serialized."""
     data_cache = cache_manager.data_cache
     if isinstance(data_cache.cache, NullCache):
         return
-    if not exceeds_max_cache_value_size(cache_key, value):
+    if not skip_oversized_cache_value(data_cache, cache_key, value):
         data_cache.set(cache_key, value, timeout=timeout)
 
 
diff --git a/superset/sql/execution/executor.py b/superset/sql/execution/executor.py
index 3f6249f01d0..8931bfe5eee 100644
--- a/superset/sql/execution/executor.py
+++ b/superset/sql/execution/executor.py
@@ -88,7 +88,7 @@ from superset.sql.execution.serialization import deserialize_nested_columns
 from superset.sql.parse import SQLScript
 from superset.superset_typing import FetchedRows
 from superset.utils import core as utils, json
-from superset.utils.cache import exceeds_max_cache_value_size
+from superset.utils.cache import skip_oversized_cache_value
 
 if TYPE_CHECKING:
     from superset_core.queries.types import (
@@ -1329,9 +1329,10 @@ class SQLExecutor:
             "total_execution_time_ms": result.total_execution_time_ms,
         }
 
-        # An oversized result is left uncached; ``_get_from_cache`` then misses and
-        # the query re-runs on the next request.
-        if exceeds_max_cache_value_size(cache_key, cached_data):
+        # An oversized result is left uncached and any older result under the key
+        # is removed; ``_get_from_cache`` then misses and the query re-runs on the
+        # next request.
+        if skip_oversized_cache_value(cache_manager.data_cache, cache_key, cached_data):
             return
 
         cache_manager.data_cache.set(
diff --git a/superset/utils/cache.py b/superset/utils/cache.py
index 46522e8ecde..d56aa719fef 100644
--- a/superset/utils/cache.py
+++ b/superset/utils/cache.py
@@ -57,13 +57,13 @@ def generate_cache_key(values_dict: dict[str, Any], key_prefix: str = "") -> str
 def exceeds_max_cache_value_size(cache_key: str, value: Any) -> bool:
     """Check a value against ``DATA_CACHE_MAX_VALUE_SIZE`` before a data-cache write.
 
-    Every writer to the data cache calls this so that one oversized value cannot
-    flood the cache backend (e.g. Redis/Memcached) and evict many smaller entries.
-    When the serialized (pickled) size of ``value`` is larger than the limit, a
-    WARNING naming the key and size is logged and the
-    ``skip_cache_value_too_large`` statsd counter is incremented; the caller must
-    then skip the write. A skipped write is an ordinary cache miss: the next read
-    re-runs the query.
+    This keeps one oversized value from flooding the cache backend (e.g.
+    Redis/Memcached) and evicting many smaller entries. When the serialized
+    (pickled) size of ``value`` is larger than the limit, a WARNING naming the key
+    and size is logged and the ``skip_cache_value_too_large`` statsd counter is
+    incremented; the caller must then skip the write. Cache writers use
+    :func:`skip_oversized_cache_value`, which also removes any older value stored
+    under the key.
 
     :returns: ``True`` when the value is too large and must not be cached. Always
         ``False`` when ``DATA_CACHE_MAX_VALUE_SIZE`` is ``None``, in which case the
@@ -86,6 +86,35 @@ def exceeds_max_cache_value_size(cache_key: str, value: Any) -> bool:
     return True
 
 
+def skip_oversized_cache_value(
+    cache_instance: Cache, cache_key: str, value: Any
+) -> bool:
+    """Decide whether a data-cache write must be skipped for size, and if so remove
+    any older value stored under the same key.
+
+    Every writer to the data cache calls this before writing. Without the delete,
+    an older, smaller value under ``cache_key`` would survive the skipped write and
+    be served on the next read, even though a fresher result was just computed.
+    With the delete, the next read misses and recomputes. Deleting is best-effort:
+    a failure is logged and never raised, and it is a no-op for ``NullCache``.
+
+    :returns: ``True`` when ``value`` exceeds ``DATA_CACHE_MAX_VALUE_SIZE`` and the
+        caller must not write it (see :func:`exceeds_max_cache_value_size`).
+    """
+    if not exceeds_max_cache_value_size(cache_key, value):
+        return False
+    if not isinstance(cache_instance.cache, NullCache):
+        try:
+            cache_instance.delete(cache_key)
+        except Exception:  # pylint: disable=broad-except
+            logger.warning(
+                "Could not delete cache key %s after skipping an oversized value",
+                cache_key,
+                exc_info=True,
+            )
+    return True
+
+
 def set_and_log_cache(
     cache_instance: Cache,
     cache_key: str,
@@ -122,9 +151,10 @@ def set_and_log_cache(
 
         # 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.
-        if exceeds_max_cache_value_size(cache_key, value):
+        # still renders; the value is not cached and any older value under the
+        # key is removed, so the next load re-queries instead of reading a cache
+        # entry.
+        if skip_oversized_cache_value(cache_instance, cache_key, value):
             return False
 
         # Flask-Caching's set() returns bool | None: cachelib backends can report
diff --git a/tests/unit_tests/common/test_oversized_result_readback.py b/tests/unit_tests/common/test_oversized_result_readback.py
index 3d04af05e96..45f806e6655 100644
--- a/tests/unit_tests/common/test_oversized_result_readback.py
+++ b/tests/unit_tests/common/test_oversized_result_readback.py
@@ -144,3 +144,30 @@ def test_normal_size_result_is_read_from_cache_on_follow_up(
     assert processor.get_query_result.call_count == 1
     assert follow_up_payload["is_cached"] is True
     assert follow_up_payload["rowcount"] == 2
+
+
+def test_oversized_refresh_removes_older_cached_result(
+    mocker: MockerFixture, data_cache: Cache
+) -> None:
+    """A result too large to cache removes the older cached copy under its key.
+
+    A small result is cached, the data then grows past the cap, and a forced
+    refresh computes the large result (which cannot be stored). The next ordinary
+    load must recompute rather than serve the older, smaller cached result.
+    """
+    # 1. An ordinary load caches a small result.
+    _processor(mocker, rows=10, force=False).get_df_payload_result(_query_object(None))
+    assert data_cache.get(CACHE_KEY) is not None
+
+    # 2-3. The data grows; a forced refresh (background task and its follow-up)
+    # recomputes the full, now oversized, result.
+    _run_task_then_follow_up(mocker, rows=2000, force=True)
+    assert data_cache.get(CACHE_KEY) is None
+
+    # 4. The next ordinary load recomputes instead of serving the old 10 rows.
+    processor = _processor(mocker, rows=2000, force=False)
+    payload = processor.get_df_payload_result(_query_object(None)).payload
+
+    processor.get_query_result.assert_called_once()
+    assert not payload["is_cached"]
+    assert payload["rowcount"] == 2000
diff --git a/tests/unit_tests/datasource/cache_size_cap_test.py b/tests/unit_tests/datasource/cache_size_cap_test.py
index 175f7433494..b87c2075177 100644
--- a/tests/unit_tests/datasource/cache_size_cap_test.py
+++ b/tests/unit_tests/datasource/cache_size_cap_test.py
@@ -18,8 +18,8 @@
 
 The column-values endpoint (filter dropdown values) and the compatible
 metrics/dimensions endpoint write to the data cache directly. An oversized
-payload is left uncached and still returned to the caller; a normal payload is
-cached as usual.
+payload is left uncached (and any older value under its key removed) and still
+returned to the caller; a normal payload is cached as usual.
 """
 
 from typing import Any
@@ -100,6 +100,9 @@ def test_column_values_oversized_payload_is_not_cached(
     assert response.status_code == 200
     assert response.json["result"] == values
     data_cache.set.assert_not_called()
+    # An older value under the same key is removed so it is not served later.
+    data_cache.delete.assert_called_once()
+    assert data_cache.delete.call_args.args[0].startswith("col_values:")
     stats_logger.incr.assert_any_call("skip_cache_value_too_large")
     mock_logger.warning.assert_called_once()
 
@@ -118,6 +121,7 @@ def test_column_values_normal_payload_is_cached(
     assert response.status_code == 200
     data_cache.set.assert_called_once()
     assert data_cache.set.call_args.args[1] == ["a", "b"]
+    data_cache.delete.assert_not_called()
     assert call("skip_cache_value_too_large") not in stats_logger.incr.mock_calls
 
 
@@ -137,6 +141,8 @@ def test_compatible_oversized_result_is_not_cached(
     assert response.status_code == 200
     assert response.json["result"]["compatible_metrics"] == metrics
     data_cache.set.assert_not_called()
+    data_cache.delete.assert_called_once()
+    assert data_cache.delete.call_args.args[0].startswith("compatible:")
     stats_logger.incr.assert_any_call("skip_cache_value_too_large")
 
 
@@ -190,6 +196,7 @@ def test_column_values_null_cache_skips_serialization(
     assert response.json["result"] == ["a", "b"]
     mock_dumps.assert_not_called()
     null_data_cache.set.assert_not_called()
+    null_data_cache.delete.assert_not_called()
 
 
 def test_compatible_null_cache_skips_serialization(
@@ -210,3 +217,22 @@ def test_compatible_null_cache_skips_serialization(
     assert response.status_code == 200
     mock_dumps.assert_not_called()
     null_data_cache.set.assert_not_called()
+
+
+def test_column_values_oversized_delete_failure_still_returns_values(
+    client: Any,
+    full_api_access: None,
+    datasource: MagicMock,
+    data_cache: MagicMock,
+    stats_logger: MagicMock,
+) -> None:
+    """A failure deleting the older cached value does not break the request."""
+    values = [f"value-{i:05d}" for i in range(500)]
+    datasource.values_for_column.return_value = values
+    data_cache.delete.side_effect = RuntimeError("backend down")
+
+    response = _get_values(client)
+
+    assert response.status_code == 200
+    assert response.json["result"] == values
+    data_cache.set.assert_not_called()
diff --git a/tests/unit_tests/sql/execution/test_executor.py b/tests/unit_tests/sql/execution/test_executor.py
index dd61879af94..dc304612a12 100644
--- a/tests/unit_tests/sql/execution/test_executor.py
+++ b/tests/unit_tests/sql/execution/test_executor.py
@@ -3734,6 +3734,8 @@ def test_store_in_cache_skips_oversized_result(
     )
 
     mock_cache_set.assert_not_called()
+    # An older result under the same key is removed so it is not served later.
+    enabled_data_cache.delete.assert_called_once()
     stats_logger.incr.assert_called_once_with("skip_cache_value_too_large")
     mock_logger.warning.assert_called_once()
 
@@ -3750,6 +3752,7 @@ def test_store_in_cache_writes_result_under_cap(
     )
 
     mock_cache_set.assert_called_once()
+    enabled_data_cache.delete.assert_not_called()
     stats_logger.incr.assert_not_called()
 
 
@@ -3790,3 +3793,4 @@ def test_store_in_cache_null_cache_skips_serialization(
 
     mock_dumps.assert_not_called()
     data_cache.set.assert_not_called()
+    data_cache.delete.assert_not_called()
diff --git a/tests/unit_tests/utils/cache_test.py b/tests/unit_tests/utils/cache_test.py
index 6adac429d22..866e5b64afa 100644
--- a/tests/unit_tests/utils/cache_test.py
+++ b/tests/unit_tests/utils/cache_test.py
@@ -595,3 +595,76 @@ def test_exceeds_max_cache_value_size_boundary(mocker: MockerFixture) -> None:
         exact_size,
         exact_size - 1,
     )
+
+
+def test_set_and_log_cache_oversized_deletes_existing_key(
+    mocker: MockerFixture,
+) -> None:
+    """Skipping an oversized value removes any older value under the same key, so
+    a later read recomputes instead of serving stale data. The skip is still
+    logged and counted."""
+    from superset.utils.cache import set_and_log_cache
+
+    config = _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=10)
+    cache_instance = _make_cache_instance(mocker)
+    mock_logger = mocker.patch("superset.utils.cache.logger")
+
+    assert set_and_log_cache(cache_instance, "my_key", {"df": "x" * 100}) is False
+
+    cache_instance.set.assert_not_called()
+    cache_instance.delete.assert_called_once_with("my_key")
+    config["STATS_LOGGER"].incr.assert_called_once_with("skip_cache_value_too_large")
+    mock_logger.warning.assert_called_once()
+
+
+def test_set_and_log_cache_oversized_delete_failure_is_logged(
+    mocker: MockerFixture,
+) -> None:
+    """A failure while deleting the older value is logged, not raised."""
+    from superset.utils.cache import set_and_log_cache
+
+    _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=10)
+    cache_instance = _make_cache_instance(mocker)
+    cache_instance.delete.side_effect = RuntimeError("backend down")
+    mock_logger = mocker.patch("superset.utils.cache.logger")
+
+    assert set_and_log_cache(cache_instance, "my_key", {"df": "x" * 100}) is False
+
+    cache_instance.delete.assert_called_once_with("my_key")
+    cache_instance.set.assert_not_called()
+    assert any(
+        "Could not delete" in str(c.args[0]) for c in mock_logger.warning.mock_calls
+    )
+    mock_logger.exception.assert_not_called()
+
+
+def test_set_and_log_cache_under_threshold_does_not_delete(
+    mocker: MockerFixture,
+) -> None:
+    """A value that fits is written normally; nothing is deleted."""
+    from superset.utils.cache import set_and_log_cache
+
+    _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()
+    cache_instance.delete.assert_not_called()
+
+
+def test_skip_oversized_cache_value_null_cache_does_not_delete(
+    mocker: MockerFixture,
+) -> None:
+    """With a ``NullCache`` backend there is nothing to remove: the oversized value
+    is still reported as skipped, and ``delete`` is not called."""
+    from flask_caching.backends import NullCache
+
+    from superset.utils.cache import skip_oversized_cache_value
+
+    _patch_config(mocker, DATA_CACHE_MAX_VALUE_SIZE=10)
+    cache_instance = mocker.MagicMock()
+    cache_instance.cache = NullCache()
+
+    assert skip_oversized_cache_value(cache_instance, "my_key", "x" * 100) is True
+    cache_instance.delete.assert_not_called()