Skip to content

fix(caching): sort extra_cache_keys before hashing (#34543) - #42597

Merged
rusackas merged 4 commits into
masterfrom
tdd/issue-34543-extra-cache-keys-order
Aug 12, 2026
Merged

fix(caching): sort extra_cache_keys before hashing (#34543)#42597
rusackas merged 4 commits into
masterfrom
tdd/issue-34543-extra-cache-keys-order

Conversation

@rusackas

@rusackas rusackas commented Jul 30, 2026

Copy link
Copy Markdown
Member

SUMMARY

Fixes #34543. Embedded dashboards with multiple Jinja url_param() filters failed async chart-data cache retrieval with a 422 Unprocessable Entity / "Error loading data from cache", while a single url_param worked fine.

Root cause: SqlaTable.get_extra_cache_keys() (superset/connectors/sqla/models.py) returns list(set(extra_cache_keys)). Python randomizes string hashing per-process (PYTHONHASHSEED), so the same set of url_param() values can iterate in a different order in the Celery worker process (which writes the query results to cache) than in the web process (which later re-derives the cache key to read them back). QueryObject.cache_key() merges that list straight into the dict it hashes, in whatever order it arrives; hash_from_dict() only sorts dict keys, never list values. A single-element list has only one possible order, which is exactly why the bug is only visible with 2+ url_params, matching the reported single-vs-multi-parameter split precisely.

This was originally opened as a test-only TDD PR pinning the gap down; this update adds the actual fix.

THE FIX

QueryObject.cache_key() (superset/common/query_object.py): sort extra_cache_keys (by str, since entries are just Hashable, not guaranteed mutually orderable) right where it enters the dict that gets hashed, before calling hash_from_dict(). Order carries no meaning for this field, it's an unordered set of opaque Jinja-derived values, so normalizing it here makes any current or future producer of extra_cache_keys safe by construction, rather than patching each producer individually.

TESTING INSTRUCTIONS

pytest tests/unit_tests/queries/query_object_test.py -v

test_cache_key_stable_regardless_of_extra_cache_keys_order was expected/confirmed red before the fix (asserts two otherwise-identical query objects with extra_cache_keys differing only in order produce the same cache key); now green. Confirmed the rest of tests/unit_tests/queries/, tests/unit_tests/common/, and tests/unit_tests/charts/ (344 tests) are unaffected.

ADDITIONAL INFORMATION

🤖 Generated with Claude Code

@dosubot dosubot Bot added global:async-query Related to Async Queries feature infra:caching Infra setup and configuration related to caching labels Jul 30, 2026
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 66.74%. Comparing base (885f001) to head (49593d1).

Files with missing lines Patch % Lines
superset/common/query_object.py 50.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master   #42597   +/-   ##
=======================================
  Coverage   66.74%   66.74%           
=======================================
  Files        2862     2862           
  Lines      161748   161750    +2     
  Branches    37312    37313    +1     
=======================================
+ Hits       107962   107966    +4     
+ Misses      51729    51727    -2     
  Partials     2057     2057           
Flag Coverage Δ
hive 38.42% <0.00%> (-0.01%) ⬇️
mysql 58.06% <50.00%> (-0.01%) ⬇️
postgres 58.09% <50.00%> (+<0.01%) ⬆️
presto 40.40% <50.00%> (+<0.01%) ⬆️
python 59.50% <50.00%> (+<0.01%) ⬆️
sqlite 57.72% <50.00%> (-0.01%) ⬇️
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread tests/unit_tests/queries/query_object_test.py
@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged issue is correct. The current regression test uses only strings, which masks potential TypeError exceptions that occur when sorting heterogeneous types (e.g., strings, integers, and None) in production. To resolve this, update the test case to include a mixed-type list in extra_cache_keys to ensure the sorting logic handles them correctly.

Here is the updated test case:

    query_object1 = QueryObject(row_limit=1)
    query_object2 = QueryObject(row_limit=1)
    # Mixed types to test sorting robustness
    mixed_values_different_order = ["CAR_IDS=1,2,3", 123, None]
    cache_key1 = query_object1.cache_key(extra_cache_keys=mixed_values_different_order)
    cache_key2 = query_object2.cache_key(
        extra_cache_keys=list(reversed(mixed_values_different_order))
    )
    assert cache_key1 == cache_key2

Would you like me to check the rest of the comments on this PR and implement fixes for them as well?

tests/unit_tests/queries/query_object_test.py

query_object1 = QueryObject(row_limit=1)
    query_object2 = QueryObject(row_limit=1)
    # Mixed types to test sorting robustness
    mixed_values_different_order = ["CAR_IDS=1,2,3", 123, None]
    cache_key1 = query_object1.cache_key(extra_cache_keys=mixed_values_different_order)
    cache_key2 = query_object2.cache_key(
        extra_cache_keys=list(reversed(mixed_values_different_order))
    )
    assert cache_key1 == cache_key2

@bito-code-review bito-code-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Agent Run #fc1c01

Actionable Suggestions - 1
  • tests/unit_tests/queries/query_object_test.py - 1
    • Test reveals unfixed cache key ordering bug · Line 89-114
Review Details
  • Files reviewed - 1 · Commit Range: a0e2dc6..a0e2dc6
    • tests/unit_tests/queries/query_object_test.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment thread tests/unit_tests/queries/query_object_test.py
query_object1 = QueryObject(row_limit=1)
query_object2 = QueryObject(row_limit=1)
same_values_different_order = ["CAR_IDS=1,2,3", "CHASSIS_IDS=100,200"]
cache_key1 = query_object1.cache_key(extra_cache_keys=same_values_different_order)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion requires canonicalizing extra_cache_keys inside QueryObject.cache_key, so fixing the nondeterminism at SqlaTable.get_extra_cache_keys would resolve both this path and the legacy BaseViz consumer but still leave this test red. Could this exercise the producer boundary instead so the regression test does not force the narrower fix location?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with sorting at cache_key() over the producer since there's more than one path feeding extra_cache_keys in, get_extra_cache_keys() and query_context_processor.py both land there. One sort at the boundary covers both instead of chasing each producer.

query_object2 = QueryObject(row_limit=1)
same_values_different_order = ["CAR_IDS=1,2,3", "CHASSIS_IDS=100,200"]
cache_key1 = query_object1.cache_key(extra_cache_keys=same_values_different_order)
cache_key2 = query_object2.cache_key(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The real producer appends only raw url_param() values, so list position is what distinguishes which template call produced each value. Making ["1", "2"] equivalent to ["2", "1"] lets swapped parameters render different SQL while sharing a data-cache key and can return the wrong cached rows. Could this test deterministic ordered deduplication at get_extra_cache_keys() instead of declaring the final list order-insensitive?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SqlaTable.get_extra_cache_keys already does list(set(extra_cache_keys)) before this ever reaches cache_key(), so any positional signal from url_param() call order is already gone by the time we see it. Nothing to lose by sorting.

Comment thread tests/unit_tests/queries/query_object_test.py
@pull-request-size pull-request-size Bot added size/M and removed size/S labels Aug 3, 2026
@rusackas rusackas changed the title test(caching): pin cache-key stability against extra_cache_keys order (#34543) fix(caching): sort extra_cache_keys before hashing (#34543) Aug 3, 2026
@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 49593d1
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a7bddeb2d158100087c27de
😎 Deploy Preview https://deploy-preview-42597--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@bito-code-review

bito-code-review Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #c49b89

Actionable Suggestions - 0
Review Details
  • Files reviewed - 1 · Commit Range: a0e2dc6..48cc9ee
    • superset/common/query_object.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

rusackas added a commit that referenced this pull request Aug 3, 2026
Per review feedback on #42597: the extra_cache_keys ordering fix
canonicalizes only that field, not list values generically. Add a
companion test asserting orderby order still changes the cache key,
so a future refactor can't accidentally widen the canonicalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread superset/common/query_object.py
rusackas added a commit that referenced this pull request Aug 3, 2026
Per review feedback on #42597: the extra_cache_keys ordering fix
canonicalizes only that field, not list values generically. Add a
companion test asserting orderby order still changes the cache key,
so a future refactor can't accidentally widen the canonicalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rusackas
rusackas force-pushed the tdd/issue-34543-extra-cache-keys-order branch from 3d6bc70 to dd86b72 Compare August 3, 2026 21:39
@bito-code-review

bito-code-review Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #9fedff

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: 758611f..dd86b72
    • superset/common/query_object.py
    • tests/unit_tests/queries/query_object_test.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@bito-code-review

bito-code-review Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #ac7024

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: dd86b72..096583f
    • superset/common/query_object.py
    • tests/unit_tests/queries/query_object_test.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@aminghadersohi aminghadersohi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Traced the full producer→consumer path at 096583f to settle the correctness question behind the two open threads. Summary up front so Joe can decide on his threads.

Producer boundary: the values reach cache_key() already set-derived

Every path into QueryObject.cache_key's extra_cache_keys comes through QueryContextProcessor.query_cache_keydatasource.get_extra_cache_keys(...). The SqlaTable implementation ends with:

# superset/connectors/sqla/models.py:2242
return list(set(extra_cache_keys))

The other implementations (Query, semantic layer) return []. So by the time the list reaches cache_key(), the raw positional url_param() appends in cache_key_wrapper (jinja_context.py:254) have already been collapsed into a set — position is destroyed and values are deduplicated before this code ever sees them.

That makes the in-code comment ("order carries no meaning — an unordered set") accurate for the real value domain, and it means sorting a set-derived list is a pure canonicalization: sorted(set(X)) maps each distinct set to exactly one deterministic list under the total order (type name, str value). It cannot merge two distinct sets, so it introduces zero collisions the existing set() didn't already have.

Re: the data-corruption thread (["1","2"]["2","1"] serving wrong rows)

url_param appends the interpolated value only (jinja_context.py:304), not a (param, value) pair, and the producer already set()s the result. So {"1","2"} and {"2","1"} were the same set — and thus the same intended cache key — before this PR. Pre-PR they only sometimes collided (whichever way set iteration happened to land that process); the actual observable bug was the opposite of corruption — a cache miss across web/worker processes with different PYTHONHASHSEED. This PR removes that nondeterminism without widening any equivalence class. I don't see a data-correctness regression on this path.

Fix-location thread — agree on merit

The legacy BaseViz.cache_key consumes the same set-derived list and is not fixed by this PR:

# superset/viz.py:489, 502
cache_dict["extra_cache_keys"] = self.datasource.get_extra_cache_keys(query_obj)
...
json_data = self.json_dumps(cache_dict, sort_keys=True)   # sorts dict KEYS, not list VALUES

sort_keys=True canonicalizes dict keys but leaves the extra_cache_keys list order untouched, so the legacy path keeps the exact nondeterminism this PR removes from QueryObject. Fixing at the producer — return sorted(set(extra_cache_keys), key=lambda v: (type(v).__name__, str(v))) in SqlaTable.get_extra_cache_keys — would cover both consumers in one place, and the code comment's "so every producer of extra_cache_keys is safe by construction" would then actually hold for every consumer too. viz.py is @deprecated(3.0), so this is lower-urgency and defensible to defer, but Joe's architectural point is correct: the producer is the more complete location. Worth at least a comment noting the legacy path is knowingly left as-is. (These two points are Joe's to resolve, not mine.)

Other scope (no blocking findings)

  • TypeError / mixed types: safe. The key (type(value).__name__, str(value)) yields a (str, str) tuple for every Hashable (None('NoneType','None'), 1('int','1'), "1"('str','1'), tuples→('tuple', '...')), which is always totally ordered. No incomparable-type TypeError reaches the chart-data path. codeant's earlier key=str tie concern is genuinely resolved by the type-qualified key.
  • Blast radius: the branch is a strict no-op when extra_cache_keys is absent (guarded by if "extra_cache_keys" in cache_dict), so only Jinja/url_param charts are touched. Expect a one-time cache-invalidation event on deploy for those entries (they were keyed on unstable order and were already missing cross-process), which is acceptable and consistent with the existing invalidation TODO just below.
  • Negative control: present and correct. test_cache_key_sensitive_to_orderby_order asserts reordering orderby still changes the key, which would fail under a generic "canonicalize every list" fix — so it properly pins the fix to extra_cache_keys specifically rather than to a blanket hash_from_dict change.

Rule-26 note (test env here lacks superset_core, so this is by inspection): reverting only the query_object.py hunk fails the two order-stability tests and leaves the orderby negative control passing — the tests target the production line rather than tautologies.

@rusackas

rusackas commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

@aminghadersohi thanks for tracing that through, matches how I read it too. Went to add a note on the legacy viz.py cache_key, but turns out that whole file's gone from master already (#41714 removed the legacy viz.py pipeline), so the concern's moot there. Rebased the branch onto current master while I was in there.

claude and others added 4 commits August 11, 2026 19:43
This is a test-only PR opened as a TDD-style validation of issue #34543.

#34543 (filed 2025-08) reports that embedded dashboards with multiple
Jinja url_param() filters fail async cache retrieval with a 422 "Error
loading data from cache", while a single url_param works fine.

Root cause: SqlaTable.get_extra_cache_keys() (superset/connectors/sqla/models.py)
returns list(set(extra_cache_keys)). Python randomizes string hashing
per-process, so the same set of url_param values can iterate in a
different order in the Celery worker (which writes the query results
to cache) than in the web process (which re-derives the cache key to
read them back). hash_from_dict() only sorts dict keys, not list
values, so two extra_cache_keys lists with identical values but
different order hash to different cache keys. A single-element list
has only one possible order, which is why the bug only appears with
multiple parameters.

This PR adds one regression test on QueryObject.cache_key():

1. test_cache_key_stable_regardless_of_extra_cache_keys_order -
   asserts the cache key is identical for two otherwise-equal query
   objects whose extra_cache_keys differ only in order.

Closes #34543

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
QueryObject.cache_key() merges extra_cache_keys (produced by
SqlaTable.get_extra_cache_keys, ultimately from Jinja url_param()
calls) straight into the hashed cache_dict in whatever order the
caller passed. hash_from_dict only sorts dict keys via
json.dumps(sort_keys=True), never list contents, so two otherwise-
identical queries whose extra_cache_keys list the same values in a
different order hash to different cache keys.

SqlaTable.get_extra_cache_keys itself returns list(set(...)), and
Python's per-process string-hash randomization means that set can
iterate in a different order in the Celery worker that writes a
chart's cached result than in the web process that later re-derives
the cache key to read it back, whenever 2+ url_params are involved
(a single-element list has only one possible order, matching the
reported single-vs-multi-parameter split exactly).

Order carries no meaning for this field, it's a set of opaque
Jinja-derived values, so sort it once where it enters cache_key()
rather than at every producer, making any future extra_cache_keys
source safe by construction (this also covers the pre-existing
call site in superset/common/query_context_processor.py, which
passes datasource.get_extra_cache_keys() straight through the same
cache_key(extra_cache_keys=...) path).

Closes #34543

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per review feedback on #42597: the extra_cache_keys ordering fix
canonicalizes only that field, not list values generically. Add a
companion test asserting orderby order still changes the cache key,
so a future refactor can't accidentally widen the canonicalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`sorted(..., key=str)` treats values with identical string
representations (e.g. `1` and `"1"`) as equal, so ties could still fall
back to non-deterministic set-iteration order. Sort on (type name, str
value) instead so ties can't happen, plus a regression test for it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rusackas
rusackas force-pushed the tdd/issue-34543-extra-cache-keys-order branch from 49fd9c0 to 49593d1 Compare August 12, 2026 02:43
@bito-code-review

bito-code-review Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #bb3da7

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: f331643..49593d1
    • superset/common/query_object.py
    • tests/unit_tests/queries/query_object_test.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@rusackas
rusackas merged commit 8f6587d into master Aug 12, 2026
67 of 69 checks passed
@rusackas
rusackas deleted the tdd/issue-34543-extra-cache-keys-order branch August 12, 2026 06:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

global:async-query Related to Async Queries feature infra:caching Infra setup and configuration related to caching preset-io size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Superset Embedded Dashboard Cache Retrieval Fails with Multi-Parameter Jinja Filters

4 participants