sadpandajoe commented on code in PR #44406:
URL: https://github.com/apache/superset/pull/44406#discussion_r4270481635


##########
tests/unit_tests/common/test_query_context_processor.py:
##########
@@ -2639,6 +3215,101 @@ def 
test_mark_force_executed_noop_without_nonce(processor, mock_query_context):
     cache_manager.data_cache.set.assert_not_called()
 
 
+# =============================================================================
+# Annotation-data cache decoupled from the dataframe cache
+# =============================================================================
+
+
+def test_get_annotation_data_cached_reads_from_cache(
+    processor: QueryContextProcessor,
+) -> None:
+    """A hit on the annotation-specific key skips recomputation entirely."""
+    with patch(
+        "superset.common.query_context_processor.cache_manager"
+    ) as cache_manager:
+        cache_manager.data_cache.get.return_value = {"annotation_data": {"a": 
1}}
+        with patch.object(processor, "get_annotation_data") as mock_get:
+            result = processor._get_annotation_data_cached(
+                query_obj=MagicMock(),
+                cache_key="ak",
+                force_query=False,
+                force_cached=False,
+                timeout=60,
+                datasource_uid="ds",
+            )
+    assert result == {"a": 1}
+    mock_get.assert_not_called()
+
+
+def test_get_annotation_data_cached_computes_and_caches_on_miss(
+    processor: QueryContextProcessor,
+) -> None:
+    """A miss computes the annotation data and stores it under the scoped 
key."""
+    with (
+        patch("superset.common.query_context_processor.cache_manager") as 
cache_manager,
+        patch("superset.common.query_context_processor.set_and_log_cache") as 
mock_set,
+    ):
+        cache_manager.data_cache.get.return_value = None
+        with patch.object(
+            processor, "get_annotation_data", return_value={"a": 1}
+        ) as mock_get:
+            result = processor._get_annotation_data_cached(
+                query_obj=MagicMock(),
+                cache_key="ak",
+                force_query=False,
+                force_cached=False,
+                timeout=60,
+                datasource_uid="ds",
+            )
+    assert result == {"a": 1}
+    mock_get.assert_called_once()
+    mock_set.assert_called_once_with(
+        cache_manager.data_cache, "ak", {"annotation_data": {"a": 1}}, 60, "ds"
+    )
+
+
+def test_get_annotation_data_cached_force_cached_raises_on_miss(
+    processor: QueryContextProcessor,
+) -> None:
+    """``force_cached`` must never fall through to a live compute -- the same
+    contract ``QueryCacheManager.get`` enforces for the dataframe cache."""
+    with patch(
+        "superset.common.query_context_processor.cache_manager"
+    ) as cache_manager:
+        cache_manager.data_cache.get.return_value = None
+        with pytest.raises(CacheLoadError):
+            processor._get_annotation_data_cached(
+                query_obj=MagicMock(),
+                cache_key="ak",
+                force_query=False,
+                force_cached=True,
+                timeout=60,
+                datasource_uid="ds",
+            )
+
+
+def test_get_annotation_data_cached_force_query_bypasses_cache_read(processor):
+    """A forced refresh recomputes rather than serving a stale cached value."""
+    with (
+        patch("superset.common.query_context_processor.cache_manager") as 
cache_manager,
+        patch("superset.common.query_context_processor.set_and_log_cache"),

Review Comment:
   `set_and_log_cache` is patched with a bare `MagicMock` here and nothing 
asserts the nonce-marker write that depends on its return value, so replacing 
`persisted` with `True` at the `_mark_force_executed` call in 
`_get_annotation_data_cached` would still pass. A forced refresh whose write 
fails would then record the marker and a same-nonce read-back would serve the 
stale annotation entry. Could a test at this boundary drive `set_and_log_cache` 
to return both `True` and `False` and assert the marker is written under the 
annotation key only on success?



##########
superset/common/query_context_processor.py:
##########
@@ -474,73 +503,226 @@ def query_cache_key(self, query_obj: QueryObject, 
**kwargs: Any) -> str | None:
         )
         return cache_key
 
