This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 04db66b0f66 feat(semantic-layer): invalidate metadata caches on
configuration save (#45100)
04db66b0f66 is described below
commit 04db66b0f6627cd94f4f1c67b3577677a3f4e757
Author: Mike Bridge <[email protected]>
AuthorDate: Sat Oct 10 16:26:59 2026 -0600
feat(semantic-layer): invalidate metadata caches on configuration save
(#45100)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Enzo Martellucci <[email protected]>
---
UPDATING.md | 14 +
.../extensions/contribution-types.md | 4 +-
docs/developer_docs/semantic-layer-cache-clear.md | 106 ++++
docs/developer_docs/semantic-metadata-contract.md | 187 ------
.../src/superset_core/semantic_layers/layer.py | 20 +-
.../src/superset_core/semantic_layers/metadata.py | 196 -------
.../src/superset_core/semantic_layers/view.py | 10 +-
.../semanticLayers/SemanticLayerModal.test.tsx | 130 ++++-
.../features/semanticLayers/SemanticLayerModal.tsx | 75 ++-
superset/commands/semantic_layer/update.py | 58 +-
superset/common/query_context_processor.py | 41 +-
superset/config.py | 5 +
superset/datasource/api.py | 8 +-
...179af013f48_add_semantic_layer_cache_version.py | 42 ++
superset/semantic_layers/api.py | 53 ++
superset/semantic_layers/models.py | 140 ++++-
superset/translations/messages.pot | 9 +
.../common/test_query_context_processor.py | 12 +-
.../semantic_layers/metadata_contract_test.py | 566 +-----------------
tests/unit_tests/semantic_layers/models_test.py | 10 +-
.../semantic_layers/simple_cache_clear_test.py | 650 +++++++++++++++++++++
.../semantic_layers/values_endpoint_test.py | 10 +-
22 files changed, 1362 insertions(+), 984 deletions(-)
diff --git a/UPDATING.md b/UPDATING.md
index 40ef0301342..3ae2e280ac6 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,20 @@ assists people when migrating to a new version.
## Next
+- Semantic-layer configuration Save and Reload metadata increment a
database-backed
+ cache generation. Apply the `c179af013f48` migration before deploying the
updated
+ application; existing semantic chart caches become cold once on upgrade.
+ Providers with local metadata caches must adopt the optional cache-token
factory
+ hook for complete invalidation. When tenants share a metadata database or
web,
+ Celery and MCP use different database host aliases, configure
+ `SEMANTIC_LAYER_CACHE_NAMESPACE` as a globally unique workspace identity that
+ resolves identically in all three contexts. A metadata session must not span
+ workspaces. Saves handled by old instances during a rolling deployment do not
+ bump the generation; repeat Reload metadata after rollout for those changes.
+ After restoring a metadata database backup, clear surviving data/provider
caches
+ or rotate the workspace namespace to avoid reusing generations from the
backup.
+ See the [cache invalidation
contract](docs/developer_docs/semantic-layer-cache-clear.md).
+
- Malformed explicit `time_range` values are rejected with a validation error
(HTTP 400 on chart-data requests) instead of silently producing an
upper-bound-only
scan. Update saved charts, dashboard filters, imports, and API callers to use
diff --git a/docs/developer_docs/extensions/contribution-types.md
b/docs/developer_docs/extensions/contribution-types.md
index 92c233b9128..416df699d27 100644
--- a/docs/developer_docs/extensions/contribution-types.md
+++ b/docs/developer_docs/extensions/contribution-types.md
@@ -245,8 +245,8 @@ See [MCP Integration](./mcp.md) for implementation details.
### Semantic Layers
-For optional catalog acquisition and host publication, see the
-[semantic provider metadata contract](../semantic-metadata-contract.md).
+See [cache invalidation](../semantic-layer-cache-clear.md) for the optional
+cache-token factory hook and provider cache requirements.
Extensions can register custom semantic layer implementations that allow
Superset to connect to external data modeling frameworks. Each semantic layer
defines how to authenticate, discover semantic views
(tables/metrics/dimensions), and execute queries against the external system.
diff --git a/docs/developer_docs/semantic-layer-cache-clear.md
b/docs/developer_docs/semantic-layer-cache-clear.md
new file mode 100644
index 00000000000..760096674bf
--- /dev/null
+++ b/docs/developer_docs/semantic-layer-cache-clear.md
@@ -0,0 +1,106 @@
+---
+title: Semantic layer cache invalidation
+sidebar_position: 21
+---
+
+<!--
+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.
+-->
+
+# Save configuration and reload metadata
+
+Saving a semantic layer's configuration increments its metadata database
+`cache_version` in the same transaction, including when the configuration is
+unchanged. Description-only updates leave the version unchanged. Failed saves
+roll back both configuration and version. `configuration_version` continues to
+identify the configuration format, independently of cache invalidation.
+
+For upstream-only changes, open the saved layer's configuration editor and
select
+**Reload metadata**. Unsaved edits must be saved or discarded first. The action
+clears the host cache generation, shows **Cache cleared; reload to fetch
metadata**,
+and closes the editor. Reload the affected Explore or dashboard tab to fetch
+metadata, using the browser's existing unsaved-change guards. Save retains its
+ordinary close-and-refresh behavior; reload the affected tab afterward too.
+
+`POST /api/v1/semantic_layer/<uuid>/clear_cache` accepts only `{}`. It requires
+`can_write` on `SemanticLayer`, access to that layer, and connection
modification
+authority (`current_user_can_modify_object`). Guests and view-edit-only users
+cannot clear a connection. Success means the database increment committed; it
+performs no provider discovery and does not assert upstream health. A
subsequent
+read failure is separate from a successful Save or clear.
+
+## Provider SDK contract
+
+Providers may override this optional factory, invoked before eager discovery:
+
+```python
+@classmethod
+def from_configuration_with_cache_token(
+ cls, configuration: dict[str, Any], *, cache_token: str
+) -> SemanticLayer:
+ ...
+```
+
+The default delegates to `from_configuration(configuration)`, preserving legacy
+providers. The host captures a database/workspace-scoped layer UUID and version
+once per metadata session (the ordinary request/task session); this is the
layer's
+**metadata generation**, passed to providers as `cache_token`. The namespace is
+an opaque digest of the metadata database backend, host, port and database
name;
+credentials, driver adapters, connection query options and `SECRET_KEY` do not
+affect it. `SEMANTIC_LAYER_CACHE_NAMESPACE`, when nonempty, replaces that
database
+identity with an operator-provided, globally unique workspace name. Configure
it
+when routing through different database host aliases or sharing a database
across
+tenants. A callable may return the active workspace, but web, Celery and MCP
+contexts must resolve the same value for the same workspace. The host captures
it
+once per layer per session. A metadata session must not span workspaces. No URL
+or credential is exposed in the token.
+
+Providers with process-local metadata caches must include the supplied token
+alongside existing endpoint, credential, role and view scope **before**
+any dictionary lookup or eager discovery. Their returned SDK views echo the
+token they were discovered under through `metadata_cache_token`; never relabel
+previously cached members with a later token. Runtime schema helpers receiving
+the layer's configuration must preserve its operation identity too. Keep
+existing expiries and prune retired dictionary generations to bound memory.
+Providers without local metadata caches may carry the token onward; this does
+not clear vendor or warehouse server caches.
+Until a provider honours this hook in its caches, it cannot promise complete
+metadata reload support. Provider adoption must accompany host deployment for
+complete invalidation.
+
+Host result, annotation-source, compatibility and value-suggestion cache keys
+include the captured generation (`SemanticView.metadata_generation` on the host
+ORM view) while preserving their other key dimensions. The SDK view's
+`metadata_cache_token` is only the provider's echo and never keys host caches.
+Containment caches must likewise key on the host generation, optionally
+alongside the provider token; the host ORM view also exposes it as
+`metadata_cache_token` for that integration. A legacy SDK view may return
+`None`; that must not disable host invalidation. Provider tokens keep their
+existing `str | None` contract. The containment integration is tracked
+in [#42760](https://github.com/apache/superset/pull/42760).
+
+Old entries expire normally. A page reload clears browser structure promises
and
+rehydrates Explore state. Reload does not create/delete saved views, widen
+allow-lists, rewrite chart selections, or automatically execute charts. Removed
+members produce ordinary validation errors.
+
+An in-flight fetch during a clear can repopulate a stale entry; clear again if
+necessary. Captured keys normally keep that entry under its old version. There
+is no coordinated publication, fencing, polling, revalidation or guarantee that
+all workers observe an identical upstream catalog. Responses already in flight
+are not recalled.
diff --git a/docs/developer_docs/semantic-metadata-contract.md
b/docs/developer_docs/semantic-metadata-contract.md
deleted file mode 100644
index 40be00dce05..00000000000
--- a/docs/developer_docs/semantic-metadata-contract.md
+++ /dev/null
@@ -1,187 +0,0 @@
----
-title: Semantic provider metadata contract
----
-
-<!--
-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.
--->
-
-# Semantic provider metadata contract
-
-The optional interfaces in `superset_core.semantic_layers.metadata` let a
provider
-use a host-owned catalog store. This SDK defines the boundary; it does not
supply
-storage, authorization, endpoints or a refresh UI. Existing providers need no
new
-overrides.
-
-## Opting in
-
-`SemanticLayer.supports_metadata_refresh(configuration)` defaults to `False`.
-Overrides must determine support without construction, network requests or
cache
-writes. `SemanticLayer.metadata_refresh` defaults to `None`; an opted-in
provider
-returns a `MetadataRefreshAdapter`. The property must return the same instance
-for the layer's lifetime; views and runtime-schema discovery use that bound
-instance. If support is declared but the instance is absent, an enabled host
must
-reject the configuration rather than silently using legacy cache keys.
-
-The host authorizes and resolves the stored connection before constructing the
-provider. It calls `adapter.bind(store, deadline=deadline)` once, before
discovery. Rebinding raises
-`MetadataRefreshError("configuration")`; an unbound refresh raises
-`MetadataRefreshError("unsupported")`. Invalid or exhausted bind deadlines
raise
-`MetadataRefreshError("deadline")` before binding and leave the adapter
unbound.
-
-Full and custom views use the bound store. The adapter's instance
-`get_runtime_schema(runtime_data=None)` returns the existing schema shape using
-that same store, avoiding the legacy classmethod's unbound construction path.
-Unsaved configuration previews cannot publish into a stored connection
namespace.
-A bound store failure must not silently fall back to provider-local caching.
-Existing unbound preview/classmethod discovery remains a provider
responsibility;
-it never publishes into the stored connection namespace. The in-memory example
-below demonstrates the bound path, not a complete preview implementation.
-
-One bound scope contains one catalog covering that connection's supported
runtime
-configurations. A provider needing different catalogs per runtime
database/schema
-must not opt in until its scope can satisfy that requirement.
-
-Discovery must be identical for every user of that connection scope. Providers
-whose discovery depends on per-user OAuth credentials, impersonation or another
-user-specific identity must not declare `supports_metadata_refresh`; the shared
-catalog has no per-user dimension. Query authorization and result-cache
user/RLS
-identity remain separate host responsibilities.
-
-## Data exchanged
-
-| Type | Fields / signature | Meaning |
-| --- | --- | --- |
-| `CatalogLoader` | `Callable[[float], str]` | Acquisition within a
host-supplied absolute monotonic deadline returning provider-validated
canonical JSON; valid empty data is allowed |
-| `CatalogSnapshot` | `payload: str`, `cache_token: str`, `observed_at: str` |
Immutable observation and its captured scope-qualified identity |
-| `MetadataRefreshResult` | `status: Literal["changed", "unchanged"]`,
`snapshot: CatalogSnapshot` | Confirmed publication, not just completed
acquisition |
-| `MetadataSnapshotStore.read` | `(fetch: CatalogLoader, *, deadline: float)
-> CatalogSnapshot` | Valid observation or bounded coordinated acquisition |
-| `MetadataRefreshAdapter.bind` | `(store: MetadataSnapshotStore, *, deadline:
float) -> None` | Bind once and capture the host operation's discovery deadline
|
-| `MetadataRefreshAdapter.refresh` | `(*, deadline: float) ->
MetadataRefreshResult` | Pass the caller's budget to the bound store |
-| `MetadataSnapshotStore.refresh` | `(fetch: CatalogLoader, *, deadline:
float) -> MetadataRefreshResult` | Bypass a hit and confirm a fresh publication
|
-
-The provider validates and interprets its payload. The host owns scope, expiry,
-authorization and publication. These records do not validate vendor JSON or
-implement coordination. `observed_at` is a UTC RFC3339 source-observation time,
-not entry creation, expiry or an ordering authority. `CatalogSnapshot` rejects
-non-string fields and empty tokens/timestamps with fixed messages that do not
echo
-input. Timestamp syntax remains the producer's responsibility; this SDK record
-does not parse RFC3339 or validate canonical JSON.
-
-Every successful publication gets a new nonempty opaque `cache_token`,
including
-unchanged discovery. Cache hits retain the captured token. Equal discovery data
-cannot prove equal metric definitions if the upstream selector omits complete
-query semantics. `changed` includes installation without a prior observation.
-
-`SemanticView.metadata_cache_token` defaults to `None` for legacy views. A
bound
-opted-in view returns the token captured with its parsed members. Hosts reject
-missing tokens in that mode and include the captured token in
metadata-dependent
-compatibility and result-cache keys, preserving view/query/access identities.
-An old result must never be labelled with a token fetched later. Host/provider
-implementations must enforce and test these obligations.
-
-## Failures and concurrency
-
-`MetadataRefreshErrorCategory` is the typed set accepted by
`MetadataRefreshError`:
-`unsupported`, `configuration`, `in_progress`, `configuration_changed`,
`upstream`,
-`invalid_payload`, `deadline`, `unavailable` and `indeterminate`. Producers
pass
-these categories, not raw vendor messages or credentials; the constructor
rejects
-unknown values with a fixed error that does not echo them. Hosts must not
expose
-exception cause chains. Providers translating sensitive vendor errors should
use
-`raise MetadataRefreshError("upstream") from None`. HTTP status codes and
-localized text belong to the host.
-
-- `configuration_changed`: the authorized connection scope/configuration
changed during the operation; discard the candidate rather than publish under
obsolete authority.
-- `invalid_payload`: acquisition or a stored observation cannot satisfy the
provider's catalog schema; never publish partial metadata or an error as an
empty catalog.
-- `unavailable`: host-owned metadata storage or coordination cannot be reached
or used; `upstream` instead identifies failure to acquire metadata from the
provider's remote service.
-
-The host establishes one finite absolute `float` deadline using
-`time.monotonic()` in the calling process before waiting or acquisition. It
passes
-that value explicitly to `adapter.bind(store, deadline=deadline)`,
-`adapter.refresh(deadline=deadline)`,
-`store.read(fetch, deadline=deadline)` and `store.refresh(fetch,
deadline=deadline)`.
-The adapter forwards it to the store, and the store passes it to
`fetch(deadline)`
-unchanged. Multiple calls within the same operation share this deadline through
-publication; neither the store nor nested acquisition may mint a new budget.
-Implementations use `remaining_budget` to reject non-finite, expired or
-implausibly large deadlines before I/O, including cache reads. The protocol
-signatures do not execute these checks on behalf of implementations.
-The provider checks
-the remaining time before each upstream operation and uses the tighter of that
-budget and its own transport limit. Exhaustion raises
-`MetadataRefreshError("deadline")` before further I/O; nested calls must not
restart
-the budget. Checking only after an unbounded request is insufficient. Actual
-transport and publication budget enforcement require host/provider tests.
-
-The existing layer/view and runtime-schema discovery signatures stay
compatible.
-Ordinary `from_configuration(configuration)` construction remains
deadline-free.
-The host hands the operation's deadline to `bind`; the adapter captures it for
-full/custom-view and runtime-schema discovery, passing it unchanged to store
-reads. A new operation requires a fresh provider/adapter instance and a new
bind,
-not rebinding or renewing an existing instance's discovery budget. The explicit
-store/refresh parameters remain required. The example exercises normal provider
-construction followed by this bind-time handoff.
-
-Use `remaining_budget(deadline)` before I/O and publication. It returns the
-positive remaining seconds and raises `MetadataRefreshError("deadline")` for
-non-finite, expired or implausibly large budgets. The SDK ceiling
-`MAX_METADATA_BUDGET_SECONDS = 300.0` permits slow metadata discovery while
-catching common `time.time()`/monotonic clock confusion; it is not a default
or a
-reason to extend the caller's budget. Longer requests must be split into
bounded
-operations. Hosts/providers may use tighter limits. For a host with an injected
-monotonic clock, `remaining_budget(deadline, now=clock())` uses that same clock
-without introducing host types into the SDK.
-
-This value is neither a duration nor a UTC timestamp. Do not serialize it
across
-processes, use it as cache expiry, or use it to order publications. A deadline
-cannot cancel arbitrary synchronous code and never replaces a publication
fence.
-
-An active explicit refresh can report `in_progress`. Cold readers wait/re-read
-within a finite deadline or fail safely. A delayed writer cannot replace a
later
-accepted observation. Failure never installs an error as an empty catalog or
-renews freshness. Unconfirmed publication reports `indeterminate`; a timeout
proves
-neither success nor rollback. These guarantees require real host backend tests.
-
-## Separate host cache controls and diagnostics
-
-Catalog invalidation, independent compatibility invalidation, selected-query
-result refresh and read-only cache inspection are host responsibilities. No
extra
-provider methods are required. Catalog invalidation retires both older writer
-authority and derived-cache validity. Compatibility-only invalidation need not
-fetch metadata; result refresh does not imply catalog refresh.
-
-Inspection reports recorded entry creation and expiry separately from source
-observation. It must not fetch, fill or renew entries. Missing legacy
timestamps
-remain unknown. Cache payloads, credentials and raw keys are not diagnostic
-fields. Storage instrumentation, access enforcement and UI presentation are not
-functionality supplied by these SDK records.
-
-## Compatibility and verification
-
-No new abstract methods are added to the existing layer or view bases.
Providers
-importing this API must require a released SDK containing it: a disabled
feature
-cannot make a missing import safe. Fleet-wide freshness requires a compatible
-host/provider fleet and a coordinated store. Upstream query-version pinning and
-vendor-cache clearing are not promised.
-
-`tests/unit_tests/semantic_layers/metadata_contract_test.py` contains a
minimal legacy
-provider and an opted-in in-memory example. It checks captured observations,
-duplicate-label/raw-ID preservation, immutable records, field validation,
explicit shared deadlines, error categories and
-imports without host dependencies in a fresh isolated subprocess. The test file
-is collected by the standard unit-test CI lane. The sequential example does
not establish
-multiworker or backend correctness.
diff --git a/superset-core/src/superset_core/semantic_layers/layer.py
b/superset-core/src/superset_core/semantic_layers/layer.py
index de7ec01f35e..bbc85ced8e3 100644
--- a/superset-core/src/superset_core/semantic_layers/layer.py
+++ b/superset-core/src/superset_core/semantic_layers/layer.py
@@ -22,7 +22,6 @@ from typing import Any, ClassVar, Generic, TypeVar
from pydantic import BaseModel
-from superset_core.semantic_layers.metadata import MetadataRefreshAdapter
from superset_core.semantic_layers.view import SemanticView
ConfigT = TypeVar("ConfigT", bound=BaseModel)
@@ -40,18 +39,17 @@ class SemanticLayer(ABC, Generic[ConfigT, SemanticViewT]):
result_cache_version: ClassVar[str | None] = None
@classmethod
- def supports_metadata_refresh(cls, configuration: dict[str, Any]) -> bool:
- """Declare opt-in support without construction, discovery or other
I/O."""
- return False
-
- @property
- def metadata_refresh(self) -> MetadataRefreshAdapter | None:
- """Return a stable adapter instance, or None for legacy metadata
behavior.
+ def from_configuration_with_cache_token(
+ cls, configuration: dict[str, Any], *, cache_token: str
+ ) -> SemanticLayer[ConfigT, SemanticViewT]:
+ """Construct with the host's metadata generation before any discovery.
- An opted-in layer returns the same adapter for its lifetime. Its views
- and runtime schema use the instance bound by the host.
+ ``cache_token`` is an opaque generation that changes when the layer's
+ metadata is reloaded. Providers with local metadata caches override
this
+ factory to include it in their keys before construction or lookup.
Legacy
+ providers retain their ordinary construction behavior.
"""
- return None
+ return cls.from_configuration(configuration)
@classmethod
@abstractmethod
diff --git a/superset-core/src/superset_core/semantic_layers/metadata.py
b/superset-core/src/superset_core/semantic_layers/metadata.py
deleted file mode 100644
index 7cdfce0613a..00000000000
--- a/superset-core/src/superset_core/semantic_layers/metadata.py
+++ /dev/null
@@ -1,196 +0,0 @@
-# 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.
-
-"""Optional provider metadata acquisition and host publication interfaces."""
-
-from __future__ import annotations
-
-from abc import ABC, abstractmethod
-from collections.abc import Callable
-from dataclasses import dataclass, field
-from math import isfinite
-from time import monotonic
-from typing import Any, get_args, Literal, Protocol, TypeAlias
-
-# Five minutes permits slow discovery while rejecting accidental wall-clock
deadlines.
-# This is a sanity ceiling, not a default; hosts/providers should use tighter
budgets.
-MAX_METADATA_BUDGET_SECONDS: float = 300.0
-
-# The host passes a finite absolute time.monotonic() deadline in this process.
-CatalogLoader: TypeAlias = Callable[[float], str]
-MetadataRefreshErrorCategory: TypeAlias = Literal[
- "unsupported",
- "configuration",
- "in_progress",
- "configuration_changed",
- "upstream",
- "invalid_payload",
- "deadline",
- "unavailable",
- "indeterminate",
-]
-
-
-@dataclass(frozen=True)
-class CatalogSnapshot:
- """Carry one validated observation and its captured cache identity.
-
- The provider supplies canonical JSON in payload. The host assigns a
nonempty,
- opaque, scope-qualified cache_token for each successful publication,
including
- unchanged discovery. observed_at is a UTC RFC3339 source-observation time
for
- display, not a cache creation/expiry time or an ordering authority. The
producer
- is responsible for RFC3339 formatting; this record checks
type/nonemptiness.
- """
-
- payload: str = field(repr=False)
- cache_token: str = field(repr=False)
- observed_at: str
-
- def __post_init__(self) -> None:
- if not isinstance(self.payload, str):
- raise TypeError("Catalog payload must be a string")
- if not isinstance(self.cache_token, str):
- raise TypeError("Catalog cache_token must be a string")
- if not isinstance(self.observed_at, str):
- raise TypeError("Catalog observed_at must be a string")
- if not self.cache_token:
- raise ValueError("Catalog cache token must not be empty")
- if not self.observed_at:
- raise ValueError("Catalog observed_at must not be empty")
-
-
-@dataclass(frozen=True)
-class MetadataRefreshResult:
- """Report a confirmed publication and its immutable observation.
-
- Unchanged compares discovery payloads, not complete upstream metric
- definitions. Changed includes installation without a prior observation.
- An indeterminate publication must raise an error instead of returning
success.
- """
-
- status: Literal["changed", "unchanged"]
- snapshot: CatalogSnapshot
-
- def __post_init__(self) -> None:
- if self.status not in ("changed", "unchanged"):
- raise ValueError("Unknown metadata refresh result status")
- if not isinstance(self.snapshot, CatalogSnapshot):
- raise TypeError("Metadata refresh snapshot must be a
CatalogSnapshot")
-
-
-class MetadataRefreshError(Exception):
- """Carry a validated failure category without vendor messages or
credentials.
-
- Hosts must not expose exception cause chains. Providers translating
sensitive
- upstream exceptions should suppress their displayed chain with ``from
None``.
- """
-
- def __init__(self, category: MetadataRefreshErrorCategory) -> None:
- if category not in get_args(MetadataRefreshErrorCategory):
- raise ValueError("Unknown metadata refresh error category")
- self.category: MetadataRefreshErrorCategory = category
- super().__init__(category)
-
-
-def remaining_budget(deadline: float, *, now: float | None = None) -> float:
- """Return bounded seconds left or reject an invalid monotonic deadline.
-
- ``now`` supports hosts with an injected monotonic clock; it must use the
same
- process-local clock as deadline. Neither value is a wall-clock timestamp.
- The sanity ceiling catches epoch timestamps without silently clamping them.
- """
- remaining: float = deadline - (monotonic() if now is None else now)
- if not isfinite(remaining) or not 0 < remaining <=
MAX_METADATA_BUDGET_SECONDS:
- raise MetadataRefreshError("deadline")
- return remaining
-
-
-class MetadataSnapshotStore(Protocol):
- """Define host-owned publication for one authorized connection scope.
-
- The loader receives a finite absolute time.monotonic() deadline and
acquires
- and validates canonical JSON within that budget and any tighter provider
limit.
- The caller sets one deadline before waiting or acquisition and passes it to
- every store/adapter call; implementations must not reset it for nested
calls.
- Use remaining_budget to reject non-finite, expired or implausibly large
budgets
- with the deadline category before further I/O, including cache reads.
- The deadline is process-local, never serialized or used for cache expiry or
- publication ordering; it does not replace writer fencing.
- The host owns expiry, writer fencing and publication confirmation. This
- protocol does not provide storage, authorization or distributed
coordination.
- Host-only invalidation and cache inspection are outside the provider
interface.
- """
-
- def read(self, fetch: CatalogLoader, *, deadline: float) ->
CatalogSnapshot:
- """Read a valid observation or perform bounded coordinated acquisition.
-
- Cold readers wait/re-read within a finite deadline or fail safely; they
- must not silently use an obsolete local copy. Cache hits retain
identity
- and expiry. Return the payload and its identity from the same
observation.
- Pass the caller's deadline unchanged to nested refresh and loader
calls.
- """
- ...
-
- def refresh(
- self, fetch: CatalogLoader, *, deadline: float
- ) -> MetadataRefreshResult:
- """Bypass a hit and confirm publication with a fresh cache token.
-
- A busy explicit refresh reports in_progress. Failure must not renew
- freshness. An unconfirmed outcome reports indeterminate rather than
- retrying publication blindly or claiming success or rollback.
- Acquisition and publication share the caller's deadline without
renewal.
- """
- ...
-
-
-class MetadataRefreshAdapter(ABC):
- """Opt a provider into host-coordinated metadata discovery and refresh."""
-
- @abstractmethod
- def bind(self, store: MetadataSnapshotStore, *, deadline: float) -> None:
- """Bind once before discovery and reject rebinding as configuration
error.
-
- Participating full/custom views and runtime schema must use this store.
- The host supplies trusted scope and checks authority before
construction.
- Construction stays deadline-free. Capture the caller's finite absolute
- monotonic deadline here and pass it unchanged to discovery store reads.
- Validate with remaining_budget before binding; failure leaves the
adapter
- unbound. A new host operation needs
- a fresh adapter, not a renewed discovery budget on an existing binding.
- """
-
- @abstractmethod
- def refresh(self, *, deadline: float) -> MetadataRefreshResult:
- """Acquire validated metadata and request confirmed host publication.
-
- An unbound adapter reports unsupported. Do not return success after
only
- fetching upstream data or silently fall back to provider-local caching.
- Pass the caller's finite absolute monotonic deadline unchanged to the
- store. Validate with remaining_budget before I/O; nested calls never
create a
- new budget.
- """
-
- @abstractmethod
- def get_runtime_schema(
- self, runtime_data: dict[str, Any] | None = None
- ) -> dict[str, Any]:
- """Discover runtime fields through the bound store used by provider
views.
-
- Return the existing runtime-schema shape. This instance hook avoids the
- legacy classmethod path bypassing the bound observation.
- """
diff --git a/superset-core/src/superset_core/semantic_layers/view.py
b/superset-core/src/superset_core/semantic_layers/view.py
index 71ab7890e1a..a2ac9c36d6e 100644
--- a/superset-core/src/superset_core/semantic_layers/view.py
+++ b/superset-core/src/superset_core/semantic_layers/view.py
@@ -74,12 +74,12 @@ class SemanticView(ABC):
@property
def metadata_cache_token(self) -> str | None:
- """Return the identity captured with these members, or None for legacy
views.
+ """Return the ``cache_token`` these members were discovered under.
- A provider using a bound metadata store must return its observation's
- nonempty token. The host must reject a missing token in that mode
rather
- than silently using legacy cache keys. Never look up a later identity
- independently of the data used for discovery or compatibility.
+ Providers echo the token passed to
``from_configuration_with_cache_token``;
+ legacy views return None. Report the token captured with the members,
never
+ a later one. The host keys its caches on its own metadata generation,
not
+ on this value.
"""
return None
diff --git
a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx
b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx
index 248e1d7728c..dbc51956aec 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx
@@ -17,7 +17,13 @@
* under the License.
*/
import { SupersetClient } from '@superset-ui/core';
-import { act, render, waitFor } from 'spec/helpers/testing-library';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from 'spec/helpers/testing-library';
import SemanticLayerModal from './SemanticLayerModal';
@@ -28,7 +34,13 @@ let capturedOnChange:
jest.mock('@jsonforms/react', () => ({
...jest.requireActual('@jsonforms/react'),
- JsonForms: ({ onChange }: { onChange: (value: unknown) => void }) => {
+ JsonForms: ({
+ onChange,
+ readonly,
+ }: {
+ onChange: (value: unknown) => void;
+ readonly?: boolean;
+ }) => {
capturedOnChange = onChange as typeof capturedOnChange;
// eslint-disable-next-line react-hooks/rules-of-hooks
if (!mockJsonFormsChangeTriggered) {
@@ -38,7 +50,7 @@ jest.mock('@jsonforms/react', () => ({
errors: [],
});
}
- return null;
+ return <input aria-label="Warehouse" readOnly={readonly} />;
},
}));
@@ -225,3 +237,115 @@ test('cancels pending schema refresh when dependencies
become unsatisfied', asyn
// No additional POST should have fired; the cancelled timer must not land.
expect(mockedPost).toHaveBeenCalledTimes(1);
});
+
+test('Reload metadata confirms and closes without navigating away from the
toast', async () => {
+ mockJsonFormsChangeTriggered = true;
+ props.onHide.mockClear();
+ render(<SemanticLayerModal {...props} />);
+ const button = await screen.findByRole('button', { name: 'Reload metadata'
});
+ await waitFor(() => expect(button).toBeEnabled());
+ fireEvent.click(button);
+ await waitFor(() => expect(props.onHide).toHaveBeenCalledTimes(1));
+ expect(mockedPost).toHaveBeenCalledWith({
+ endpoint: `/api/v1/semantic_layer/${props.semanticLayerUuid}/clear_cache`,
+ jsonPayload: {},
+ });
+ expect(props.addSuccessToast).toHaveBeenCalledWith(
+ 'Cache cleared; reload to fetch metadata',
+ );
+});
+
+test('Reload metadata stays enabled after schema values are backfilled but
disables on an edit', async () => {
+ mockJsonFormsChangeTriggered = true;
+ mockedPost.mockResolvedValue({
+ json: {
+ result: {
+ type: 'object',
+ properties: {
+ warehouse: { type: 'string' },
+ region: { type: 'string', readOnly: true, default: 'us-east-1' },
+ provider: { type: 'string', const: 'snowflake' },
+ },
+ },
+ },
+ });
+
+ render(<SemanticLayerModal {...props} />);
+ const button = await screen.findByRole('button', { name: 'Reload metadata'
});
+ await waitFor(() => expect(capturedOnChange).not.toBeNull());
+
+ act(() =>
+ capturedOnChange?.({
+ data: { warehouse: 'wh0', region: 'us-east-1', provider: 'snowflake' },
+ errors: [],
+ }),
+ );
+ expect(button).toBeEnabled();
+
+ act(() =>
+ capturedOnChange?.({
+ data: { warehouse: 'wh1', region: 'us-east-1', provider: 'snowflake' },
+ errors: [],
+ }),
+ );
+ expect(button).toBeDisabled();
+});
+
+test('Reload metadata is disabled for unsaved configuration and name edits',
async () => {
+ render(<SemanticLayerModal {...props} />);
+ const button = await screen.findByRole('button', { name: 'Reload metadata'
});
+ expect(button).toBeDisabled();
+ act(() => capturedOnChange?.({ data: { warehouse: 'wh0' }, errors: [] }));
+ await waitFor(() => expect(button).toBeEnabled());
+ fireEvent.change(screen.getByPlaceholderText('Name of the semantic layer'), {
+ target: { value: 'Unsaved name' },
+ });
+ expect(button).toBeDisabled();
+});
+
+test('Reload metadata reports failure without reloading or closing the
editor', async () => {
+ mockJsonFormsChangeTriggered = true;
+ props.addDangerToast.mockClear();
+ props.onHide.mockClear();
+ render(<SemanticLayerModal {...props} />);
+ const button = await screen.findByRole('button', { name: 'Reload metadata'
});
+ await waitFor(() => expect(button).toBeEnabled());
+ mockedPost.mockRejectedValueOnce(new Error('clear failed'));
+ fireEvent.click(button);
+ await waitFor(() =>
+ expect(props.addDangerToast).toHaveBeenCalledWith(
+ 'Unable to clear metadata cache',
+ ),
+ );
+ expect(props.onHide).not.toHaveBeenCalled();
+ expect(button).toBeEnabled();
+});
+
+test('A pending clear prevents edits and a closed editor ignores its late
response', async () => {
+ mockJsonFormsChangeTriggered = true;
+ props.addSuccessToast.mockClear();
+ const { rerender } = render(<SemanticLayerModal {...props} />);
+ const button = await screen.findByRole('button', { name: 'Reload metadata'
});
+ await waitFor(() => expect(button).toBeEnabled());
+ let finish: (value: { json: object }) => void = () => {};
+ mockedPost.mockReturnValueOnce(
+ new Promise(resolve => {
+ finish = resolve;
+ }),
+ );
+ expect(
+ screen.getByRole('textbox', { name: 'Warehouse' }),
+ ).not.toHaveAttribute('readonly');
+ fireEvent.click(button);
+ expect(screen.getByRole('textbox', { name: 'Warehouse' })).toHaveAttribute(
+ 'readonly',
+ );
+ expect(button).toBeDisabled();
+ expect(
+ screen.getByPlaceholderText('Name of the semantic layer'),
+ ).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+ rerender(<SemanticLayerModal {...props} show={false} />);
+ await act(async () => finish({ json: {} }));
+ expect(props.addSuccessToast).not.toHaveBeenCalled();
+});
diff --git
a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
index c875e835f58..0b10f000288 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
+import isEqual from 'lodash/isEqual';
import { t } from '@apache-superset/core/translation';
import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
import { Input, Select, Button } from '@superset-ui/core/components';
@@ -82,6 +83,26 @@ export default function SemanticLayerModal({
);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
+ const [clearing, setClearing] = useState(false);
+ const [savedForm, setSavedForm] = useState<{
+ name: string;
+ configuration: Record<string, unknown>;
+ } | null>(null);
+ const clearRequestRef = useRef(0);
+ const savedConfiguration = { ...savedForm?.configuration };
+ for (const [field, property] of Object.entries(
+ configSchema?.properties ?? {},
+ )) {
+ if (typeof property !== 'object' || property === null) continue;
+ const backfilledValue =
+ property.const ?? (property.readOnly ? property.default : undefined);
+ if (backfilledValue !== undefined)
+ savedConfiguration[field] = backfilledValue;
+ }
+ const dirty =
+ !savedForm ||
+ name !== savedForm.name ||
+ !isEqual(formData, savedConfiguration);
const [hasErrors, setHasErrors] = useState(true);
const [refreshingSchema, setRefreshingSchema] = useState(false);
const [validationMode, setValidationMode] =
@@ -167,6 +188,10 @@ export default function SemanticLayerModal({
setName(layer.name ?? '');
setSelectedType(layer.type);
setFormData(layer.configuration ?? {});
+ setSavedForm({
+ name: layer.name ?? '',
+ configuration: layer.configuration ?? {},
+ });
setHasErrors(false);
// In edit mode, fetch the enriched schema using the full saved
// configuration so that dynamic dropdowns (account, project,
@@ -207,6 +232,8 @@ export default function SemanticLayerModal({
setConfigSchema(null);
setUiSchema(undefined);
setFormData({});
+ setSavedForm(null);
+ setClearing(false);
setHasErrors(true);
setRefreshingSchema(false);
setValidationMode('ValidateAndHide');
@@ -218,6 +245,39 @@ export default function SemanticLayerModal({
}
}, [show, fetchTypes, isEditMode, semanticLayerUuid, fetchExistingLayer]);
+ useEffect(
+ () => () => {
+ clearRequestRef.current += 1;
+ },
+ [show, semanticLayerUuid],
+ );
+
+ const handleReloadMetadata = async () => {
+ if (!semanticLayerUuid || dirty || saving || clearing || refreshingSchema)
+ return;
+ clearRequestRef.current += 1;
+ const request = clearRequestRef.current;
+ setClearing(true);
+ try {
+ await SupersetClient.post({
+ endpoint: `/api/v1/semantic_layer/${semanticLayerUuid}/clear_cache`,
+ jsonPayload: {},
+ });
+ if (request !== clearRequestRef.current) return;
+ addSuccessToast(t('Cache cleared; reload to fetch metadata'));
+ onHide();
+ } catch (error) {
+ const clientError = await getClientErrorObject(error);
+ if (request === clearRequestRef.current) {
+ addDangerToast(
+ clientError.error || t('Unable to clear metadata cache'),
+ );
+ }
+ } finally {
+ if (request === clearRequestRef.current) setClearing(false);
+ }
+ };
+
const handleStepAdvance = () => {
if (selectedType) {
// Pre-fill the Name with the type's display name so a user who just
@@ -388,7 +448,9 @@ export default function SemanticLayerModal({
icon={isEditMode ? <Icons.EditOutlined /> : <Icons.PlusOutlined />}
width={isTypeStep ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH}
saveDisabled={
- isTypeStep ? !selectedType : saving || !name.trim() || hasErrors
+ isTypeStep
+ ? !selectedType
+ : saving || clearing || !name.trim() || hasErrors
}
saveText={isTypeStep ? undefined : isEditMode ? t('Save') : t('Create')}
saveLoading={saving}
@@ -411,6 +473,15 @@ export default function SemanticLayerModal({
</ModalFormField>
) : (
<>
+ {isEditMode && (
+ <Button
+ disabled={dirty || saving || clearing || refreshingSchema}
+ loading={clearing}
+ onClick={handleReloadMetadata}
+ >
+ {t('Reload metadata')}
+ </Button>
+ )}
{!isEditMode && (
<Button
buttonStyle="link"
@@ -422,6 +493,7 @@ export default function SemanticLayerModal({
)}
<ModalFormField label={t('Name')} required>
<Input
+ disabled={clearing}
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('Name of the semantic layer')}
@@ -437,6 +509,7 @@ export default function SemanticLayerModal({
onSubmit={e => e.preventDefault()}
>
<JsonForms
+ readonly={clearing}
schema={configSchema}
uischema={uiSchema}
data={formData}
diff --git a/superset/commands/semantic_layer/update.py
b/superset/commands/semantic_layer/update.py
index 6e42bc3ca52..cef8511f57c 100644
--- a/superset/commands/semantic_layer/update.py
+++ b/superset/commands/semantic_layer/update.py
@@ -23,6 +23,7 @@ from typing import Any
from flask_appbuilder.models.sqla import Model
from sqlalchemy.exc import SQLAlchemyError
+from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
@@ -179,6 +180,20 @@ class UpdateSemanticViewCommand(BaseCommand):
)
+def _load_modifiable_layer(uuid: str) -> SemanticLayer:
+ """Load a connection with the shared access and modification policy."""
+ layer: SemanticLayer | None = SemanticLayerDAO.find_by_uuid(uuid)
+ if not layer:
+ raise SemanticLayerNotFoundError()
+ try:
+ layer.raise_for_access()
+ except SupersetSecurityException as ex:
+ raise SemanticLayerForbiddenError() from ex
+ if not current_user_can_modify_object(layer):
+ raise SemanticLayerForbiddenError()
+ return layer
+
+
class UpdateSemanticLayerCommand(BaseCommand):
def __init__(self, uuid: str, data: dict[str, Any]):
self._uuid = uuid
@@ -199,19 +214,12 @@ class UpdateSemanticLayerCommand(BaseCommand):
self._properties["configuration"] = json.dumps(
self._properties["configuration"]
)
+ if "configuration" in self._properties:
+ self._model.clear_metadata_cache()
return SemanticLayerDAO.update(self._model,
attributes=self._properties)
def validate(self) -> None:
- self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
- if not self._model:
- raise SemanticLayerNotFoundError()
- try:
- self._model.raise_for_access()
- except SupersetSecurityException as ex:
- raise SemanticLayerForbiddenError() from ex
-
- if not current_user_can_modify_object(self._model):
- raise SemanticLayerForbiddenError()
+ self._model = _load_modifiable_layer(self._uuid)
name = self._properties.get("name")
if name and not
SemanticLayerDAO.validate_update_uniqueness(self._uuid, name):
@@ -230,3 +238,33 @@ class UpdateSemanticLayerCommand(BaseCommand):
if sl_type not in registry:
raise SemanticLayerInvalidError(f"Unknown type: {sl_type}")
validate_configuration(registry[sl_type], configuration)
+
+
+class ClearSemanticLayerCacheCommand(BaseCommand):
+ """Invalidate a saved connection's metadata without fetching its
provider."""
+
+ def __init__(self, uuid: str) -> None:
+ self._uuid: str = uuid
+ self._model: SemanticLayer | None = None
+
+ def validate(self) -> None:
+ """Require connection management authority, including for direct
callers."""
+ if security_manager.is_guest_user() or not security_manager.can_access(
+ "can_write", "SemanticLayer"
+ ):
+ raise SemanticLayerForbiddenError()
+ self._model = _load_modifiable_layer(self._uuid)
+
+ @transaction(
+ on_error=partial(
+ on_error,
+ catches=(SQLAlchemyError,),
+ reraise=SemanticLayerUpdateFailedError,
+ )
+ )
+ def run(self) -> Model:
+ """Return only after the cache generation has committed."""
+ self.validate()
+ assert self._model
+ self._model.clear_metadata_cache()
+ return self._model
diff --git a/superset/common/query_context_processor.py
b/superset/common/query_context_processor.py
index 90e132ddcfa..ec318d82410 100644
--- a/superset/common/query_context_processor.py
+++ b/superset/common/query_context_processor.py
@@ -43,6 +43,12 @@ from superset.common.utils.time_range_utils import
get_since_until_from_time_ran
from superset.constants import CACHE_DISABLED_TIMEOUT, CacheRegion
from superset.daos.annotation_layer import AnnotationLayerDAO
from superset.daos.chart import ChartDAO
+from superset.daos.datasource import DatasourceDAO
+from superset.daos.exceptions import (
+ DatasourceNotFound,
+ DatasourceTypeNotSupportedError,
+ DatasourceValueIsIncorrect,
+)
from superset.dataframe import df_to_records
from superset.exceptions import (
QueryObjectValidationError,
@@ -55,7 +61,7 @@ from superset.extensions import cache_manager,
security_manager
from superset.models.helpers import QueryResult
from superset.semantic_layers.models import SemanticView
from superset.superset_typing import AdhocColumn, AdhocMetric, Column
-from superset.utils import csv, excel
+from superset.utils import csv, excel, json
from superset.utils.cache import generate_cache_key, set_and_log_cache
from superset.utils.core import (
ANNOTATION_SOURCE_TYPES_WITH_CHART_REFERENCE,
@@ -75,6 +81,7 @@ from superset.utils.pandas_postprocessing.utils import
unescape_separator
if TYPE_CHECKING:
from superset.common.query_context import QueryContext
from superset.common.query_object import QueryObject
+ from superset.daos.datasource import Datasource
from superset.db_engine_specs.base import BaseEngineSpec
logger = logging.getLogger(__name__)
@@ -467,6 +474,7 @@ class QueryContextProcessor:
"""
source_rls: dict[str, list[str] | None] = {}
source_versions: dict[str, tuple[str, str]] = {}
+ metadata_versions: dict[str, str] = {}
for layer in query_obj.annotation_layers:
if (
layer.get("sourceType")
@@ -477,7 +485,30 @@ class QueryContextProcessor:
chart = (
ChartDAO.find_by_id(layer_value) if layer_value is not None
else None
)
- annotation_datasource = chart.datasource if chart else None
+ annotation_datasource: Datasource | None = (
+ chart.resolved_datasource if chart else None
+ )
+ if chart and isinstance(chart.query_context, str):
+ try:
+ source: dict[str, Any] = json.loads(chart.query_context)[
+ "datasource"
+ ]
+ annotation_datasource = DatasourceDAO.get_datasource(
+ source["type"], source["id"]
+ )
+ except (
+ ValueError,
+ TypeError,
+ KeyError,
+ DatasourceNotFound,
+ DatasourceTypeNotSupportedError,
+ DatasourceValueIsIncorrect,
+ ):
+ annotation_datasource = None
+ if isinstance(annotation_datasource, SemanticView):
+ metadata_versions[str(layer_value)] = (
+ annotation_datasource.metadata_generation
+ )
source_rls[str(layer.get("value"))] = (
security_manager.get_rls_cache_key(annotation_datasource)
if annotation_datasource
@@ -489,7 +520,11 @@ class QueryContextProcessor:
)
if discriminator is not None:
source_versions[str(layer_value)] = discriminator
- context: dict[str, Any] = {"user_id": get_user_id(), "source_rls":
source_rls}
+ context: dict[str, Any] = {
+ "user_id": get_user_id(),
+ "source_rls": source_rls,
+ "source_versions": metadata_versions,
+ }
if source_versions:
context["semantic_result_versions"] = source_versions
return context
diff --git a/superset/config.py b/superset/config.py
index 176ccf6c772..2ab845461bb 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -106,6 +106,11 @@ if "SUPERSET_HOME" in os.environ:
else:
DATA_DIR = os.path.expanduser("~/.superset")
+# Optional globally unique workspace identity, overriding database address
scope.
+# Web, Celery and MCP must resolve the same value for the same workspace.
+# A callable may return the active tenant's stable namespace per session.
+SEMANTIC_LAYER_CACHE_NAMESPACE: str | Callable[[], str] = ""
+
# ---------------------------------------------------------
# Superset specific config
# ---------------------------------------------------------
diff --git a/superset/datasource/api.py b/superset/datasource/api.py
index 0e2ddf248ac..82bb4c6a79f 100644
--- a/superset/datasource/api.py
+++ b/superset/datasource/api.py
@@ -80,6 +80,7 @@ def _column_values_cache_key(
"""Bind a value query to its producer guarantee and existing security
scope."""
material: dict[str, Any] = dict(query)
if isinstance(datasource, SemanticView):
+ material["metadata_version"] = datasource.metadata_generation
discriminator: tuple[str, str] | None =
datasource.result_cache_discriminator
if discriminator is not None:
material["semantic_result_version"] = discriminator
@@ -214,8 +215,6 @@ class DatasourceRestApi(BaseSupersetApi):
# This route cannot prove the provenance of saved dimension names.
# Gate before cache access as well as provider execution.
if datasource_type == DatasourceType.SEMANTIC_VIEW.value:
- from superset.semantic_layers.models import SemanticView
-
if (
cast(SemanticView,
datasource).implementation.selection_identity_version
is not None
@@ -602,6 +601,11 @@ class DatasourceRestApi(BaseSupersetApi):
json.dumps(
{
"uid": datasource.uid,
+ "metadata_version": (
+ datasource.metadata_generation
+ if isinstance(datasource, SemanticView)
+ else None
+ ),
"m": sorted(selected_metrics),
"d": sorted(selected_dimensions),
},
diff --git
a/superset/migrations/versions/2026-10-08_00-00_c179af013f48_add_semantic_layer_cache_version.py
b/superset/migrations/versions/2026-10-08_00-00_c179af013f48_add_semantic_layer_cache_version.py
new file mode 100644
index 00000000000..009bd8dd21f
--- /dev/null
+++
b/superset/migrations/versions/2026-10-08_00-00_c179af013f48_add_semantic_layer_cache_version.py
@@ -0,0 +1,42 @@
+# 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.
+
+"""Add a transactionally rotated semantic-layer cache generation.
+
+Revision ID: c179af013f48
+Revises: e4a7c2b9d1f3
+"""
+
+import sqlalchemy as sa
+
+from superset.migrations.shared.utils import add_columns, drop_columns
+
+revision: str = "c179af013f48"
+down_revision: str = "e4a7c2b9d1f3"
+
+
+def upgrade() -> None:
+ """Initialize existing and newly inserted layers at generation zero."""
+ add_columns(
+ "semantic_layers",
+ sa.Column("cache_version", sa.Integer(), nullable=False,
server_default="0"),
+ )
+
+
+def downgrade() -> None:
+ """Remove only the cache generation."""
+ drop_columns("semantic_layers", "cache_version")
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 58cc3d68289..f0f124b0128 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -53,6 +53,7 @@ from superset.commands.semantic_layer.exceptions import (
SemanticViewUpdateFailedError,
)
from superset.commands.semantic_layer.update import (
+ ClearSemanticLayerCacheCommand,
UpdateSemanticLayerCommand,
UpdateSemanticViewCommand,
)
@@ -577,6 +578,7 @@ class SemanticLayerRestApi(BaseSupersetApi):
method_permission_name = {
**MODEL_API_RW_METHOD_PERMISSION_MAP,
"types": "read",
+ "clear_cache": "write",
"configuration_schema": "read",
"runtime_schema": "read",
# ``read`` (not the default ``can_views`` / ``can_connections``) so
@@ -948,6 +950,57 @@ class SemanticLayerRestApi(BaseSupersetApi):
)
return self.response_422(message=str(ex))
+ @expose("/<uuid>/clear_cache", methods=("POST",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @requires_json
+ def clear_cache(self, uuid: str) -> FlaskResponse:
+ """Invalidate saved semantic-layer metadata without provider discovery.
+ ---
+ post:
+ summary: Clear a semantic layer's cache
+ parameters:
+ - in: path
+ name: uuid
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: false
+ responses:
+ 200:
+ description: Cache cleared; reload to fetch metadata
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ """
+ if not is_feature_enabled("SEMANTIC_LAYERS"):
+ return self.response_404()
+ if request.get_json(silent=True) != {}:
+ return self.response_400(message="Expected an empty JSON object")
+ try:
+ ClearSemanticLayerCacheCommand(uuid).run()
+ except SemanticLayerNotFoundError:
+ return self.response_404()
+ except SemanticLayerForbiddenError as ex:
+ return self.response(403, message=str(ex))
+ except SemanticLayerUpdateFailedError as ex:
+ return self.response_422(message=str(ex))
+ return self.response(200, message="Cache cleared; reload to fetch
metadata")
+
@expose("/<uuid>", methods=("DELETE",))
@protect()
@safe
diff --git a/superset/semantic_layers/models.py
b/superset/semantic_layers/models.py
index 15cc74c06f4..93802125d1b 100644
--- a/superset/semantic_layers/models.py
+++ b/superset/semantic_layers/models.py
@@ -19,22 +19,24 @@
from __future__ import annotations
+import hashlib
import logging
import math
import uuid
-from collections.abc import Hashable
-from dataclasses import dataclass
+from collections.abc import Callable, Hashable
+from dataclasses import dataclass, field
from functools import cached_property
-from typing import Any, TYPE_CHECKING
+from typing import Any, cast, TYPE_CHECKING
import pandas as pd
import pyarrow as pa
import sqlalchemy as sa
+from flask import current_app
from flask_appbuilder import Model
from flask_babel import lazy_gettext as _
from sqlalchemy import Column, ForeignKey, Integer, String, Text
from sqlalchemy.engine.base import Connection
-from sqlalchemy.orm import Mapped, relationship
+from sqlalchemy.orm import Mapped, object_session, relationship, Session
from sqlalchemy.orm.mapper import Mapper
from sqlalchemy_utils import UUIDType
from sqlalchemy_utils.types.json import JSONType
@@ -59,7 +61,7 @@ from superset.exceptions import (
SemanticResultCompletenessError,
)
from superset.explorables.base import TimeGrainDict
-from superset.extensions import encrypted_field_factory
+from superset.extensions import db, encrypted_field_factory
from superset.models.helpers import AuditMixinNullable, QueryResult
from superset.result_set import stringify_extension_columns
from superset.semantic_layers.completeness import provider_completeness
@@ -170,6 +172,55 @@ class ColumnMetadata:
extra: str | None = None
+_METADATA_GENERATIONS_KEY: str = "semantic_layer_metadata_generations"
+
+
+@dataclass
+class _MetadataGenerations:
+ """Session-local generations, captured before discovery and forgotten on
Save."""
+
+ generations: dict[uuid.UUID, str] = field(default_factory=dict)
+
+ @classmethod
+ def for_session(cls, session: Session) -> _MetadataGenerations:
+ """Keep the memo's storage convention in one place."""
+ if _METADATA_GENERATIONS_KEY not in session.info:
+ session.info[_METADATA_GENERATIONS_KEY] = cls()
+ return cast(_MetadataGenerations,
session.info[_METADATA_GENERATIONS_KEY])
+
+ def capture(self, layer: SemanticLayer, session: Session) -> str:
+ """Web, task and MCP operations share a credential-independent
identity."""
+ if layer.uuid not in self.generations:
+ namespace: str | Callable[[], str] = current_app.config.get(
+ "SEMANTIC_LAYER_CACHE_NAMESPACE", ""
+ )
+ if callable(namespace):
+ namespace = namespace()
+ if not isinstance(namespace, str):
+ raise TypeError(
+ "SEMANTIC_LAYER_CACHE_NAMESPACE must resolve to a string"
+ )
+ url: sa.engine.URL =
session.get_bind(mapper=SemanticLayer).engine.url
+ driver: str = url.drivername.split("+", 1)[0]
+ identity: list[str | int | None] = (
+ ["namespace", namespace]
+ if namespace
+ else [
+ driver,
+ url.host,
+ url.port,
+ url.database,
+ ]
+ )
+ scope: str =
hashlib.sha256(json.dumps(identity).encode()).hexdigest()
+ self.generations[layer.uuid] =
f"{scope}:{layer.uuid}:{layer.cache_version}"
+ return self.generations[layer.uuid]
+
+ def forget(self, layer_uuid: uuid.UUID) -> None:
+ """Permit a fresh capture after an authorized ``cache_version``
increment."""
+ self.generations.pop(layer_uuid, None)
+
+
class SemanticLayer(AuditMixinNullable, Model):
"""
Semantic layer model.
@@ -191,6 +242,7 @@ class SemanticLayer(AuditMixinNullable, Model):
# Tracks the schema version of the configuration JSON field to aid with
# migrations as the configuration schema evolves over time.
configuration_version = Column(Integer, nullable=False, default=1)
+ cache_version: Mapped[int] = Column(Integer, nullable=False,
server_default="0")
cache_timeout = Column(Integer, nullable=True)
# Permission string for FAB's datasource_access PVM mechanism.
@@ -271,6 +323,38 @@ class SemanticLayer(AuditMixinNullable, Model):
security_manager.semantic_layer_after_delete(mapper, connection,
target)
+ @property
+ def metadata_generation(self) -> str:
+ """Capture the database-scoped metadata generation once per session.
+
+ This is the host's cache identity for the layer's metadata: it keys
host
+ caches and is passed to providers as ``cache_token``.
+ """
+ session: Session = object_session(self) or db.session
+ return _MetadataGenerations.for_session(session).capture(self, session)
+
+ def clear_metadata_cache(self) -> None:
+ """Rotate atomically in the caller's transaction without provider
I/O."""
+ session: Session = object_session(self) or db.session
+ session.execute(
+ sa.update(SemanticLayer)
+ .where(SemanticLayer.uuid == self.uuid)
+ .values(cache_version=SemanticLayer.cache_version + 1)
+ .execution_options(synchronize_session=False)
+ )
+ session.expire(self, ["cache_version"])
+ self.__dict__.pop("implementation", None)
+ _MetadataGenerations.for_session(session).forget(self.uuid)
+ instance: object
+ for instance in session.identity_map.values():
+ if isinstance(instance, SemanticView):
+ # Unknown/expired ownership is safe to forget, without a
SELECT.
+ if (
+ sa.inspect(instance).dict.get("semantic_layer_uuid",
self.uuid)
+ == self.uuid
+ ):
+ instance.forget_metadata()
+
@cached_property
def implementation(
self,
@@ -281,7 +365,9 @@ class SemanticLayer(AuditMixinNullable, Model):
# TODO (betodealmeida):
# return extension_manager.get_contribution("semanticLayers",
self.type)
class_ = registry[self.type]
- return class_.from_configuration(json.loads(self.configuration))
+ return class_.from_configuration_with_cache_token(
+ json.loads(self.configuration),
cache_token=self.metadata_generation
+ )
class SemanticView(AuditMixinNullable, Model):
@@ -374,6 +460,8 @@ class SemanticView(AuditMixinNullable, Model):
"""
Return semantic view implementation.
"""
+ # Pin before the provider can discover any members.
+ self._capture_metadata_generation()
return self.semantic_layer.implementation.get_semantic_view(
self.name,
json.loads(self.configuration),
@@ -776,6 +864,39 @@ class SemanticView(AuditMixinNullable, Model):
def data_for_slices(self, slices: list[Any]) -> ExplorableData:
return self.data
+ _metadata_generation: str | None = None
+
+ def _capture_metadata_generation(self) -> None:
+ """Bind this view to the layer generation used by its
implementation."""
+ if self._metadata_generation is None:
+ self._metadata_generation = self.semantic_layer.metadata_generation
+
+ def forget_metadata(self) -> None:
+ """Discard this view's implementation and captured generation
together."""
+ self.__dict__.pop("implementation", None)
+ self._metadata_generation = None
+
+ @property
+ def metadata_generation(self) -> str:
+ """The host metadata generation that keys this view's cached results.
+
+ Unlike the SDK view's ``metadata_cache_token``, which a provider only
+ echoes back, this is the host's own value and the one caches use.
+ """
+ self._capture_metadata_generation()
+ assert self._metadata_generation is not None
+ return self._metadata_generation
+
+ @property
+ def metadata_cache_token(self) -> str:
+ """Deprecated alias of ``metadata_generation``, the host generation.
+
+ Kept only while the containment result cache reads the host generation
+ under this name; remove it once that reads ``metadata_generation``.
+ Not the SDK view's provider-echoed ``metadata_cache_token``.
+ """
+ return self.metadata_generation
+
@property
def result_cache_version(self) -> str | None:
"""Read the producer guarantee without constructing its
implementation."""
@@ -801,9 +922,10 @@ class SemanticView(AuditMixinNullable, Model):
def get_extra_cache_keys(self, query_obj: QueryObjectDict) ->
list[Hashable]:
discriminator: tuple[str, str] | None = self.result_cache_discriminator
- if discriminator is None:
- return []
- return [("semantic-result-version", *discriminator)]
+ keys: list[Hashable] = [self.metadata_generation]
+ if discriminator is not None:
+ keys.append(("semantic-result-version", *discriminator))
+ return keys
@property
def catalog_perm(self) -> str | None:
diff --git a/superset/translations/messages.pot
b/superset/translations/messages.pot
index 0300d5f321f..305a84a52b9 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -2734,6 +2734,9 @@ msgstr ""
msgid "Cache Timeout (seconds)"
msgstr ""
+msgid "Cache cleared; reload to fetch metadata"
+msgstr ""
+
msgid "Cache data separately for each user based on their data access roles
and permissions. When disabled, a single cache will be used for all users."
msgstr ""
@@ -10751,6 +10754,9 @@ msgstr ""
msgid "Reload SQL Lab"
msgstr ""
+msgid "Reload metadata"
+msgstr ""
+
msgid "Reload the page and try again to continue."
msgstr ""
@@ -15403,6 +15409,9 @@ msgstr ""
msgid "Unable to calculate such a date delta"
msgstr ""
+msgid "Unable to clear metadata cache"
+msgstr ""
+
#, python-format
msgid "Unable to connect to catalog named \"%(catalog_name)s\"."
msgstr ""
diff --git a/tests/unit_tests/common/test_query_context_processor.py
b/tests/unit_tests/common/test_query_context_processor.py
index 3fa9a05e00d..778912fdf1e 100644
--- a/tests/unit_tests/common/test_query_context_processor.py
+++ b/tests/unit_tests/common/test_query_context_processor.py
@@ -2895,7 +2895,11 @@ def
test_semantic_annotation_generation_versions_sql_parent(
query: MagicMock = MagicMock()
query.annotation_layers = [{"sourceType": "line", "value": 7}]
view: SemanticView =
SemanticView(semantic_layer=SemanticLayer(type="fixture"))
- chart: MagicMock = MagicMock(datasource=view)
+ from superset.models.slice import Slice
+
+ chart: Slice = Slice(
+ datasource_type="semantic_view", datasource_id=17, semantic_view=view
+ )
provider: MagicMock = MagicMock(result_cache_version=None)
with (
patch.dict("superset.semantic_layers.models.registry", {"fixture":
provider}),
@@ -2912,7 +2916,11 @@ def
test_semantic_annotation_generation_versions_sql_parent(
old: dict[str, Any] = processor._annotation_cache_context(query)
provider.result_cache_version = "guarded-v1"
new: dict[str, Any] = processor._annotation_cache_context(query)
- assert old == {"user_id": 42, "source_rls": {"7": ["scope"]}}
+ assert old == {
+ "user_id": 42,
+ "source_rls": {"7": ["scope"]},
+ "source_versions": {"7": view.metadata_generation},
+ }
assert new != old
assert new["user_id"] == old["user_id"]
assert new["source_rls"] == old["source_rls"]
diff --git a/tests/unit_tests/semantic_layers/metadata_contract_test.py
b/tests/unit_tests/semantic_layers/metadata_contract_test.py
index f0352ac99f7..a7be9ab1237 100644
--- a/tests/unit_tests/semantic_layers/metadata_contract_test.py
+++ b/tests/unit_tests/semantic_layers/metadata_contract_test.py
@@ -17,31 +17,14 @@
from __future__ import annotations
-import inspect
-import json # noqa: TID251 -- SDK examples must not import the host JSON
utility.
import subprocess
import sys
-from collections.abc import Callable
-from dataclasses import FrozenInstanceError
from pathlib import Path
-from time import monotonic
-from typing import Any, cast, get_args, get_type_hints, Literal
+from typing import Any
import pyarrow as pa
-import pytest
from pydantic import BaseModel
-from superset_core.semantic_layers import metadata as metadata_sdk
from superset_core.semantic_layers.layer import SemanticLayer
-from superset_core.semantic_layers.metadata import (
- CatalogLoader,
- CatalogSnapshot,
- MetadataRefreshAdapter,
- MetadataRefreshError,
- MetadataRefreshErrorCategory,
- MetadataRefreshResult,
- MetadataSnapshotStore,
- remaining_budget,
-)
from superset_core.semantic_layers.types import (
Dimension,
Filter,
@@ -52,14 +35,6 @@ from superset_core.semantic_layers.types import (
from superset_core.semantic_layers.view import SemanticView
[email protected]
-def clock(monkeypatch: pytest.MonkeyPatch) -> list[float]:
- """Control the SDK monotonic clock used by the examples."""
- times: list[float] = [100.0]
- monkeypatch.setattr(metadata_sdk, "monotonic", lambda: times[0])
- return times
-
-
class LegacyView(SemanticView):
name: str = "legacy"
@@ -124,121 +99,9 @@ class LegacyLayer(SemanticLayer[BaseModel, LegacyView]):
return LegacyView()
-class SnapshotView(LegacyView):
- def __init__(self, snapshot: CatalogSnapshot) -> None:
- self.snapshot: CatalogSnapshot = snapshot
-
- @property
- def metadata_cache_token(self) -> str:
- return self.snapshot.cache_token
-
- def get_metrics(self) -> set[Metric]:
- members: list[str] = json.loads(self.snapshot.payload)
- return {
- Metric(member, member, pa.int64(), member, verbose_name="Shared
label")
- for member in members
- }
-
-
-class MemoryStore:
- """Sequential example, without waiting or distributed guarantees."""
-
- def __init__(self) -> None:
- self.snapshot: CatalogSnapshot | None = None
- self.publications: int = 0
-
- def read(self, fetch: CatalogLoader, *, deadline: float) ->
CatalogSnapshot:
- remaining_budget(deadline)
- return (
- self.snapshot
- if self.snapshot is not None
- else self.refresh(fetch, deadline=deadline).snapshot
- )
-
- def refresh(
- self, fetch: CatalogLoader, *, deadline: float
- ) -> MetadataRefreshResult:
- remaining_budget(deadline)
- payload: str = fetch(deadline)
- remaining_budget(deadline)
- previous: CatalogSnapshot | None = self.snapshot
- self.publications += 1
- self.snapshot = CatalogSnapshot(
- payload, f"connection:{self.publications}", "2026-09-30T12:00:00Z"
- )
- return MetadataRefreshResult(
- "unchanged"
- if previous is not None and previous.payload == payload
- else "changed",
- self.snapshot,
- )
-
-
-class MemoryAdapter(MetadataRefreshAdapter):
- """Operation-scoped provider example sharing the host's discovery
budget."""
-
- def __init__(self) -> None:
- self.deadline: float | None = None
- self.store: MetadataSnapshotStore | None = None
- self.payload: str = '["orders", "revenue"]'
- self.fetches: int = 0
-
- def bind(self, store: MetadataSnapshotStore, *, deadline: float) -> None:
- if self.store is not None:
- raise MetadataRefreshError("configuration")
- remaining_budget(deadline)
- self.store = store
- self.deadline = deadline
-
- def fetch(self, deadline: float) -> str:
- remaining_budget(deadline)
- self.fetches += 1
- return self.payload
-
- def snapshot(self) -> CatalogSnapshot:
- if self.store is None or self.deadline is None:
- raise MetadataRefreshError("unsupported")
- return self.store.read(self.fetch, deadline=self.deadline)
-
- def refresh(self, *, deadline: float) -> MetadataRefreshResult:
- if self.store is None:
- raise MetadataRefreshError("unsupported")
- return self.store.refresh(self.fetch, deadline=deadline)
-
- def get_runtime_schema(
- self, runtime_data: dict[str, Any] | None = None
- ) -> dict[str, Any]:
- return {"enum": json.loads(self.snapshot().payload)}
-
-
-class OptedInLayer(LegacyLayer):
- def __init__(self) -> None:
- self.adapter: MemoryAdapter = MemoryAdapter()
-
- @classmethod
- def supports_metadata_refresh(cls, configuration: dict[str, Any]) -> bool:
- return True
-
- @property
- def metadata_refresh(self) -> MemoryAdapter:
- return self.adapter
-
- def get_semantic_view(
- self, name: str, additional_configuration: dict[str, Any]
- ) -> SnapshotView:
- return SnapshotView(self.adapter.snapshot())
-
- def get_semantic_views(
- self, runtime_configuration: dict[str, Any]
- ) -> set[LegacyView]:
- return {self.get_semantic_view("example", {})}
-
-
def test_legacy_provider_needs_no_metadata_overrides() -> None:
layer: LegacyLayer = LegacyLayer.from_configuration({})
view: LegacyView = layer.get_semantic_view("legacy", {})
- assert not layer.supports_metadata_refresh({})
- assert layer.metadata_refresh is None
assert view.metadata_cache_token is None
assert {metric.id for metric in view.get_compatible_metrics(set(), set())}
== {
"orders"
@@ -246,100 +109,6 @@ def test_legacy_provider_needs_no_metadata_overrides() ->
None:
assert view.get_table(SemanticQuery([], [])).results.to_pydict() ==
{"value": [17]}
-def test_provider_and_host_exchange_one_captured_observation() -> None:
- layer: OptedInLayer = OptedInLayer()
- store: MemoryStore = MemoryStore()
- assert layer.supports_metadata_refresh({})
- assert layer.adapter.fetches == 0
- assert layer.metadata_refresh is layer.metadata_refresh
- deadline: float = monotonic() + 30.0
- layer.metadata_refresh.bind(store, deadline=deadline)
- old_view: SnapshotView = layer.get_semantic_view("example", {})
- assert layer.adapter.get_runtime_schema() == {"enum": ["orders",
"revenue"]}
- assert layer.adapter.fetches == 1
- assert {metric.id for metric in old_view.get_metrics()} == {"orders",
"revenue"}
- assert {metric.verbose_name for metric in old_view.get_metrics()} == {
- "Shared label"
- }
-
- layer.adapter.payload = '["orders", "revenue", "customers"]'
- result: MetadataRefreshResult =
layer.metadata_refresh.refresh(deadline=deadline)
- new_view: SnapshotView = layer.get_semantic_view("example", {})
- assert result.status == "changed"
- assert new_view.metadata_cache_token == result.snapshot.cache_token
- assert old_view.metadata_cache_token != new_view.metadata_cache_token
- assert {metric.id for metric in old_view.get_metrics()} == {"orders",
"revenue"}
- assert {metric.id for metric in new_view.get_compatible_metrics(set(),
set())} == {
- "orders",
- "revenue",
- "customers",
- }
- assert layer.adapter.get_runtime_schema() == {
- "enum": ["orders", "revenue", "customers"]
- }
- assert layer.adapter.fetches == 2
-
-
-def test_unchanged_discovery_can_have_a_new_cache_identity() -> None:
- deadline: float = monotonic() + 30.0
- adapter: MemoryAdapter = MemoryAdapter()
- adapter.bind(MemoryStore(), deadline=deadline)
- first: MetadataRefreshResult = adapter.refresh(deadline=deadline)
- second: MetadataRefreshResult = adapter.refresh(deadline=deadline)
- assert second.status == "unchanged"
- assert first.snapshot.payload == second.snapshot.payload
- assert first.snapshot.cache_token != second.snapshot.cache_token
-
-
-def test_adapter_example_rejects_use_before_binding_and_rebinding() -> None:
- deadline: float = monotonic() + 30.0
- adapter: MemoryAdapter = MemoryAdapter()
- with pytest.raises(MetadataRefreshError, match="unsupported"):
- adapter.refresh(deadline=deadline)
- adapter.bind(MemoryStore(), deadline=deadline)
- with pytest.raises(MetadataRefreshError, match="configuration"):
- adapter.bind(MemoryStore(), deadline=deadline)
- assert adapter.fetches == 0
-
-
[email protected]("field", ["payload", "cache_token", "observed_at"])
-def test_snapshot_cannot_change_after_capture(field: str) -> None:
- snapshot: CatalogSnapshot = CatalogSnapshot("[]", "scope:1",
"2026-09-30T12:00:00Z")
- with pytest.raises(FrozenInstanceError):
- setattr(snapshot, field, "replacement")
-
-
[email protected]("field", ["status", "snapshot"])
-def test_refresh_result_cannot_change_after_publication(field: str) -> None:
- snapshot: CatalogSnapshot = CatalogSnapshot("[]", "scope:1",
"2026-09-30T12:00:00Z")
- result: MetadataRefreshResult = MetadataRefreshResult("changed", snapshot)
- with pytest.raises(FrozenInstanceError):
- setattr(result, field, "replacement")
-
-
[email protected](
- "category",
- [
- "unsupported",
- "configuration",
- "in_progress",
- "configuration_changed",
- "upstream",
- "invalid_payload",
- "deadline",
- "unavailable",
- "indeterminate",
- ],
-)
-def test_declared_errors_cross_the_boundary_without_vendor_details(
- category: MetadataRefreshErrorCategory,
-) -> None:
- error: MetadataRefreshError = MetadataRefreshError(category)
- assert error.category == category
- assert str(error) == category
- assert error.args == (category,)
-
-
def test_sdk_metadata_imports_without_host_dependencies() -> None:
source: Path = Path(__file__).resolve().parents[3] / "superset-core" /
"src"
script: str = """
@@ -360,13 +129,8 @@ class RejectHostImports(importlib.abc.MetaPathFinder):
sys.meta_path.insert(0, RejectHostImports())
sys.path.insert(0, sys.argv[1])
-from superset_core.semantic_layers import metadata as metadata_sdk
from superset_core.semantic_layers.layer import SemanticLayer
-from superset_core.semantic_layers.metadata import CatalogSnapshot
from superset_core.semantic_layers.view import SemanticView
-assert metadata_sdk.remaining_budget(25.0, now=0.0) == 25.0
-assert not SemanticLayer.supports_metadata_refresh({})
-assert CatalogSnapshot('[]', 'scope:1', '2026-09-30T12:00:00Z').cache_token ==
'scope:1'
"""
# The isolated import probe uses a fixed script/interpreter and no shell.
completed: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603
@@ -379,316 +143,32 @@ assert CatalogSnapshot('[]', 'scope:1',
'2026-09-30T12:00:00Z').cache_token == '
assert completed.returncode == 0, completed.stderr
[email protected]("category", ["", "vendor detail: private upstream
response"])
-def test_unknown_error_category_is_rejected_without_echoing_it(category: str)
-> None:
- with pytest.raises(ValueError, match="^Unknown metadata refresh error
category$"):
- MetadataRefreshError(cast(MetadataRefreshErrorCategory, category))
-
-
-def test_empty_snapshot_identity_is_rejected() -> None:
- with pytest.raises(ValueError, match="^Catalog cache token must not be
empty$"):
- CatalogSnapshot("[]", "", "2026-09-30T12:00:00Z")
+class CapturedView(LegacyView):
+ """A provider view retains the identity captured with its members."""
+ def __init__(self, token: str) -> None:
+ self.token: str = token
-def test_optional_adapter_annotation_resolves_for_sdk_introspection() -> None:
- descriptor: property = inspect.getattr_static(SemanticLayer,
"metadata_refresh")
- assert descriptor.fget is not None
- hints: dict[str, Any] = get_type_hints(descriptor.fget)
- assert hints["return"] == MetadataRefreshAdapter | None
+ @property
+ def metadata_cache_token(self) -> str:
+ return self.token
[email protected]("status", ["", "failed", "indeterminate"])
-def test_unconfirmed_refresh_status_cannot_construct_success(status: str) ->
None:
- snapshot: CatalogSnapshot = CatalogSnapshot("[]", "scope:1",
"2026-09-30T12:00:00Z")
- with pytest.raises(ValueError, match="^Unknown metadata refresh result
status$"):
- MetadataRefreshResult(cast(Literal["changed", "unchanged"], status),
snapshot)
+def test_views_retain_their_captured_cache_identity() -> None:
+ """A later provider observation does not relabel an existing view."""
+ old_view: CapturedView = CapturedView("scope:1")
+ new_view: CapturedView = CapturedView("scope:2")
+ assert old_view.metadata_cache_token == "scope:1" # noqa: S105
+ assert new_view.metadata_cache_token == "scope:2" # noqa: S105
[email protected]("as_result", [False, True])
-def test_snapshot_representations_omit_catalog_and_cache_identity(
- as_result: bool,
-) -> None:
- snapshot: CatalogSnapshot = CatalogSnapshot(
- '["private_catalog_member"]', "private_cache_identity",
"2026-09-30T12:00:00Z"
- )
- value: CatalogSnapshot | MetadataRefreshResult = (
- MetadataRefreshResult("changed", snapshot) if as_result else snapshot
+def test_legacy_factory_accepts_optional_host_identity() -> None:
+ """Existing providers need not implement the cache-aware factory hook."""
+ layer: SemanticLayer[BaseModel, LegacyView] = (
+ LegacyLayer.from_configuration_with_cache_token(
+ {},
+ cache_token="host-generation", # noqa: S106
+ )
)
- representation: str = repr(value)
- assert "private_catalog_member" not in representation
- assert "private_cache_identity" not in representation
- assert "2026-09-30T12:00:00Z" in representation
-
-
-def test_catalog_loader_accepts_one_absolute_deadline() -> None:
- assert get_args(CatalogLoader) == ([float], str)
-
-
-def test_expired_loader_deadline_rejects_before_acquisition() -> None:
- adapter: MemoryAdapter = MemoryAdapter()
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- adapter.fetch(monotonic() - 1.0)
- assert adapter.fetches == 0
-
-
-def test_store_passes_one_deadline_and_cache_hits_do_not_acquire(
- clock: list[float],
-) -> None:
- deadlines: list[float] = []
-
- def fetch(deadline: float) -> str:
- deadlines.append(deadline)
- return "[]"
-
- store: MemoryStore = MemoryStore()
- first: CatalogSnapshot = store.read(fetch, deadline=125.0)
- assert store.read(fetch, deadline=125.0) is first
- refreshed: MetadataRefreshResult = store.refresh(fetch, deadline=125.0)
- assert refreshed.status == "unchanged"
- assert deadlines == [125.0, 125.0]
-
-
[email protected]("field", ["payload", "cache_token", "observed_at"])
[email protected]("value", [None, 17, True, ["private upstream
detail"]])
-def test_snapshot_rejects_non_string_fields(field: str, value: object) -> None:
- """Malformed fields cannot cross the SDK boundary or appear in errors."""
- fields: dict[str, str] = {
- "payload": "[]",
- "cache_token": "scope:1",
- "observed_at": "2026-09-30T12:00:00Z",
- }
- fields[field] = cast(str, value)
- with pytest.raises(TypeError, match=f"^Catalog {field} must be a string$"):
- CatalogSnapshot(**fields)
-
-
-def test_snapshot_rejects_empty_observation_time() -> None:
- """An observation always carries a source timestamp for display."""
- with pytest.raises(ValueError, match="^Catalog observed_at must not be
empty$"):
- CatalogSnapshot("[]", "scope:1", "")
-
-
[email protected](
- "method",
- [
- MetadataSnapshotStore.read,
- MetadataSnapshotStore.refresh,
- MetadataRefreshAdapter.bind,
- MetadataRefreshAdapter.refresh,
- ],
-)
-def test_metadata_operations_require_explicit_caller_deadline(
- method: Callable[..., object],
-) -> None:
- """All metadata operation seams accept the same SDK-native deadline
type."""
- parameter: inspect.Parameter =
inspect.signature(method).parameters["deadline"]
- assert parameter.default is inspect.Parameter.empty
- assert parameter.kind is inspect.Parameter.KEYWORD_ONLY
- assert get_type_hints(method)["deadline"] is float
-
-
[email protected]("deadline", [float("nan"), float("inf"),
-float("inf"), -1.0])
[email protected]("operation", ["read", "refresh", "adapter", "loader"])
-def test_example_rejects_invalid_deadlines_without_acquisition(
- deadline: float,
- operation: str,
-) -> None:
- """Invalid or exhausted budgets cannot fetch or publish metadata."""
- store: MemoryStore = MemoryStore()
- adapter: MemoryAdapter = MemoryAdapter()
- adapter.bind(store, deadline=monotonic() + 30.0)
- operations: dict[str, Callable[[], object]] = {
- "read": lambda: store.read(adapter.fetch, deadline=deadline),
- "refresh": lambda: store.refresh(adapter.fetch, deadline=deadline),
- "adapter": lambda: adapter.refresh(deadline=deadline),
- "loader": lambda: adapter.fetch(deadline),
- }
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- operations[operation]()
- assert adapter.fetches == 0
- assert store.publications == 0
-
-
-def test_sequential_store_calls_share_remaining_budget(
- clock: list[float],
-) -> None:
- """Time spent in one acquisition reduces the next call's remaining
budget."""
- deadlines: list[float] = []
-
- def fetch(deadline: float) -> str:
- """Consume part of the operation's budget."""
- deadlines.append(deadline)
- clock[0] += 20.0
- return "[]"
-
- store: MemoryStore = MemoryStore()
- store.read(fetch, deadline=150.0)
- store.refresh(fetch, deadline=150.0)
- assert deadlines == [150.0, 150.0]
- before: CatalogSnapshot | None = store.snapshot
- clock[0] = 150.0
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- store.refresh(fetch, deadline=150.0)
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- store.read(fetch, deadline=150.0)
- assert len(deadlines) == 2
- assert store.publications == 2
- assert store.snapshot is before
-
-
-def test_provider_factory_accepts_discovery_deadline_only_at_bind(
- clock: list[float],
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- """Normal construction stays deadline-free; bind carries the host
budget."""
- deadlines: list[float] = []
-
- def fetch(self: MemoryAdapter, deadline: float) -> str:
- """Capture the unchanged budget passed to provider acquisition."""
- deadlines.append(deadline)
- return self.payload
-
- monkeypatch.setattr(MemoryAdapter, "fetch", fetch)
- layer: LegacyLayer = OptedInLayer.from_configuration({})
- assert isinstance(layer, OptedInLayer)
- assert layer.adapter.fetches == 0
- assert layer.adapter.store is None
- assert deadlines == []
- with pytest.raises(MetadataRefreshError, match="^unsupported$"):
- layer.get_semantic_view("example", {})
- store: MemoryStore = MemoryStore()
- layer.adapter.bind(store, deadline=125.0)
- assert layer.get_semantic_views({})
- clock[0] = 120.0
- assert layer.adapter.get_runtime_schema() == {"enum": ["orders",
"revenue"]}
- layer.adapter.refresh(deadline=125.0)
- assert deadlines == [125.0, 125.0]
- clock[0] = 125.0
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- layer.adapter.get_runtime_schema()
- assert store.publications == 2
-
-
[email protected]("deadline", [float("nan"), float("inf"),
-float("inf"), 100.0])
-def test_invalid_bind_deadline_does_not_partially_bind(
- clock: list[float], deadline: float
-) -> None:
- """Invalid binding leaves the adapter unbound and permits a valid first
bind."""
-
- layer: LegacyLayer = OptedInLayer.from_configuration({})
- assert isinstance(layer, OptedInLayer)
- store: MemoryStore = MemoryStore()
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- layer.adapter.bind(store, deadline=deadline)
- assert layer.adapter.store is None
- assert layer.adapter.fetches == 0
- with pytest.raises(MetadataRefreshError, match="^unsupported$"):
- layer.adapter.snapshot()
- layer.adapter.bind(store, deadline=125.0)
- assert layer.adapter.snapshot().payload == layer.adapter.payload
- with pytest.raises(MetadataRefreshError, match="^configuration$"):
- layer.adapter.bind(MemoryStore(), deadline=150.0)
-
-
-def test_discovery_and_adapter_refresh_preserve_host_deadline(
- clock: list[float],
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- """View/schema discovery and explicit refresh retain one caller budget."""
- deadlines: list[float] = []
-
- def fetch(self: MemoryAdapter, deadline: float) -> str:
- """Record the deadline reaching the provider through each path."""
- deadlines.append(deadline)
- return self.payload
-
- monkeypatch.setattr(MemoryAdapter, "fetch", fetch)
- layer: OptedInLayer = OptedInLayer()
- store: MemoryStore = MemoryStore()
- layer.adapter.bind(store, deadline=125.0)
- layer.get_semantic_view("example", {})
- clock[0] = 120.0
- assert layer.adapter.get_runtime_schema() == {"enum": ["orders",
"revenue"]}
- layer.adapter.refresh(deadline=125.0)
- assert deadlines == [125.0, 125.0]
- clock[0] = 125.0
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- layer.get_semantic_views({})
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- layer.adapter.get_runtime_schema()
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- layer.adapter.refresh(deadline=125.0)
- assert deadlines == [125.0, 125.0]
- assert store.publications == 2
-
-
[email protected]("warm", [False, True])
-def test_acquisition_exhausting_budget_cannot_publish(
- clock: list[float], warm: bool
-) -> None:
- """A completed fetch cannot publish or replace a snapshot after its
deadline."""
-
- def fetch(deadline: float) -> str:
- """Exhaust the budget during acquisition."""
- clock[0] = deadline
- return '["new"]'
-
- store: MemoryStore = MemoryStore()
- if warm:
- store.snapshot = CatalogSnapshot("[]", "scope:1",
"2026-09-30T12:00:00Z")
- store.publications = 1
- before: CatalogSnapshot | None = store.snapshot
- publications: int = store.publications
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- store.refresh(fetch, deadline=150.0)
- assert store.publications == publications
- assert store.snapshot is before
-
-
[email protected](
- "deadline",
- [
- float("nan"),
- float("inf"),
- -float("inf"),
- 0.0,
- 99.0,
- 100.0,
- 401.0,
- 1_790_000_000.0,
- ],
-)
-def test_remaining_budget_rejects_invalid_or_implausible_deadline(
- clock: list[float], deadline: float
-) -> None:
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- remaining_budget(deadline)
-
-
-def test_remaining_budget_decreases_and_accepts_ceiling(clock: list[float]) ->
None:
- assert metadata_sdk.MAX_METADATA_BUDGET_SECONDS == 300.0
- assert remaining_budget(400.0) == 300.0
- clock[0] = 110.0
- assert remaining_budget(400.0) == 290.0
- clock[0] = 400.0
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- remaining_budget(400.0)
-
-
[email protected]("snapshot", [None, "not a snapshot", {}, 17, True])
-def test_refresh_result_rejects_non_snapshot(snapshot: object) -> None:
- with pytest.raises(
- TypeError, match="^Metadata refresh snapshot must be a
CatalogSnapshot$"
- ):
- MetadataRefreshResult("changed", cast(CatalogSnapshot, snapshot))
-
-
[email protected]("now", [float("nan"), float("inf"), -float("inf"),
400.0])
-def test_remaining_budget_rejects_invalid_injected_clock(now: float) -> None:
- with pytest.raises(MetadataRefreshError, match="^deadline$"):
- remaining_budget(400.0, now=now)
-
-
-def test_remaining_budget_uses_injected_monotonic_clock() -> None:
- assert remaining_budget(125.0, now=100.0) == 25.0
- assert remaining_budget(25.0, now=0.0) == 25.0
+ assert isinstance(layer, LegacyLayer)
+ assert layer.get_semantic_view("legacy", {}).metadata_cache_token is None
diff --git a/tests/unit_tests/semantic_layers/models_test.py
b/tests/unit_tests/semantic_layers/models_test.py
index 721da927b00..47d7154fdde 100644
--- a/tests/unit_tests/semantic_layers/models_test.py
+++ b/tests/unit_tests/semantic_layers/models_test.py
@@ -260,7 +260,7 @@ def test_semantic_layer_implementation() -> None:
mock_class = MagicMock()
mock_impl = MagicMock()
- mock_class.from_configuration.return_value = mock_impl
+ mock_class.from_configuration_with_cache_token.return_value = mock_impl
with patch.dict(
"superset.semantic_layers.models.registry",
@@ -272,7 +272,9 @@ def test_semantic_layer_implementation() -> None:
result = layer.implementation
- mock_class.from_configuration.assert_called_once_with({"key": "value"})
+ mock_class.from_configuration_with_cache_token.assert_called_once_with(
+ {"key": "value"}, cache_token=layer.metadata_generation
+ )
assert result == mock_impl
@@ -500,7 +502,7 @@ def test_semantic_view_get_extra_cache_keys() -> None:
"superset.semantic_layers.models.registry", {"fixture": ProviderLayer}
):
result: list[Any] = view.get_extra_cache_keys({})
- assert result == []
+ assert result == [view.metadata_generation]
def test_semantic_view_perm() -> None:
@@ -2673,7 +2675,7 @@ def
test_result_generation_reads_class_without_provider_construction(
view: SemanticView =
SemanticView(semantic_layer=SemanticLayer(type="fixture"))
with patch.dict("superset.semantic_layers.models.registry", {"fixture":
provider}):
assert view.result_cache_version == version
- assert view.get_extra_cache_keys({}) == (
+ assert view.get_extra_cache_keys({}) == [view.metadata_generation] + (
[] if version is None else [("semantic-result-version", "fixture",
version)]
)
provider.assert_not_called()
diff --git a/tests/unit_tests/semantic_layers/simple_cache_clear_test.py
b/tests/unit_tests/semantic_layers/simple_cache_clear_test.py
new file mode 100644
index 00000000000..fb906b4e4c0
--- /dev/null
+++ b/tests/unit_tests/semantic_layers/simple_cache_clear_test.py
@@ -0,0 +1,650 @@
+# 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.
+
+from __future__ import annotations
+
+from collections.abc import Hashable
+from typing import Any
+from unittest.mock import call, Mock, patch, PropertyMock
+from uuid import uuid4
+
+import pytest
+from flask import Flask
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from superset.commands.semantic_layer.exceptions import
SemanticLayerUpdateFailedError
+from superset.commands.semantic_layer.update import UpdateSemanticLayerCommand
+from superset.semantic_layers.models import SemanticLayer, SemanticView
+
+pytestmark: pytest.MarkDecorator = pytest.mark.parametrize(
+ "app", [{"FEATURE_FLAGS": {"SEMANTIC_LAYERS": True}}], indirect=True
+)
+
+
[email protected]
+def layer(session: Session, monkeypatch: pytest.MonkeyPatch) -> SemanticLayer:
+ """Persist a layer without involving a provider or permission event
callbacks."""
+ from superset_core.semantic_layers.layer import SemanticLayer as
ProviderLayer
+
+ from superset.semantic_layers.models import registry
+
+ monkeypatch.setitem(registry, "test", ProviderLayer)
+ SemanticLayer.metadata.create_all(session.get_bind())
+ session.execute(
+ SemanticLayer.__table__.insert().values(
+ uuid=uuid4(),
+ name="test",
+ type="test",
+ configuration="{}",
+ )
+ )
+ session.commit()
+ return session.scalars(select(SemanticLayer)).one()
+
+
[email protected](
+ "properties,expected",
+ [
+ ({"configuration": {}}, 1),
+ ({"description": "changed"}, 0),
+ ],
+)
+def test_save_rotates_only_submitted_configuration(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+ properties: dict[str, Any],
+ expected: int,
+) -> None:
+ """Save writes its generation in the same transaction, even for unchanged
config."""
+ command: UpdateSemanticLayerCommand = UpdateSemanticLayerCommand(
+ str(layer.uuid), properties
+ )
+ with app.app_context(), patch.object(command, "validate"):
+ command._model = layer
+ command.run()
+ session.expire_all()
+ assert session.get(SemanticLayer, layer.uuid).cache_version == expected
+
+
+def test_failed_save_rolls_back_configuration_and_version(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+) -> None:
+ """Commit failure cannot publish a generation without the corresponding
Save."""
+ command: UpdateSemanticLayerCommand = UpdateSemanticLayerCommand(
+ str(layer.uuid),
+ {"configuration": {"value": "new"}},
+ )
+ command._model = layer
+ with (
+ app.app_context(),
+ patch.object(command, "validate"),
+ patch.object(
+ session,
+ "commit",
+ side_effect=ValueError("failed commit"),
+ ),
+ pytest.raises(SemanticLayerUpdateFailedError),
+ ):
+ command.run()
+ session.expire_all()
+ assert layer.configuration == "{}"
+ assert layer.cache_version == 0
+
+
+def test_metadata_generation_is_captured_and_namespaced(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+) -> None:
+ """An operation cannot label old discovery with a newly rotated
generation."""
+ with app.app_context():
+ first: str = layer.metadata_generation
+ layer.cache_version = 1
+ assert layer.metadata_generation == first
+ session.info.clear()
+ second: str = layer.metadata_generation
+ assert second != first
+ app.config["SEMANTIC_LAYER_CACHE_NAMESPACE"] = "other-workspace"
+ session.info.clear()
+ assert layer.metadata_generation != second
+ app.config.pop("SEMANTIC_LAYER_CACHE_NAMESPACE")
+
+
+def test_generation_binding_precedes_provider_construction(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+) -> None:
+ """The optional factory hook receives identity before eager discovery can
run."""
+ factory: Mock = Mock(result_cache_version=None)
+ with (
+ app.app_context(),
+ patch.dict("superset.semantic_layers.models.registry", {"test":
factory}),
+ ):
+ assert (
+ layer.implementation
+ is factory.from_configuration_with_cache_token.return_value
+ )
+ factory.from_configuration_with_cache_token.assert_called_once_with(
+ {},
+ cache_token=layer.metadata_generation,
+ )
+ view: SemanticView = SemanticView(name="view", semantic_layer=layer)
+ assert view.metadata_generation == layer.metadata_generation
+ assert view.get_extra_cache_keys({}) == [layer.metadata_generation]
+
+
+def test_host_generation_is_named_apart_from_provider_token(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+) -> None:
+ """Host keys use the metadata generation, never the provider's echoed
token."""
+ with app.app_context():
+ view: SemanticView = SemanticView(name="view", semantic_layer=layer)
+ view.__dict__["implementation"] = Mock(
+ metadata_cache_token="provider-echo" # noqa: S106
+ )
+ assert view.metadata_generation == layer.metadata_generation
+ # Containment (#42760) still reads the host generation by its older
name.
+ assert view.metadata_cache_token == view.metadata_generation
+ assert view.get_extra_cache_keys({}) == [layer.metadata_generation]
+
+
[email protected](
+ "allowed,guest,modifiable",
+ [
+ (True, False, True),
+ (False, False, True),
+ (True, True, True),
+ (True, False, False),
+ ],
+)
+def test_clear_requires_management_authority_without_provider_work(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+ allowed: bool,
+ guest: bool,
+ modifiable: bool,
+) -> None:
+ """Clear uses the management gate and commits only for an authorized
owner."""
+ from superset.commands.semantic_layer.exceptions import
SemanticLayerForbiddenError
+ from superset.commands.semantic_layer.update import
ClearSemanticLayerCacheCommand
+
+ security: Mock
+ with (
+ app.app_context(),
+ patch(
+ "superset.commands.semantic_layer.update.security_manager",
new=Mock()
+ ) as security,
+ patch(
+
"superset.commands.semantic_layer.update.current_user_can_modify_object",
+ return_value=modifiable,
+ ),
+ patch(
+
"superset.commands.semantic_layer.update.SemanticLayerDAO.find_by_uuid",
+ return_value=layer,
+ ),
+ patch.object(
+ layer,
+ "raise_for_access",
+ ),
+ patch.dict("superset.semantic_layers.models.registry", {}, clear=True),
+ ):
+ security.is_guest_user.return_value = guest
+ security.can_access.return_value = allowed
+ command: ClearSemanticLayerCacheCommand =
ClearSemanticLayerCacheCommand(
+ str(layer.uuid)
+ )
+ if allowed and not guest and modifiable:
+ command.run()
+ assert layer.cache_version == 1
+ else:
+ with pytest.raises(SemanticLayerForbiddenError):
+ command.run()
+ assert layer.cache_version == 0
+ assert "implementation" not in layer.__dict__
+
+
[email protected](
+ "payload,status", [({}, 200), ({"version": 4}, 400), ([], 400)]
+)
+def test_clear_route_requires_empty_object(
+ client: Any,
+ full_api_access: None,
+ payload: Any,
+ status: int,
+) -> None:
+ """The clear endpoint does not accept arbitrary configuration or
versions."""
+ command: Mock
+ with (
+ patch("superset.semantic_layers.api.is_feature_enabled",
return_value=True),
+ patch(
+ "superset.semantic_layers.api.ClearSemanticLayerCacheCommand",
+ create=True,
+ ) as command,
+ ):
+ response: Any = client.post(
+ f"/api/v1/semantic_layer/{uuid4()}/clear_cache", json=payload
+ )
+ assert response.status_code == status
+ assert command.return_value.run.call_count == (status == 200)
+
+
[email protected]("route", ["compatible", "column/category/values/"])
+def test_suggestion_cache_rotates_with_metadata_generation(
+ client: Any,
+ full_api_access: None,
+ route: str,
+) -> None:
+ """Both endpoint caches partition generations without changing their query
scope."""
+ view: Mock = Mock(spec=SemanticView)
+ view.uid = "view"
+ view.cache_timeout = 60
+ view.changed_on = None
+ view.type = "semantic_view"
+ view.metadata_generation = "generation-zero"
+ view.result_cache_discriminator = None
+ view.implementation.selection_identity_version = None
+ view.get_compatible_metrics.return_value = []
+ view.get_compatible_dimensions.return_value = []
+ view.values_for_column.return_value = ["one"]
+ cache: Mock
+ with (
+ patch(
+ "superset.datasource.api.DatasourceDAO.get_datasource",
return_value=view
+ ),
+ patch(
+ "superset.datasource.api.cache_manager",
+ ) as cache,
+ patch(
+ "superset.datasource.api.security_manager.get_rls_cache_key",
+ return_value=[],
+ ),
+ ):
+ cache.data_cache.get.return_value = None
+ endpoint: str = f"/api/v1/datasource/semantic_view/1/{route}"
+
+ def request() -> Any:
+ return (
+ client.post(endpoint, json={})
+ if route == "compatible"
+ else client.get(endpoint)
+ )
+
+ assert request().status_code == 200
+ first: str = cache.data_cache.get.call_args.args[0]
+ view.metadata_generation = "generation-one"
+ assert request().status_code == 200
+ assert cache.data_cache.get.call_args.args[0] != first
+
+
+def test_annotation_result_key_uses_saved_source_generation(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+) -> None:
+ """Annotation versions follow the datasource in the saved query context."""
+ from superset.common.query_context_processor import QueryContextProcessor
+
+ view: SemanticView = SemanticView(name="view", semantic_layer=layer)
+ chart: Mock =
Mock(query_context='{"datasource":{"id":17,"type":"semantic_view"}}')
+ query: Mock = Mock(annotation_layers=[{"sourceType": "line", "value": 7}])
+ processor: QueryContextProcessor = QueryContextProcessor(Mock())
+ get_datasource: Mock
+ with (
+ app.app_context(),
+ patch(
+ "superset.common.query_context_processor.ChartDAO.find_by_id",
+ return_value=chart,
+ ),
+ patch(
+ "superset.daos.datasource.DatasourceDAO.get_datasource",
+ return_value=view,
+ ) as get_datasource,
+ patch(
+
"superset.common.query_context_processor.security_manager.get_rls_cache_key",
+ return_value=[],
+ ),
+ ):
+ first: dict[str, Any] = processor._annotation_cache_context(query)
+ layer.cache_version = 1
+ session.info.clear()
+ view.forget_metadata()
+ assert processor._annotation_cache_context(query) != first
+ # The saved query context's datasource, not the chart's own, is resolved.
+ assert get_datasource.call_args_list == [call("semantic_view", 17)] * 2
+
+
+def test_stale_writers_each_increment_the_database_version(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+) -> None:
+ """Two writers that read zero must commit two increments, not overwrite
one."""
+ other: Session
+ with Session(session.get_bind()) as other:
+ stale: SemanticLayer = other.get(SemanticLayer, layer.uuid)
+ assert stale.cache_version == layer.cache_version == 0
+ layer.clear_metadata_cache()
+ session.commit()
+ stale.clear_metadata_cache()
+ other.commit()
+ session.expire_all()
+ assert layer.cache_version == 2
+
+
+def test_clear_discards_reused_implementation_objects(
+ session: Session,
+ layer: SemanticLayer,
+) -> None:
+ """Reusing ORM instances after a Save cannot reuse their old
implementation."""
+ view: SemanticView = SemanticView(name="view", semantic_layer=layer)
+ session.add(view)
+ session.commit()
+ layer.__dict__["implementation"] = Mock()
+ view.__dict__["implementation"] = Mock()
+ previous: str = view.metadata_generation
+ layer.clear_metadata_cache()
+ session.commit()
+ assert "implementation" not in layer.__dict__
+ assert "implementation" not in view.__dict__
+ assert view.metadata_generation != previous
+
+
+def test_migration_backfills_existing_layers_and_downgrades() -> None:
+ """The server default initializes existing rows and non-ORM inserts
alike."""
+ from importlib import import_module
+
+ from alembic.migration import MigrationContext
+ from alembic.operations import Operations
+ from sqlalchemy import create_engine, text
+ from sqlalchemy.engine import Connection, Engine
+
+ migration: Any = import_module(
+
"superset.migrations.versions.2026-10-08_00-00_c179af013f48_add_semantic_layer_cache_version"
+ )
+ engine: Engine = create_engine("sqlite://")
+ connection: Connection
+ with engine.begin() as connection:
+ connection.execute(text("CREATE TABLE semantic_layers (name TEXT NOT
NULL)"))
+ connection.execute(
+ text("INSERT INTO semantic_layers (name) VALUES ('existing')")
+ )
+ with Operations.context(MigrationContext.configure(connection)):
+ migration.upgrade()
+ connection.execute(
+ text("INSERT INTO semantic_layers (name) VALUES ('new')")
+ )
+ assert connection.execute(
+ text("SELECT cache_version FROM semantic_layers")
+ ).scalars().all() == [0, 0]
+ migration.downgrade()
+ assert connection.execute(text("SELECT * FROM
semantic_layers")).all() == [
+ ("existing",),
+ ("new",),
+ ]
+ engine.dispose()
+
+
[email protected](
+ "error,status",
+ [
+ ("SemanticLayerNotFoundError", 404),
+ ("SemanticLayerForbiddenError", 403),
+ ("SemanticLayerUpdateFailedError", 422),
+ ],
+)
+def test_clear_route_preserves_management_errors(
+ client: Any,
+ full_api_access: None,
+ error: str,
+ status: int,
+) -> None:
+ """Management failures retain their status and cannot report success."""
+ from superset.commands.semantic_layer import exceptions
+
+ command: Mock
+ with patch(
+ "superset.semantic_layers.api.ClearSemanticLayerCacheCommand"
+ ) as command:
+ command.return_value.run.side_effect = getattr(exceptions, error)()
+ assert (
+ client.post(
+ f"/api/v1/semantic_layer/{uuid4()}/clear_cache", json={}
+ ).status_code
+ == status
+ )
+
+
+def test_clear_route_disabled_feature_does_no_work(
+ client: Any, full_api_access: None
+) -> None:
+ """A disabled feature does not expose the mutation."""
+ command: Mock
+ with (
+ patch("superset.semantic_layers.api.is_feature_enabled",
return_value=False),
+ patch(
+ "superset.semantic_layers.api.ClearSemanticLayerCacheCommand",
+ ) as command,
+ ):
+ assert (
+ client.post(
+ f"/api/v1/semantic_layer/{uuid4()}/clear_cache", json={}
+ ).status_code
+ == 404
+ )
+ command.assert_not_called()
+
+
+def test_workspace_namespace_can_follow_the_active_tenant(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A shared database connection can distinguish routed workspace
schemas."""
+ scope: list[str] = ["first"]
+ monkeypatch.setitem(app.config, "SEMANTIC_LAYER_CACHE_NAMESPACE", lambda:
scope[0])
+ first: str = layer.metadata_generation
+ scope[0] = "second"
+ session.info.clear()
+ assert layer.metadata_generation != first
+ session.info["unrelated"] = 1
+ layer.clear_metadata_cache()
+ assert session.info["unrelated"] == 1
+
+
[email protected]("allowed,status", [(True, 200), (False, 403)])
+def test_clear_route_protects_write_permission(
+ client: Any,
+ full_api_access: None,
+ allowed: bool,
+ status: int,
+) -> None:
+ """The real protect decorator checks connection write, never view
write/read."""
+ from superset import security_manager
+
+ access: Mock
+ command: Mock
+ with (
+ patch.object(security_manager, "is_item_public", return_value=False),
+ patch.object(
+ security_manager,
+ "has_access",
+ return_value=allowed,
+ ) as access,
+ patch("superset.semantic_layers.api.ClearSemanticLayerCacheCommand")
as command,
+ ):
+ assert (
+ client.post(
+ f"/api/v1/semantic_layer/{uuid4()}/clear_cache", json={}
+ ).status_code
+ == status
+ )
+ access.assert_called_with("can_write", "SemanticLayer")
+ assert command.return_value.run.call_count == allowed
+
+
[email protected]("namespace", ["", "workspace"])
+def test_web_and_task_share_metadata_keys_despite_local_credentials(
+ session: Session,
+ layer: SemanticLayer,
+ app: Flask,
+ monkeypatch: pytest.MonkeyPatch,
+ namespace: str,
+) -> None:
+ """Credential, driver, query-option and secret rotation cannot strand
async hits."""
+ from sqlalchemy.engine import make_url
+
+ monkeypatch.setitem(app.config, "SEMANTIC_LAYER_CACHE_NAMESPACE",
namespace)
+ bind: Mock = Mock()
+ bind.engine.url = make_url(
+
"postgresql+psycopg2://web:old@metadata:5432/superset?application_name=web"
+ )
+ view: SemanticView = SemanticView(semantic_layer=layer)
+ with patch.object(session, "get_bind", return_value=bind):
+ web_key: list[Hashable] = view.get_extra_cache_keys({})
+ session.info.clear()
+ bind.engine.url = make_url(
+
"postgresql://worker:new@metadata:5432/superset?application_name=celery"
+ )
+ monkeypatch.setitem(app.config, "SECRET_KEY", "rotated-for-task")
+ task_view: SemanticView = SemanticView(semantic_layer=layer)
+ assert task_view.get_extra_cache_keys({}) == web_key
+
+
+def test_namespace_is_validated_and_captured_once(
+ app: Flask,
+ session: Session,
+ layer: SemanticLayer,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Reject misconfigured identity and evaluate a tenant resolver only
once."""
+ monkeypatch.setitem(app.config, "SEMANTIC_LAYER_CACHE_NAMESPACE", 17)
+ with pytest.raises(TypeError, match="must resolve to a string"):
+ assert layer.metadata_generation
+ namespace: Mock = Mock(return_value="workspace")
+ monkeypatch.setitem(app.config, "SEMANTIC_LAYER_CACHE_NAMESPACE",
namespace)
+ first: str = layer.metadata_generation
+ assert layer.metadata_generation == first
+ namespace.assert_called_once_with()
+
+
+def test_legacy_provider_keeps_host_invalidation(
+ session: Session,
+ layer: SemanticLayer,
+) -> None:
+ """A None SDK token never opts a legacy provider out of host cache
rotation."""
+ from tests.unit_tests.semantic_layers.metadata_contract_test import
LegacyLayer
+
+ view: SemanticView = SemanticView(name="legacy", semantic_layer=layer)
+ session.add(view)
+ session.commit()
+ with patch.dict("superset.semantic_layers.models.registry", {"test":
LegacyLayer}):
+ assert view.implementation.metadata_cache_token is None
+ before: list[Hashable] = view.get_extra_cache_keys({})
+ layer.clear_metadata_cache()
+ session.commit()
+ assert view.implementation.metadata_cache_token is None
+ assert view.get_extra_cache_keys({}) != before
+
+
+def test_clear_preserves_loaded_views_of_other_layers(
+ session: Session,
+ layer: SemanticLayer,
+) -> None:
+ """Invalidating one connection cannot discard another connection's
discovery."""
+ session.execute(
+ SemanticLayer.__table__.insert().values(
+ uuid=uuid4(),
+ name="other",
+ type="test",
+ configuration="{}",
+ )
+ )
+ other: SemanticLayer = session.scalars(
+ select(SemanticLayer).where(SemanticLayer.name == "other")
+ ).one()
+ view: SemanticView = SemanticView(name="view", semantic_layer=other)
+ session.add(view)
+ session.commit()
+ assert view.semantic_layer_uuid == other.uuid
+ implementation: Mock = Mock()
+ view.__dict__["implementation"] = implementation
+ before: str = view.metadata_generation
+ layer.clear_metadata_cache()
+ assert view.implementation is implementation
+ assert view.metadata_generation == before
+
+
[email protected]("changed", ["metadata", "producer"])
+def test_cache_keys_compose_metadata_and_producer_versions(
+ app: Flask, session: Session, layer: SemanticLayer, changed: str
+) -> None:
+ """Neither independently changing cache guarantee may be lost in the
merge."""
+ from superset.common.query_context_processor import QueryContextProcessor
+ from superset.datasource.api import _column_values_cache_key
+ from superset.models.slice import Slice
+
+ view: SemanticView = SemanticView(name="view", semantic_layer=layer)
+ provider: Mock = Mock(result_cache_version="v1")
+ chart: Slice = Slice(
+ datasource_type="semantic_view", datasource_id=17, semantic_view=view
+ )
+ query: Mock = Mock(annotation_layers=[{"sourceType": "line", "value": 7}])
+ processor: QueryContextProcessor = QueryContextProcessor(Mock())
+ with (
+ app.app_context(),
+ patch.dict("superset.semantic_layers.models.registry", {"test":
provider}),
+ patch.object(
+ SemanticView, "uid", new_callable=PropertyMock, return_value="view"
+ ),
+ patch(
+ "superset.common.query_context_processor.ChartDAO.find_by_id",
+ return_value=chart,
+ ),
+ patch(
+
"superset.common.query_context_processor.security_manager.get_rls_cache_key",
+ return_value=[],
+ ),
+ ):
+ first_generation: str = view.metadata_generation
+ first_result: list[Hashable] = view.get_extra_cache_keys({})
+ first_values: str = _column_values_cache_key(view, {"col": "category"})
+ first_annotation: dict[str, Any] =
processor._annotation_cache_context(query)
+ assert first_result == [
+ first_generation,
+ ("semantic-result-version", "test", "v1"),
+ ]
+ assert first_annotation["source_versions"] == {"7": first_generation}
+ assert first_annotation["semantic_result_versions"] == {"7": ("test",
"v1")}
+ if changed == "metadata":
+ layer.cache_version = 1
+ session.info.clear()
+ view.forget_metadata()
+ else:
+ provider.result_cache_version = "v2"
+ assert view.get_extra_cache_keys({}) != first_result
+ assert _column_values_cache_key(view, {"col": "category"}) !=
first_values
+ assert processor._annotation_cache_context(query) != first_annotation
+ provider.assert_not_called()
diff --git a/tests/unit_tests/semantic_layers/values_endpoint_test.py
b/tests/unit_tests/semantic_layers/values_endpoint_test.py
index 0c2b71d8124..4b4351a7d79 100644
--- a/tests/unit_tests/semantic_layers/values_endpoint_test.py
+++ b/tests/unit_tests/semantic_layers/values_endpoint_test.py
@@ -34,7 +34,7 @@ from superset_core.semantic_layers.types import (
SemanticResult,
)
-from superset.semantic_layers.models import SemanticView
+from superset.semantic_layers.models import SemanticLayer, SemanticView
@pytest.fixture
@@ -51,8 +51,6 @@ def semantic_view_datasource(mocker: MockerFixture) ->
SemanticView:
)
from superset_core.semantic_layers.layer import SemanticLayer as
ProviderLayer
- from superset.semantic_layers.models import SemanticLayer
-
mocker.patch.dict(
"superset.semantic_layers.models.registry", {"fixture": ProviderLayer}
)
@@ -227,8 +225,6 @@ def test_values_cache_excludes_legacy_generation(
) -> None:
from unittest.mock import PropertyMock
- from superset.semantic_layers.models import SemanticLayer
-
semantic_view_datasource.semantic_layer = SemanticLayer(type="fixture")
version: PropertyMock = mocker.patch.object(
SemanticView,
@@ -341,7 +337,7 @@ def
test_default_provider_value_cache_key_matches_legacy_bytes(
mocker: MockerFixture,
provider_type: str,
) -> None:
- """An SDK-default provider adds no field to the pre-guard cache
identity."""
+ """An SDK-default provider adds no producer field to metadata-versioned
keys."""
import hashlib
from superset_core.semantic_layers.layer import SemanticLayer as
ProviderLayer
@@ -374,6 +370,8 @@ def
test_default_provider_value_cache_key_matches_legacy_bytes(
"rls": ["scope-a"],
"changed_on": "None",
}
+ if isinstance(datasource, SemanticView):
+ legacy["metadata_version"] = datasource.metadata_generation
expected: str = (
"col_values:"
+ hashlib.sha256(json.dumps(legacy,
sort_keys=True).encode()).hexdigest()