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 fc5956b0ca4 fix(semantic-layer): use durable metadata cache identity 
(#45544)
fc5956b0ca4 is described below

commit fc5956b0ca4d36e1689a2cc83033a284b9b9e871
Author: Mike Bridge <[email protected]>
AuthorDate: Mon Oct 26 11:37:59 2026 -0600

    fix(semantic-layer): use durable metadata cache identity (#45544)
    
    Co-authored-by: Mike Bridge <[email protected]>
---
 UPDATING.md                                        |  10 +-
 docs/developer_docs/semantic-layer-cache-clear.md  |  18 +-
 superset/semantic_layers/execution.py              |  75 +++-
 superset/semantic_layers/models.py                 |  27 +-
 .../commands/report/semantic_view_report_test.py   |   1 +
 .../common/test_query_context_processor.py         |  13 +
 tests/unit_tests/datasource/test_compatible_api.py |  10 +-
 .../unit_tests/security/semantic_guest_rls_test.py |   5 +-
 .../semantic_layers/integer_precision_test.py      |   6 +-
 .../semantic_layers/metadata_namespace_test.py     | 422 +++++++++++++++++++++
 tests/unit_tests/semantic_layers/models_test.py    |  29 +-
 .../semantic_layers/simple_cache_clear_test.py     |  18 +-
 .../semantic_layers/values_endpoint_test.py        |  46 ++-
 13 files changed, 632 insertions(+), 48 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index 782b938a02d..1410a7666a8 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -217,9 +217,13 @@ assists people when migrating to a new version.
   Providers must accept required keyword-only `cache_token` and `context` on
   `from_configuration`; the optional `from_configuration_with_cache_token` hook
   is removed. Local metadata caches must include `cache_token` in their keys
-  before lookup or eager discovery for complete invalidation. When tenants 
share
-  a metadata database or web, Celery and MCP use different database host 
aliases,
-  configure
+  before lookup or eager discovery for complete invalidation. The default 
namespace
+  uses the active metadata database's durable installation identity rather than
+  its address, so web, Celery and MCP agree across host/port/database aliases.
+  This causes a one-time cache miss for semantic-layer results after deployment
+  when using the default namespace; update all roles together. Existing 
explicit
+  namespaces retain their key scheme. When tenants share a metadata database or
+  a full database clone must be isolated from its source, 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
diff --git a/docs/developer_docs/semantic-layer-cache-clear.md 
b/docs/developer_docs/semantic-layer-cache-clear.md
index b820b423ea8..b2ceb04aa7c 100644
--- a/docs/developer_docs/semantic-layer-cache-clear.md
+++ b/docs/developer_docs/semantic-layer-cache-clear.md
@@ -61,16 +61,24 @@ The context and reserved credentials channel are described 
in the
 [execution 
contract](extensions/extension-points/semantic-layers.md#execution-context). 
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
+an opaque digest of the durable installation identity stored in the active 
metadata
+database, not its connection address. Host aliases, proxy/default ports, 
database
+aliases, credentials, driver adapters, connection query options and 
`SECRET_KEY`
+do not affect it. `SEMANTIC_LAYER_CACHE_NAMESPACE`, when nonempty, replaces 
that
 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
+when sharing one metadata database across tenants or cloning a database into an
+isolated workspace: a full clone also copies the installation identity. 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.
 
+Switching from the address-derived default causes a one-time cache miss for
+semantic-layer results after deployment. Roll web, worker and MCP processes
+together so they use the same key scheme. An existing explicit namespace keeps
+its key scheme. After restoring a backup, clear surviving caches or rotate the
+explicit namespace to prevent reuse of generations captured before the restore.
+
 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
diff --git a/superset/semantic_layers/execution.py 
b/superset/semantic_layers/execution.py
index 3d30610bfb2..e261d9297d7 100644
--- a/superset/semantic_layers/execution.py
+++ b/superset/semantic_layers/execution.py
@@ -28,9 +28,10 @@ from uuid import uuid4
 
 from flask import current_app, g, has_request_context
 from sqlalchemy import select
-from sqlalchemy.engine import Engine
+from sqlalchemy.engine import Connection, Engine
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import object_session, Session
+from sqlalchemy.pool import SingletonThreadPool, StaticPool
 from sqlalchemy.sql import Select
 from superset_core.semantic_layers.context import (
     ExecutionMethod,
@@ -94,14 +95,71 @@ def semantic_namespace() -> str:
     return namespace
 
 
+def metadata_namespace(connection: Connection) -> str:
+    """Read the active metadata store's identity, not the app's startup bind.
+
+    Use the caller's connection without ORM autoflush or another pool checkout.
+    Only a missing identity needs an independent, durable bootstrap 
transaction.
+    Callers memoize this value only within their metadata session.
+    """
+    value: bytes | None = connection.scalar(_namespace_statement())
+    if value is not None:
+        return _decode_namespace(value)
+    if connection.dialect.name == "sqlite" and getattr(
+        connection.connection.driver_connection, "in_transaction", False
+    ):
+        # A separate writer could wait on this caller's SQLite transaction.
+        # Do not commit or roll back caller work to make bootstrap possible.
+        raise ValueError(
+            "Initialize the semantic execution namespace "
+            "before starting a SQLite transaction"
+        )
+    engine: Engine = connection.engine
+    if connection.dialect.name == "sqlite" and isinstance(
+        engine.pool, (SingletonThreadPool, StaticPool)
+    ):
+        # These pools share the caller's connection; recreating them loses an
+        # in-memory store. The transaction guard above protects caller writes.
+        return _install_namespace(engine)
+    # A separate pool preserves the configured connection creator/on-connect
+    # hooks without waiting for capacity held by this caller in the original 
pool.
+    bootstrap: Engine = Engine(
+        engine.pool.recreate(),
+        engine.dialect,
+        engine.url,
+        execution_options=engine.get_execution_options(),
+    )
+    try:
+        return _install_namespace(bootstrap)
+    finally:
+        bootstrap.dispose()
+
+
+def _namespace_statement() -> Select:
+    """Select the existing installation record shared by both identity 
readers."""
+    return select(KeyValueEntry.value).where(
+        KeyValueEntry.uuid == SEMANTIC_INSTALL_ID_KEY,
+        KeyValueEntry.resource == KeyValueResource.SEMANTIC_EXECUTION.value,
+    )
+
+
+def _decode_namespace(value: bytes | None) -> str:
+    """Validate persisted identity without substituting a process-local 
value."""
+    namespace: Any = (
+        JsonKeyValueCodec().decode(value).get("namespace")
+        if value is not None
+        else None
+    )
+    if not isinstance(namespace, str) or not namespace.strip():
+        raise ValueError("Semantic execution namespace is unavailable")
+    return namespace
+
+
 def _install_namespace(engine: Engine) -> str:
     """Create a durable UUID independently, including startup races."""
     codec: JsonKeyValueCodec = JsonKeyValueCodec()
     with Session(engine) as session:
-        statement: Select = select(KeyValueEntry.value).where(
-            KeyValueEntry.uuid == SEMANTIC_INSTALL_ID_KEY,
-            KeyValueEntry.resource == 
KeyValueResource.SEMANTIC_EXECUTION.value,
-        )
+        statement: Select = _namespace_statement()
         value: bytes | None = session.scalar(statement)
         if value is None:
             session.add(
@@ -119,12 +177,7 @@ def _install_namespace(engine: Engine) -> str:
             except IntegrityError:
                 session.rollback()  # pylint: 
disable=consider-using-transaction
             value = session.scalar(statement)
-        namespace: Any = (
-            codec.decode(value).get("namespace") if value is not None else None
-        )
-        if not isinstance(namespace, str) or not namespace.strip():
-            raise ValueError("Semantic execution namespace is unavailable")
-        return namespace
+        return _decode_namespace(value)
 
 
 def effective_role_ids(user: Any) -> tuple[str, ...]:
diff --git a/superset/semantic_layers/models.py 
b/superset/semantic_layers/models.py
index 9e946f7edcd..63790838288 100644
--- a/superset/semantic_layers/models.py
+++ b/superset/semantic_layers/models.py
@@ -71,7 +71,11 @@ from superset.semantic_layers.exceptions import (
     execute_semantic_query,
     SemanticLayerQueryRejectedError,
 )
-from superset.semantic_layers.execution import configured_namespace, 
SemanticBindingMemo
+from superset.semantic_layers.execution import (
+    configured_namespace,
+    metadata_namespace,
+    SemanticBindingMemo,
+)
 from superset.semantic_layers.mapper import get_results
 from superset.semantic_layers.registry import registry
 from superset.utils import json
@@ -152,6 +156,7 @@ class _MetadataGenerations:
     """Session-local generations, captured before discovery and forgotten on 
Save."""
 
     generations: dict[uuid.UUID, str] = field(default_factory=dict)
+    namespaces: dict[sa.engine.Engine, str] = field(default_factory=dict)
 
     @classmethod
     def for_session(cls, session: Session) -> _MetadataGenerations:
@@ -164,18 +169,14 @@ class _MetadataGenerations:
         """Web, task and MCP operations share a credential-independent 
identity."""
         if layer.uuid not in self.generations:
             namespace: str = configured_namespace()
-            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,
-                ]
-            )
+            if not namespace:
+                engine: sa.engine.Engine = 
session.get_bind(mapper=SemanticLayer).engine
+                if engine not in self.namespaces:
+                    self.namespaces[engine] = metadata_namespace(
+                        session.connection(bind_arguments={"mapper": 
SemanticLayer})
+                    )
+                namespace = self.namespaces[engine]
+            identity: list[str] = ["namespace", namespace]
             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]
diff --git a/tests/unit_tests/commands/report/semantic_view_report_test.py 
b/tests/unit_tests/commands/report/semantic_view_report_test.py
index f40eae3e405..d2172fa8579 100644
--- a/tests/unit_tests/commands/report/semantic_view_report_test.py
+++ b/tests/unit_tests/commands/report/semantic_view_report_test.py
@@ -103,6 +103,7 @@ def semantic_report_state(
     mocker.patch.dict(
         app.config,
         {
+            "SEMANTIC_LAYER_CACHE_NAMESPACE": "semantic-report-tests",
             "ALERT_REPORTS_EXECUTORS": [FixedExecutor(username=user.username)],
             "THUMBNAIL_EXECUTORS": [FixedExecutor(username=user.username)],
         },
diff --git a/tests/unit_tests/common/test_query_context_processor.py 
b/tests/unit_tests/common/test_query_context_processor.py
index 752d1f2b1c1..1af98edec69 100644
--- a/tests/unit_tests/common/test_query_context_processor.py
+++ b/tests/unit_tests/common/test_query_context_processor.py
@@ -23,6 +23,8 @@ from unittest.mock import MagicMock, patch, PropertyMock
 import numpy as np
 import pandas as pd
 import pytest
+from flask import Flask
+from pytest_mock import MockerFixture
 
 from superset.common.chart_data import ChartDataResultFormat, 
ChartDataResultType
 from superset.common.chart_data_timing import QueryDataResult, QueryTiming
@@ -37,6 +39,14 @@ from superset.utils.core import GenericDataType
 from superset.utils.date_parser import get_past_or_future
 
 
[email protected]
+def semantic_cache_namespace(app: Flask, mocker: MockerFixture) -> None:
+    """Isolate transient semantic datasource cache tests from metadata 
storage."""
+    mocker.patch.dict(
+        app.config, {"SEMANTIC_LAYER_CACHE_NAMESPACE": "query-context-tests"}
+    )
+
+
 @pytest.fixture
 def mock_query_context():
     with patch(
@@ -2982,6 +2992,7 @@ def 
test_completeness_error_escapes_failed_payload_conversion(
 
 def test_semantic_annotation_generation_versions_sql_parent(
     processor: QueryContextProcessor,
+    semantic_cache_namespace: None,
 ) -> None:
     from superset.semantic_layers.models import SemanticLayer, SemanticView
 
@@ -3046,6 +3057,7 @@ def 
test_completeness_annotation_failure_preserves_subtype(app_context: Any) ->
 
 def test_data_generation_separates_late_legacy_writer_and_task_keys(
     processor: QueryContextProcessor,
+    semantic_cache_namespace: None,
 ) -> None:
     from unittest.mock import PropertyMock
 
@@ -3121,6 +3133,7 @@ def 
test_get_viz_annotation_data_ignores_source_chart_annotations(
 def test_semantic_annotation_null_protocol_versions_parent_cache(
     app_context: Any,
     monkeypatch: pytest.MonkeyPatch,
+    semantic_cache_namespace: None,
 ) -> None:
     """Saved semantic annotation rows must not reuse pre-NULL-fix parent 
keys."""
     from superset.common.query_context import QueryContext
diff --git a/tests/unit_tests/datasource/test_compatible_api.py 
b/tests/unit_tests/datasource/test_compatible_api.py
index 4210d2d5876..46e5ff21186 100644
--- a/tests/unit_tests/datasource/test_compatible_api.py
+++ b/tests/unit_tests/datasource/test_compatible_api.py
@@ -22,6 +22,7 @@ from uuid import uuid4
 
 import pyarrow as pa
 import pytest
+from flask import Flask
 from flask.testing import FlaskClient
 from pytest_mock import MockerFixture
 from superset_core.semantic_layers.types import Dimension, Grains, Metric
@@ -33,9 +34,16 @@ from superset.semantic_layers.models import SemanticLayer, 
SemanticView
 
 @pytest.mark.parametrize("ambiguous", [False, True])
 def test_compatible_endpoint_resolves_semantic_variants(
-    client: FlaskClient, full_api_access: None, mocker: MockerFixture, 
ambiguous: bool
+    app: Flask,
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+    ambiguous: bool,
 ) -> None:
     """The public endpoint uses the real model resolver on cache misses."""
+    mocker.patch.dict(
+        app.config, {"SEMANTIC_LAYER_CACHE_NAMESPACE": 
"compatible-endpoint-tests"}
+    )
     raw: Dimension = Dimension("raw", "event_time", pa.timestamp("us"))
     month: Dimension = Dimension(
         "month", "event_time", pa.timestamp("us"), grain=Grains.MONTH
diff --git a/tests/unit_tests/security/semantic_guest_rls_test.py 
b/tests/unit_tests/security/semantic_guest_rls_test.py
index 9f1cbf6633f..846715199cc 100644
--- a/tests/unit_tests/security/semantic_guest_rls_test.py
+++ b/tests/unit_tests/security/semantic_guest_rls_test.py
@@ -23,7 +23,7 @@ from unittest.mock import MagicMock, PropertyMock
 import pandas as pd
 import pyarrow as pa
 import pytest
-from flask import g
+from flask import Flask, g
 from flask.testing import FlaskClient
 from pytest_mock import MockerFixture
 from sqlalchemy import create_engine, literal, select, union_all
@@ -62,8 +62,9 @@ def set_guest(rules: list[GuestTokenRlsRule]) -> None:
 
 
 @pytest.fixture
-def provider(mocker: MockerFixture) -> MagicMock:
+def provider(app: Flask, mocker: MockerFixture) -> MagicMock:
     """The provider returns rows unless the request is refused."""
+    mocker.patch.dict(app.config, {"SEMANTIC_LAYER_CACHE_NAMESPACE": 
"guest-rls-tests"})
     implementation: MagicMock = MagicMock()
     implementation.features = frozenset()
     # An unversioned view, so value suggestions are served normally.
diff --git a/tests/unit_tests/semantic_layers/integer_precision_test.py 
b/tests/unit_tests/semantic_layers/integer_precision_test.py
index 8929d0c14be..e7343ca71de 100644
--- a/tests/unit_tests/semantic_layers/integer_precision_test.py
+++ b/tests/unit_tests/semantic_layers/integer_precision_test.py
@@ -22,6 +22,7 @@ from unittest.mock import MagicMock, PropertyMock
 
 import pyarrow as pa
 import pytest
+from flask import Flask
 from flask.testing import FlaskClient
 from pytest_mock import MockerFixture
 from superset_core.semantic_layers.types import (
@@ -37,8 +38,11 @@ from superset.semantic_layers.models import SemanticLayer, 
SemanticView
 
 
 @pytest.fixture
-def semantic_layer(mocker: MockerFixture) -> SemanticLayer:
+def semantic_layer(app: Flask, mocker: MockerFixture) -> SemanticLayer:
     """Give chart-data views the registered layer required by cache keys."""
+    mocker.patch.dict(
+        app.config, {"SEMANTIC_LAYER_CACHE_NAMESPACE": 
"integer-precision-tests"}
+    )
     mocker.patch.dict(
         "superset.semantic_layers.models.registry",
         {"fixture": MagicMock(result_cache_version=None)},
diff --git a/tests/unit_tests/semantic_layers/metadata_namespace_test.py 
b/tests/unit_tests/semantic_layers/metadata_namespace_test.py
new file mode 100644
index 00000000000..deeb308c50c
--- /dev/null
+++ b/tests/unit_tests/semantic_layers/metadata_namespace_test.py
@@ -0,0 +1,422 @@
+# 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 Callable, Hashable, Iterator
+from datetime import timedelta
+from pathlib import Path
+from sqlite3 import Connection as SQLiteConnection
+from typing import Any
+from unittest.mock import MagicMock
+from uuid import UUID
+
+import pandas as pd
+import pytest
+from flask import Flask, Response
+from flask_caching import Cache
+from pytest_mock import MockerFixture
+from sqlalchemy import create_engine, event, func, select
+from sqlalchemy.engine import Engine, make_url
+from sqlalchemy.orm import Session
+from sqlalchemy.pool import NullPool, QueuePool, SingletonThreadPool, 
StaticPool
+
+from superset.charts.data.api import ChartDataRestApi
+from superset.commands.chart.data.get_data_command import ChartDataCommand
+from superset.common.chart_data import ChartDataResultFormat, 
ChartDataResultType
+from superset.common.query_context import QueryContext
+from superset.common.query_object import QueryObject
+from superset.common.utils.query_cache_manager import QueryCacheManager
+from superset.constants import CacheRegion
+from superset.key_value.models import KeyValueEntry
+from superset.key_value.types import (
+    JsonKeyValueCodec,
+    KeyValueResource,
+)
+from superset.models.helpers import QueryResult
+from superset.semantic_layers.execution import _install_namespace
+from superset.semantic_layers.models import SemanticLayer, SemanticView
+from tests.unit_tests.semantic_layers.conftest import LegacyLayer
+
+
[email protected]
+def metadata_engines(tmp_path: Path) -> Iterator[Callable[[str, str], Engine]]:
+    """Real isolated stores with synthetic transport aliases, never network 
URLs."""
+    engines: list[Engine] = []
+
+    def create(store: str, address: str) -> Engine:
+        engine: Engine = create_engine(f"sqlite:///{tmp_path / store}")
+        KeyValueEntry.__table__.create(engine, checkfirst=True)
+        # The SQLite pool still reaches the file; only address metadata varies.
+        engine.url = make_url(address)
+        engines.append(engine)
+        return engine
+
+    yield create
+    for engine in engines:
+        engine.dispose()
+
+
[email protected]
+def namespace_app(app: Flask, mocker: MockerFixture) -> Flask:
+    """Do not let an app-global identity hide a metadata-bind isolation 
defect."""
+    mocker.patch.dict(app.config, {"SEMANTIC_LAYER_CACHE_NAMESPACE": ""})
+    mocker.patch.dict(app.extensions, {"semantic_execution_namespace": 
"wrong-store"})
+    mocker.patch.dict(
+        "superset.semantic_layers.models.registry", {"legacy": LegacyLayer}
+    )
+    mocker.patch(
+        
"superset.common.query_context_processor.security_manager.get_rls_cache_key",
+        return_value=[],
+    )
+    return app
+
+
+def context_for_session(session: Session) -> QueryContext:
+    """Use identical layer/view identities and real query-key composition."""
+    layer: SemanticLayer = SemanticLayer(
+        uuid=UUID(int=1), type="legacy", configuration="{}", cache_version=0
+    )
+    session.add(layer)
+    view: SemanticView = SemanticView(
+        id=1, uuid=UUID(int=2), name="legacy", semantic_layer=layer, 
configuration="{}"
+    )
+    return QueryContext(
+        datasource=view,
+        queries=[QueryObject(columns=[], metrics=[])],
+        slice_=None,
+        form_data=None,
+        result_type=ChartDataResultType.FULL,
+        result_format=ChartDataResultFormat.JSON,
+        cache_values={},
+    )
+
+
[email protected](
+    "address",
+    [
+        "postgresql://web:old@alias:5432/workspace",
+        "postgresql://web:old@metadata/workspace",
+        "postgresql://web:old@pgbouncer:6432/workspace",
+        "postgresql://web:old@metadata:5432/workspace_alias",
+        
"postgresql+psycopg2://worker:new@metadata:5432/workspace?application_name=task",
+    ],
+)
+def test_metadata_aliases_share_result_cache(
+    namespace_app: Flask,
+    metadata_engines: Callable[[str, str], Engine],
+    mocker: MockerFixture,
+    address: str,
+) -> None:
+    """Worker DATA writes are readable by an independent web metadata 
session."""
+    worker: Engine = metadata_engines(
+        "workspace.db", "postgresql://web:old@metadata:5432/workspace"
+    )
+    web: Engine = metadata_engines("workspace.db", address)
+    backend: Cache = Cache(namespace_app, config={"CACHE_TYPE": "SimpleCache"})
+    mocker.patch.dict(
+        "superset.common.utils.query_cache_manager._cache", {CacheRegion.DATA: 
backend}
+    )
+    with Session(worker) as worker_session, Session(web) as web_session:
+        worker_context: QueryContext = context_for_session(worker_session)
+        worker_key: str | None = worker_context.query_cache_key(
+            worker_context.queries[0]
+        )
+        assert worker_key
+        cached: QueryCacheManager = QueryCacheManager()
+        cached.set_query_result(
+            worker_key,
+            QueryResult(
+                df=pd.DataFrame({"value": [7]}),
+                query="provider result",
+                duration=timedelta(0),
+            ),
+            region=CacheRegion.DATA,
+            timeout=600,
+        )
+        assert cached.result_persisted
+        mocker.patch.dict(namespace_app.config, {"SECRET_KEY": 
"rotated-test-key"})
+        web_context: QueryContext = context_for_session(web_session)
+        web_key: str | None = 
web_context.query_cache_key(web_context.queries[0])
+        api: ChartDataRestApi = ChartDataRestApi()
+        submit: MagicMock = mocker.patch(
+            "superset.charts.data.api.submit_chart_data_query_tasks", 
return_value={}
+        )
+        mocker.patch.object(
+            api, "_send_chart_response", return_value=Response(status=200)
+        )
+        response: Response = api._run_async({}, ChartDataCommand(web_context))
+        assert response.status_code == 200
+        submit.assert_not_called()
+        loaded: QueryCacheManager = QueryCacheManager.get(
+            web_key, region=CacheRegion.DATA, force_cached=True
+        )
+        assert loaded.is_cached
+        pd.testing.assert_frame_equal(loaded.df, pd.DataFrame({"value": [7]}))
+        assert web_key == worker_key
+        assert isinstance(worker_context.datasource, SemanticView)
+        assert isinstance(web_context.datasource, SemanticView)
+        assert worker_context.datasource.metadata_generation == (
+            web_context.datasource.metadata_generation
+        )
+        assert (
+            "semantic-null-filters-v1"
+            in web_context.datasource.get_extra_cache_keys({})
+        )
+
+
+def test_copied_layer_ids_in_distinct_stores_are_isolated(
+    namespace_app: Flask, metadata_engines: Callable[[str, str], Engine]
+) -> None:
+    """Sequential tenant sessions in one app cannot borrow its startup 
identity."""
+    address: str = "postgresql://user:password@proxy/tenant"
+    first: Engine = metadata_engines("first.db", address)
+    second: Engine = metadata_engines("second.db", address)
+    with Session(first) as first_session, Session(second) as second_session:
+        first_context: QueryContext = context_for_session(first_session)
+        second_context: QueryContext = context_for_session(second_session)
+        assert first_context.query_cache_key(first_context.queries[0]) != (
+            second_context.query_cache_key(second_context.queries[0])
+        )
+
+
[email protected]("callable_override", [False, True])
+def test_explicit_namespace_needs_no_identity_store(
+    namespace_app: Flask, mocker: MockerFixture, callable_override: bool
+) -> None:
+    """Operator identity takes precedence even when the durable store is 
unavailable."""
+    configured: str | Callable[[], str] = (
+        (lambda: "explicit-workspace") if callable_override else 
"explicit-workspace"
+    )
+    mocker.patch.dict(
+        namespace_app.config, {"SEMANTIC_LAYER_CACHE_NAMESPACE": configured}
+    )
+    with Session() as session:
+        context: QueryContext = context_for_session(session)
+        assert context.query_cache_key(context.queries[0])
+
+
+def test_identity_is_memoized_without_committing_caller(
+    namespace_app: Flask, metadata_engines: Callable[[str, str], Engine]
+) -> None:
+    """Many layers share one identity lookup; caller work remains 
uncommitted."""
+    engine: Engine = metadata_engines("memo.db", "sqlite:///synthetic-address")
+    statements: list[str] = []
+
+    def record(
+        connection: Any,
+        cursor: Any,
+        statement: str,
+        parameters: Any,
+        context: Any,
+        executemany: bool,
+    ) -> None:
+        statements.append(statement)
+
+    event.listen(engine, "before_cursor_execute", record)
+    try:
+        with Session(engine) as session:
+            first: QueryContext = context_for_session(session)
+            assert isinstance(first.datasource, SemanticView)
+            original: str = first.datasource.metadata_generation
+            statements.clear()
+            other: SemanticLayer = SemanticLayer(
+                uuid=UUID(int=3), type="legacy", configuration="{}", 
cache_version=0
+            )
+            session.add(other)
+            assert other.metadata_generation != original
+            assert not statements
+            assert other in session.new
+            first.datasource.semantic_layer.cache_version = 1
+            assert first.datasource.metadata_generation == original
+            session.info.clear()
+            assert first.datasource.semantic_layer.metadata_generation != 
original
+            assert len(statements) == 1
+    finally:
+        event.remove(engine, "before_cursor_execute", record)
+
+
[email protected]("seed_identity", [True, False])
+def test_single_connection_pool_never_waits_for_itself(
+    namespace_app: Flask, tmp_path: Path, seed_identity: bool
+) -> None:
+    """Both existing identity reads and first-use bootstrap avoid pool 
self-wait."""
+    engine: Engine = create_engine(
+        f"sqlite:///{tmp_path / 'single.db'}",
+        poolclass=QueuePool,
+        pool_size=1,
+        max_overflow=0,
+        pool_timeout=0.05,
+    )
+    try:
+        KeyValueEntry.__table__.create(engine)
+        expected: str | None = _install_namespace(engine) if seed_identity 
else None
+        with Session(engine) as session:
+            session.connection()
+            context: QueryContext = context_for_session(session)
+            assert isinstance(context.datasource, SemanticView)
+            generation: str = context.datasource.metadata_generation
+            assert generation
+            assert context.datasource.semantic_layer in session.new
+        durable: str = _install_namespace(engine)
+        if expected is not None:
+            assert durable == expected
+        with Session(engine) as reader:
+            assert (
+                
context_for_session(reader).datasource.get_extra_cache_keys({})[0]
+                == generation
+            )
+    finally:
+        engine.dispose()
+
+
+def test_nullpool_identity_read_adds_no_connections(
+    namespace_app: Flask, tmp_path: Path
+) -> None:
+    """Each request's already-open connection suffices for the identity 
lookup."""
+    engine: Engine = create_engine(
+        f"sqlite:///{tmp_path / 'nullpool.db'}", poolclass=NullPool
+    )
+    connections: list[object] = []
+
+    def connected(dbapi_connection: object, record: object) -> None:
+        connections.append(dbapi_connection)
+
+    try:
+        KeyValueEntry.__table__.create(engine)
+        _install_namespace(engine)
+        event.listen(engine, "connect", connected)
+        for _ in range(3):
+            with Session(engine) as session:
+                session.connection()
+                assert 
context_for_session(session).datasource.get_extra_cache_keys({})
+        assert len(connections) == 3
+    finally:
+        event.remove(engine, "connect", connected)
+        engine.dispose()
+
+
+def test_missing_identity_with_sqlite_write_fails_without_committing(
+    namespace_app: Flask, tmp_path: Path
+) -> None:
+    """Bootstrap must not wait on, or commit, the caller's SQLite write 
lock."""
+    engine: Engine = create_engine(
+        f"sqlite:///{tmp_path / 'write-lock.db'}",
+        poolclass=QueuePool,
+        pool_size=1,
+        max_overflow=0,
+        pool_timeout=0.05,
+    )
+    try:
+        KeyValueEntry.__table__.create(engine)
+        with Session(engine) as session:
+            session.connection().execute(
+                KeyValueEntry.__table__.insert().values(
+                    uuid=UUID(int=20),
+                    resource=KeyValueResource.SEMANTIC_EXECUTION.value,
+                    value=JsonKeyValueCodec().encode({"pending": True}),
+                )
+            )
+            context: QueryContext = context_for_session(session)
+            with pytest.raises(
+                ValueError, match="before starting a SQLite transaction"
+            ):
+                context.datasource.get_extra_cache_keys({})
+            session.rollback()
+        with engine.connect() as connection:
+            assert (
+                
connection.scalar(select(func.count()).select_from(KeyValueEntry)) == 0
+            )
+        _install_namespace(engine)
+        with Session(engine) as session:
+            assert 
context_for_session(session).datasource.get_extra_cache_keys({})
+    finally:
+        engine.dispose()
+
+
[email protected]("poolclass", [SingletonThreadPool, StaticPool])
+def test_missing_identity_in_memory_preserves_store(
+    namespace_app: Flask, poolclass: type[SingletonThreadPool] | 
type[StaticPool]
+) -> None:
+    """First use retains the in-memory store and does not flush caller 
objects."""
+    engine: Engine = create_engine("sqlite://", poolclass=poolclass)
+    try:
+        KeyValueEntry.__table__.create(engine)
+        with Session(engine) as writer:
+            writer.connection().execute(
+                KeyValueEntry.__table__.insert().values(
+                    uuid=UUID(int=20),
+                    resource=KeyValueResource.SEMANTIC_EXECUTION.value,
+                    value=JsonKeyValueCodec().encode({"pending": True}),
+                )
+            )
+            with pytest.raises(
+                ValueError, match="before starting a SQLite transaction"
+            ):
+                context_for_session(writer).datasource.get_extra_cache_keys({})
+            writer.rollback()
+        with Session(engine) as session:
+            assert 
session.scalar(select(func.count()).select_from(KeyValueEntry)) == 0
+            context: QueryContext = context_for_session(session)
+            generation: Hashable = 
context.datasource.get_extra_cache_keys({})[0]
+            assert generation
+            assert isinstance(context.datasource, SemanticView)
+            assert context.datasource.semantic_layer in session.new
+            session.rollback()
+        with Session(engine) as reader:
+            assert 
reader.scalar(select(func.count()).select_from(KeyValueEntry)) == 1
+            assert (
+                
context_for_session(reader).datasource.get_extra_cache_keys({})[0]
+                == generation
+            )
+    finally:
+        engine.dispose()
+
+
+def test_missing_identity_uses_metadata_schema(
+    namespace_app: Flask, tmp_path: Path
+) -> None:
+    """Bootstrap writes the configured schema, never an available default 
table."""
+    engine: Engine = create_engine(f"sqlite:///{tmp_path / 'main.db'}")
+
+    def attach_schema(connection: SQLiteConnection, record: object) -> None:
+        connection.execute(
+            "ATTACH DATABASE ? AS tenant", (str(tmp_path / "tenant.db"),)
+        )
+
+    event.listen(engine, "connect", attach_schema)
+    tenant: Engine = engine.execution_options(schema_translate_map={None: 
"tenant"})
+    try:
+        KeyValueEntry.__table__.create(engine)
+        KeyValueEntry.__table__.create(tenant)
+        with Session(tenant) as session:
+            generation: Hashable = context_for_session(
+                session
+            ).datasource.get_extra_cache_keys({})[0]
+        with Session(tenant) as reader:
+            assert 
reader.scalar(select(func.count()).select_from(KeyValueEntry)) == 1
+            assert (
+                
context_for_session(reader).datasource.get_extra_cache_keys({})[0]
+                == generation
+            )
+        with Session(engine) as default_schema:
+            assert (
+                
default_schema.scalar(select(func.count()).select_from(KeyValueEntry))
+                == 0
+            )
+    finally:
+        engine.dispose()
diff --git a/tests/unit_tests/semantic_layers/models_test.py 
b/tests/unit_tests/semantic_layers/models_test.py
index d2d5240b085..7e0c6d04eaf 100644
--- a/tests/unit_tests/semantic_layers/models_test.py
+++ b/tests/unit_tests/semantic_layers/models_test.py
@@ -21,7 +21,7 @@ from __future__ import annotations
 
 import os
 import uuid
-from collections.abc import Hashable
+from collections.abc import Hashable, Iterator
 from datetime import date, datetime, timezone
 from itertools import permutations
 from pathlib import Path
@@ -30,6 +30,7 @@ from unittest.mock import MagicMock, patch, PropertyMock
 
 import pyarrow as pa
 import pytest
+from flask import Flask
 from flask.testing import FlaskClient
 from pytest_mock import MockerFixture
 from sqlalchemy import inspect, select
@@ -70,6 +71,19 @@ from superset.utils.core import GenericDataType
 # =============================================================================
 
 
[email protected]
+def explicit_model_namespace(app: Flask, mocker: MockerFixture) -> 
Iterator[None]:
+    """Give transient model tests an identity without a metadata-store 
fixture."""
+    mocker.patch.dict(
+        app.config, {"SEMANTIC_LAYER_CACHE_NAMESPACE": "model-unit-tests"}
+    )
+    durable_lookup: MagicMock = mocker.patch(
+        "superset.semantic_layers.models.metadata_namespace"
+    )
+    yield
+    durable_lookup.assert_not_called()
+
+
 @pytest.mark.parametrize(
     "grains, expected",
     [
@@ -358,7 +372,9 @@ def 
test_semantic_layer_implementation_is_scoped_to_owning_session(
         assert factory.call_count == 2
 
 
-def test_semantic_layer_implementation(mocker: MockerFixture) -> None:
+def test_semantic_layer_implementation(
+    mocker: MockerFixture, explicit_model_namespace: None
+) -> None:
     """Test that implementation returns a configured semantic layer."""
     from superset.semantic_layers.execution import 
current_host_execution_context
 
@@ -607,7 +623,9 @@ def test_semantic_view_query_endpoint_returns_error(
 
 
 @pytest.mark.parametrize("version", [None, "producer-v1", "producer-v2"])
-def test_semantic_view_get_extra_cache_keys(version: str | None) -> None:
+def test_semantic_view_get_extra_cache_keys(
+    version: str | None, explicit_model_namespace: None
+) -> None:
     """Keep NULL normalization and producer guarantees in the result 
identity."""
     from superset.common.query_object import QueryObject
 
@@ -1446,7 +1464,9 @@ def test_semantic_view_explore_url() -> None:
     )
 
 
-def test_semantic_view_implementation(mocker: MockerFixture) -> None:
+def test_semantic_view_implementation(
+    mocker: MockerFixture, explicit_model_namespace: None
+) -> None:
     """Views share the layer binding and canonicalize equivalent 
configuration."""
     from superset.semantic_layers.execution import SemanticBindingMemo
 
@@ -2884,6 +2904,7 @@ def 
test_values_search_translates_provider_completeness_error(
 @pytest.mark.parametrize("version", [None, "metricflow-completeness-v1"])
 def test_result_generation_reads_class_without_provider_construction(
     version: str | None,
+    explicit_model_namespace: None,
 ) -> None:
     provider: MagicMock = MagicMock()
     provider.result_cache_version = version
diff --git a/tests/unit_tests/semantic_layers/simple_cache_clear_test.py 
b/tests/unit_tests/semantic_layers/simple_cache_clear_test.py
index 59a4002ef4c..8b71bf73231 100644
--- a/tests/unit_tests/semantic_layers/simple_cache_clear_test.py
+++ b/tests/unit_tests/semantic_layers/simple_cache_clear_test.py
@@ -25,6 +25,7 @@ from uuid import uuid4
 import pytest
 from flask import Flask
 from sqlalchemy import select
+from sqlalchemy.engine import Engine
 from sqlalchemy.orm import Session
 from superset_core.semantic_layers.context import ExecutionMethod
 
@@ -403,7 +404,7 @@ def 
test_migration_backfills_existing_layers_and_downgrades() -> None:
     from alembic.migration import MigrationContext
     from alembic.operations import Operations
     from sqlalchemy import create_engine, text
-    from sqlalchemy.engine import Connection, Engine
+    from sqlalchemy.engine import Connection
 
     migration: Any = import_module(
         
"superset.migrations.versions.2026-10-08_00-00_c179af013f48_add_semantic_layer_cache_version"
@@ -542,15 +543,18 @@ def 
test_web_and_task_share_metadata_keys_despite_local_credentials(
     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"
-    )
+    bind: Engine = session.get_bind().engine
     view: SemanticView = SemanticView(semantic_layer=layer)
-    with patch.object(session, "get_bind", return_value=bind):
+    with patch.object(
+        bind,
+        "url",
+        make_url(
+            
"postgresql+psycopg2://web:old@metadata:5432/superset?application_name=web"
+        ),
+    ):
         web_key: list[Hashable] = view.get_extra_cache_keys({})
         session.info.clear()
-        bind.engine.url = make_url(
+        bind.url = make_url(
             
"postgresql://worker:new@metadata:5432/superset?application_name=celery"
         )
         monkeypatch.setitem(app.config, "SECRET_KEY", "rotated-for-task")
diff --git a/tests/unit_tests/semantic_layers/values_endpoint_test.py 
b/tests/unit_tests/semantic_layers/values_endpoint_test.py
index ff5358e85c3..43bcbfce650 100644
--- a/tests/unit_tests/semantic_layers/values_endpoint_test.py
+++ b/tests/unit_tests/semantic_layers/values_endpoint_test.py
@@ -23,13 +23,18 @@ header contract.
 """
 
 from datetime import datetime
+from pathlib import Path
 from typing import Any, cast
 from unittest.mock import MagicMock
 
 import pyarrow as pa
 import pytest
+from flask import Flask
 from flask.testing import FlaskClient
 from pytest_mock import MockerFixture
+from sqlalchemy import create_engine, select
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import Session
 from superset_core.semantic_layers.types import (
     Dimension,
     SemanticRequest,
@@ -37,11 +42,16 @@ from superset_core.semantic_layers.types import (
 )
 from werkzeug.test import TestResponse
 
+from superset.key_value.models import KeyValueEntry
 from superset.semantic_layers.models import SemanticLayer, SemanticView
 
 
 @pytest.fixture
-def semantic_view_datasource(mocker: MockerFixture) -> SemanticView:
+def semantic_view_datasource(app: Flask, mocker: MockerFixture) -> 
SemanticView:
+    """Give the transient datasource an explicit, isolated cache namespace."""
+    mocker.patch.dict(
+        app.config, {"SEMANTIC_LAYER_CACHE_NAMESPACE": "values-endpoint-tests"}
+    )
     implementation: MagicMock = MagicMock(selection_identity_version=None)
     implementation.uid.return_value = "semantic_view_uid_123"
     implementation.get_dimensions.return_value = [
@@ -100,6 +110,40 @@ def test_semantic_view_values_endpoint_returns_values(
     assert cached.headers["X-Cache-Status"] == "HIT"
 
 
+def test_values_endpoint_bootstraps_identity_without_flushing_datasource(
+    app: Flask,
+    client: FlaskClient,
+    full_api_access: None,
+    semantic_view_datasource: SemanticView,
+    mocker: MockerFixture,
+    tmp_path: Path,
+) -> None:
+    """The default request path uses a migrated store, not a fixture 
override."""
+    mocker.patch.dict(app.config, {"SEMANTIC_LAYER_CACHE_NAMESPACE": ""})
+    mocker.patch(
+        "superset.datasource.api.cache_manager"
+    ).data_cache.get.return_value = None
+    engine: Engine = create_engine(f"sqlite:///{tmp_path / 'metadata.db'}")
+    try:
+        KeyValueEntry.__table__.create(engine)
+        with Session(engine) as session:
+            session.add(semantic_view_datasource.semantic_layer)
+            response: TestResponse = _get(client, "category/values/")
+            assert response.status_code == 200
+            assert response.json is not None
+            assert response.json["result"] == ["Books", "Clothing"]
+            assert semantic_view_datasource.semantic_layer in session.new
+            identity: bytes | None = session.connection().scalar(
+                select(KeyValueEntry.value)
+            )
+            assert identity is not None
+        # Closing the caller session rolls back its pending work, not the 
identity.
+        with engine.connect() as connection:
+            assert connection.scalar(select(KeyValueEntry.value)) == identity
+    finally:
+        engine.dispose()
+
+
 def test_semantic_view_values_endpoint_passes_search_to_the_provider(
     client: Any,
     full_api_access: None,

Reply via email to