-    def _annotation_cache_context(self, query_obj: QueryObject) -> dict[str, 
Any]:
+    def annotation_cache_key(self, query_obj: QueryObject) -> str | None:
         """
-        Cache-key material binding cached annotation data to its security
-        context.
-
-        Annotation payloads are fetched per requesting user and stored on the
-        same cache entry as the dataframe, so the key also binds the requesting
-        user and, for chart-backed layers, the RLS clauses of the referenced
-        chart's datasource.
+        Cache key for this query's annotation-layer payload, or ``None`` when
+        the query has no annotation layers or when some layer's access scope
+        could not be resolved (the payload is then never cached, see
+        :meth:`_annotation_source_scope`).
+
+        Annotation payloads are fetched under the requesting user's access
+        scope, which is a stricter security requirement than the dataframe
+        itself has. Keying them separately from :meth:`query_cache_key` keeps
+        that scoping from forcing every distinct viewer of an annotated chart
+        onto their own full copy of the (potentially much larger) shared
+        dataframe: users with the same access scope share this key too.
         """
-        source_rls: dict[str, list[str] | None] = {}
-        source_versions: dict[str, tuple[str, str]] = {}
-        metadata_versions: dict[str, str] = {}
+        if not query_obj or not query_obj.annotation_layers:
+            return None
+        if (annotation_context := self._annotation_cache_context(query_obj)) 
is None:
+            return None
+        return self.query_cache_key(query_obj, 
annotation_context=annotation_context)
+
+    def _annotation_cache_context(
+        self, query_obj: QueryObject
+    ) -> dict[str, Any] | None:
+        """
+        Cache-key material binding annotation data to its security *scope* so
+        users with the same access share a cache entry and users with a
+        different scope — or no access — never read each other's data.
+
+        * NATIVE layers: the ``can_read`` permission on ``Annotation``, the
+          only user-dependent dimension of these global records.
+        * Chart-backed (``line``/``table``) layers: see
+          :meth:`_annotation_source_scope`, recorded per *layer* (not per
+          referenced chart) since two layers can reference the same chart
+          with different overrides, and so different scopes.
+
+        Returns ``None`` when any layer's scope cannot be resolved, meaning
+        the payload must not be cached at all.
+        """
+        context: dict[str, Any] = {}
+
+        if any(
+            layer.get("sourceType") == "NATIVE" for layer in 
query_obj.annotation_layers
+        ):
+            context["annotation_read"] = security_manager.can_access(
+                "can_read", "Annotation"
+            )
+
+        source_scope: list[dict[str, Any]] = []
         for layer in query_obj.annotation_layers:
             if (
                 layer.get("sourceType")
                 not in ANNOTATION_SOURCE_TYPES_WITH_CHART_REFERENCE
             ):
                 continue
-            layer_value = layer.get("value")
+            if (scope := self._annotation_source_scope(layer)) is None:
+                return None
+            source_scope.append({"value": str(layer.get("value")), **scope})
+        if source_scope:
+            context["source_scope"] = source_scope
+
+        return context
+
+    def _annotation_source_scope(self, layer: dict[str, Any]) -> dict[str, 
Any] | None:
+        """
+        Access and data-identity cache-key material for one chart-backed
+        annotation layer, or ``None`` when it cannot be resolved.
+
+        ``None`` means "do not cache": a missing chart, a chart with neither
+        a saved query context nor a resolvable datasource, or any error while
+        deriving the scope would otherwise collapse an authorized and a
+        denied requester onto one shared identity, letting the latter read
+        the former's warmed payload. The caller fetches live instead.
+
+        ``access`` keeps a user denied the referenced chart's datasource from
+        reading an authorized user's cached payload. When the chart has a
+        saved query context, this runs the *same* authorization path
+        :meth:`get_viz_annotation_data` executes
+        (``QueryContext.raise_for_access``) rather than the coarser
+        :meth:`SecurityManager.can_access_datasource` — a requester whose
+        access comes from a dashboard/viewer-promiscuous-mode bypass (which
+        depends on the chart's own saved ``form_data``, e.g. its
+        ``slice_id``/``dashboardId``) would otherwise still fail that coarser,
+        context-free check and collapse onto the same denied scope as a
+        genuinely unauthorized requester, letting the latter read the
+        former's cached payload. ``data_key`` is the annotation chart's own
+        query cache key(s) — derived from the same, override-applied query
+        objects actually executed (see :meth:`_apply_annotation_overrides`),
+        so it captures the datasource version, RLS clauses, and any per-user
+        Jinja/virtual-dataset RLS material exactly as the live fetch would,
+        including material an override only introduces at a finer grain.
+        Reusing this logic avoids re-deriving it and automatically inherits
+        any future correctness fixes made there.
+        """
+        layer_value = layer.get("value")
+        try:
             chart = (
                 ChartDAO.find_by_id(layer_value) if layer_value is not None 
else None
             )
-            annotation_datasource: Datasource | None = (
-                chart.resolved_datasource if chart else None
-            )
-            if chart and isinstance(chart.query_context, str):
+            if chart is None:
+                return None
+
+            # The saved query context is what the live fetch executes and
+            # authorizes against, so it is consulted first, independent of
+            # the chart's own datasource pointer: a chart can have its
+            # ``datasource_id`` cleared while its saved query context still
+            # targets a valid dataset, and the live fetch would still work.
+            annotation_query_context = chart.get_query_context()
+            if annotation_query_context is not None:
+                self._apply_annotation_overrides(annotation_query_context, 
layer)
                 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
+                    annotation_query_context.raise_for_access()
+                    access: Any = True
+                except SupersetSecurityException:
+                    access = False
+                data_key: Any = [
+                    annotation_query_context.query_cache_key(query_object)

Review Comment:
   With `ALLOW_ADHOC_SUBQUERY` on, an annotation-source chart whose metric is a 
SQL expression with a subquery (`MAX((SELECT SUM(amount) FROM sales))`) gets 
its tenant RLS on `sales` injected at execution time, but that RLS is not part 
of `query_cache_key` or `get_rls_cache_key` here. Two users with the same 
dataset grant and different RLS on `sales` therefore get identical `access` and 
`data_key`, and when the source chart does not cache itself 
(`cache_timeout=-1`) the second user is served the first user's warmed total. 
The old `user_id` binding isolated this. Should the scope include the RLS 
predicates of tables referenced inside adhoc expressions, or skip caching when 
an adhoc subquery is present?



##########
tests/unit_tests/common/test_query_context_processor.py:
##########
@@ -3115,11 +3788,17 @@ def payload(context: QueryContext, **kwargs: Any) -> 
dict[str, Any]:
             
"superset.common.query_context_processor.security_manager.get_rls_cache_key",
             return_value=[],
         ),
+        patch.object(QueryContext, "raise_for_access"),
+        patch.object(QueryContext, "query_cache_key", return_value="data-key"),
+        patch(
+            "superset.common.query_context_processor.security_manager"
+            ".can_access_datasource",
+            return_value=True,
+        ),
         patch(
             "superset.common.query_context_processor.ChartDAO.find_by_id",
             return_value=chart,
         ),
-        patch("superset.common.query_context_processor.get_user_id", 
return_value=42),
     ):
         context: dict[str, Any] = processor._annotation_cache_context(query)

Review Comment:
   `_annotation_cache_context` now returns `dict[str, Any] | None`, but the 
result is assigned to a `dict[str, Any]` local in a function that is fully 
annotated (`-> None`), so mypy checks it and will report an incompatible 
assignment. Could this declare the optional type and assert it is not `None` 
before indexing `context["source_scope"]`?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to