codeant-ai-for-open-source[bot] commented on code in PR #41076:
URL: https://github.com/apache/superset/pull/41076#discussion_r3560247138
##########
superset/dashboards/api.py:
##########
@@ -2540,6 +2539,85 @@ def get_version(self, uuid_str: str, version_uuid_str:
str) -> Response:
404:
$ref: '#/components/responses/404'
"""
- return get_version_endpoint(
- self, Dashboard, uuid_str, version_uuid_str,
access_kwarg="dashboard"
- )
+ return get_version_endpoint(self, Dashboard, uuid_str,
version_uuid_str)
+
+ @expose("/<uuid_str>/activity/", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs:
f"{self.__class__.__name__}.activity",
+ log_to_statsd=False,
+ )
+ def activity(self, uuid_str: str) -> Response:
Review Comment:
**Suggestion:** This new protected endpoint does not set a permission alias
(for example mapping to `get`), so FAB will require `can_activity_Dashboard`
instead of the standard read permission. Existing non-admin roles that can read
dashboards but do not have this newly named permission will get 403s,
effectively breaking access to the endpoint. Add a method-permission mapping
(or `@permission_name("get")`) for `activity`. [api mismatch]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
❌ Dashboard activity endpoint inaccessible to non-admin dashboard readers.
⚠️ New activity-view UI broken for standard dashboard roles.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Inspect `DashboardRestApi` in `superset/dashboards/api.py:10-57`; note
`class_permission_name = "Dashboard"` and `method_permission_name` only maps
standard CRUD
methods and `"restore"` to `"write"` (lines 45-56), with the comment that
missing mappings
make `@protect()` fall back to `can_<method>_<class>` which "standard roles
don't carry".
2. Observe the `activity` endpoint definition at
`superset/dashboards/api.py:105-113`: it
uses `@expose("/<uuid_str>/activity/", methods=("GET",))` and `@protect()`
but has no
`@permission_name("get")` decorator and no `"activity"` entry in
`method_permission_name`.
3. Start Superset and log in as a non-admin user whose role grants dashboard
read
permissions (can view dashboards via `/api/v1/dashboard/`), but who has not
been assigned
any `can_activity_Dashboard` permission (roles are configured from the
existing permission
map; no code adds this new permission).
4. Call `GET /api/v1/dashboard/<uuid>/activity/` which is routed to
`DashboardRestApi.activity`; FAB computes the required permission as
`can_activity_Dashboard`, finds that the user lacks it, and `@protect()`
denies the
request with HTTP 403, even though the user can read the dashboard itself.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=e328bfb379f84675bb7b7d55899f0d54&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
[](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=e328bfb379f84675bb7b7d55899f0d54&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/dashboards/api.py
**Line:** 2544:2552
**Comment:**
*Api Mismatch: This new protected endpoint does not set a permission
alias (for example mapping to `get`), so FAB will require
`can_activity_Dashboard` instead of the standard read permission. Existing
non-admin roles that can read dashboards but do not have this newly named
permission will get 403s, effectively breaking access to the endpoint. Add a
method-permission mapping (or `@permission_name("get")`) for `activity`.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask
user if the user wants to fix the rest of the comments as well. if said yes,
then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=941b559a3c0acb48e42afbe31e13c171b038704047fcecfbfecc1edad539ad6c&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=941b559a3c0acb48e42afbe31e13c171b038704047fcecfbfecc1edad539ad6c&reaction=dislike'>👎</a>
##########
superset/versioning/activity/queries.py:
##########
@@ -0,0 +1,754 @@
+# 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.
+"""DB-touching helpers for the activity-view read path.
+
+All Phase A relationship walks (``charts_attached_to_dashboard``,
+``datasets_used_by_chart``, ``batch_datasets_used_by_charts``),
+the Phase B change-record fetch (``fetch_change_records`` /
+``_select_change_rows_for_kinds``), the name-denormalization helpers
+(``_resolve_names_for_kind`` / ``apply_entity_name_denormalization``), the
+path-entity resolution helper (``resolve_path_entity``), and the
+tombstone-state lookup (``check_entity_tombstones``) live here.
+
+Each helper is a thin SELECT-and-shape function — no orchestration,
+no business logic. Callers in :mod:`scope`, :mod:`render`, and
+:mod:`orchestrator` compose them into the end-to-end request.
+
+**Inline imports.** Continuum's ``version_class`` / ``versioning_manager``
+and the Superset model classes are imported inside each helper because
+this package is loaded from ``init_versioning()`` before all SQLAlchemy
+mappers are configured.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+import sqlalchemy as sa
+from flask_appbuilder import Model
+
+from superset.extensions import db
+from superset.versioning.activity.kinds import (
+ API_KIND_TO_TABLE,
+ chunked_ids,
+ EntityWindows,
+ load_live_model,
+ NAME_COLUMN,
+ NOT_FOUND_EXC,
+ TABLE_KIND_TO_API,
+ Window,
+)
+from superset.versioning.activity.windows import row_within_any_window
+from superset.versioning.changes import version_changes_table
+
+logger = logging.getLogger(__name__)
+
+# ---- Path-entity resolution -----------------------------------------------
+
+
+def resolve_path_entity(model_cls: type[Model], entity_uuid: UUID) ->
tuple[Any, int]:
+ """Resolve *entity_uuid* to ``(live_entity, entity_id)`` or raise a
+ typed 404.
+
+ Soft-delete handling is inherited transparently from
+ :func:`superset.versioning.queries.find_active_by_uuid` once it
+ learns to filter out ``deleted_at IS NOT NULL`` rows; at that point
+ soft-deleted paths will also raise here.
+ """
+ # pylint: disable=import-outside-toplevel
+ from superset.versioning.queries import find_active_by_uuid
+
+ entity = find_active_by_uuid(model_cls, entity_uuid)
+ if entity is None:
+ api_kind = model_cls.__name__
+ exc_cls = NOT_FOUND_EXC.get(api_kind)
+ if exc_cls is None:
+ raise LookupError(
+ f"Activity view does not support model class {api_kind!r}"
+ )
+ raise exc_cls(str(entity_uuid))
+ return entity, entity.id
+
+
+def first_tracked_tx(
+ model_cls: type[Model], entity_id: int, entity_uuid: UUID
+) -> int | None:
+ """Return the earliest Continuum transaction_id of the shadow rows
+ that belong to *this* live entity, matched on ``(id, uuid)``; ``None``
+ when the entity has no tracked history yet.
+
+ Used to lower-bound the self-scope window. Matching on the integer
+ ``id`` alone would inherit a previously hard-deleted entity's history
+ under id reuse (SQLite/MySQL reuse ``max(id)+1``); pinning the uuid
+ too — the same discrimination :func:`mark_first_tracked_saves` uses —
+ scopes the window to the current entity's own transactions.
+ """
+ # pylint: disable=import-outside-toplevel
+ from sqlalchemy_continuum import version_class
+
+ shadow_tbl = version_class(model_cls).__table__
+ stmt = sa.select(sa.func.min(shadow_tbl.c.transaction_id)).where(
+ shadow_tbl.c.id == entity_id,
+ shadow_tbl.c.uuid == entity_uuid,
+ )
+ return db.session.connection().execute(stmt).scalar()
+
+
+# ---- Phase A: relationship-traversal queries ------------------------------
+
+
+def charts_attached_to_dashboard(dashboard_id: int) -> list[tuple[int,
Window]]:
+ """Return ``(slice_id, window)`` for every chart that has ever been on
+ *dashboard_id*, with each association's validity window in
+ transaction-id space.
+
+ Reads from ``dashboard_slices_version`` (Continuum's auto-generated
+ M2M shadow). Rows with ``operation_type = 2`` (DELETE) are excluded
+ so we don't synthesize a phantom window from a detachment row.
+ """
+ # pylint: disable=import-outside-toplevel
+ from sqlalchemy_continuum import version_class
+
+ from superset.models.dashboard import Dashboard
+
+ metadata = version_class(Dashboard).__table__.metadata
+ m2m_tbl = metadata.tables.get("dashboard_slices_version")
+ if m2m_tbl is None:
+ return []
+
+ rows = (
+ db.session.connection()
+ .execute(
+ sa.select(
+ m2m_tbl.c.slice_id,
+ m2m_tbl.c.transaction_id,
+ m2m_tbl.c.end_transaction_id,
+ ).where(
+ m2m_tbl.c.dashboard_id == dashboard_id,
+ m2m_tbl.c.operation_type != 2,
+ m2m_tbl.c.slice_id.is_not(None),
+ )
+ )
+ .all()
+ )
+ result: list[tuple[int, Window]] = []
+ for row in rows:
+ try:
+ window = Window(row[1], row[2])
+ except ValueError:
+ # A degenerate shadow row (end_tx <= start_tx) must not 500 the
+ # endpoint; skip it and leave a breadcrumb for investigation.
+ logger.warning(
+ "activity: skipping degenerate dashboard_slices_version row "
+ "(dashboard_id=%s, slice_id=%s, tx=%s, end_tx=%s)",
+ dashboard_id,
+ row[0],
+ row[1],
+ row[2],
+ )
+ continue
+ result.append((row[0], window))
+ return result
+
+
+def datasets_used_by_chart(slice_id: int) -> list[tuple[int, Window]]:
+ """Return ``(datasource_id, window)`` for every dataset that *slice_id*
+ has ever pointed at, with each association's validity window.
+
+ Single-slice form, used by ``_resolve_chart_scope`` where there
+ is only one chart to walk. The dashboard-scope path calls
+ :func:`batch_datasets_used_by_charts` instead so the query fires
+ once for all slices on the dashboard, not once per slice.
+
+ Reads from ``slices_version`` (the chart parent shadow). Filters to
+ ``datasource_type = 'table'`` because the activity view only follows
+ the chart → ``SqlaTable`` dependency edge (not legacy/other
+ datasources). Rows with ``operation_type = 2`` are excluded.
+ """
+ return batch_datasets_used_by_charts({slice_id}).get(slice_id, [])
+
+
+def batch_datasets_used_by_charts(
+ slice_ids: set[int],
+) -> dict[int, list[tuple[int, Window]]]:
+ """Batch form of :func:`datasets_used_by_chart`. Returns
+ ``{slice_id: [(dataset_id, window), ...]}`` in a single query so the
+ dashboard-scope walker doesn't fire one query per chart on the
+ dashboard. The previous per-slice shape became O(n_charts) round-
+ trips, which dominated ``get_activity`` latency on dashboards with
+ rich history (profile run 2026-05-26 showed `resolve_scope`
+ accounting for ~1.9s out of 4s p95).
+ """
+ if not slice_ids:
+ return {}
+
+ # pylint: disable=import-outside-toplevel
+ from sqlalchemy_continuum import version_class
+
+ from superset.models.slice import Slice
+
+ slices_tbl = version_class(Slice).__table__
+ grouped: dict[int, list[tuple[int, Window]]] = {}
+ # Chunk the IN-clause under SQLite's bind-variable floor (a dashboard can
+ # carry more charts than the floor allows in one statement).
+ rows: list[Any] = []
+ for chunk in chunked_ids(slice_ids):
+ rows.extend(
+ db.session.connection()
+ .execute(
+ sa.select(
+ slices_tbl.c.id,
+ slices_tbl.c.datasource_id,
+ slices_tbl.c.transaction_id,
+ slices_tbl.c.end_transaction_id,
+ ).where(
+ slices_tbl.c.id.in_(chunk),
+ slices_tbl.c.datasource_type == "table",
+ slices_tbl.c.operation_type != 2,
+ slices_tbl.c.datasource_id.is_not(None),
+ )
+ )
+ .mappings()
+ .all()
+ )
+ for row in rows:
+ try:
+ window = Window(row["transaction_id"], row["end_transaction_id"])
+ except ValueError:
+ # A degenerate shadow row (end_tx <= start_tx) must not 500 the
+ # endpoint; skip it and leave a breadcrumb for investigation.
+ logger.warning(
+ "activity: skipping degenerate slices_version row "
+ "(slice_id=%s, datasource_id=%s, tx=%s, end_tx=%s)",
+ row["id"],
+ row["datasource_id"],
+ row["transaction_id"],
+ row["end_transaction_id"],
+ )
+ continue
+ grouped.setdefault(row["id"], []).append((row["datasource_id"],
window))
+ return grouped
+
+
+# ---- Phase B: change-record fetch -----------------------------------------
+
+
+def fetch_change_records(
+ entity_window_tuples: list[EntityWindows],
+ since: datetime | None,
+ until: datetime | None,
+) -> tuple[list[dict[str, Any]], bool]:
+ """Fetch all ``version_changes`` rows matching any of the supplied
+ entity-window tuples, joined with ``version_transaction`` for
+ ``issued_at`` and ``user_id``.
+
+ Each tuple is ``(api_kind, entity_id, [(start_tx, end_tx), ...])``;
+ a record matches when ``entity_kind`` equals the table-stored form
+ of *api_kind*, ``entity_id`` matches, and ``transaction_id`` falls
+ inside at least one of the entity's windows. ``since``/``until``
+ further restrict by ``issued_at``.
+
+ Implementation: one SELECT per kind with ``entity_id IN (...)`` and
+ a wide ``transaction_id`` bound (the union of all windows for that
+ kind). Per-window precision is applied in Python afterward. This
+ keeps the SQL shape proportional to the number of *kinds* (≤3) and
+ the bound proportional to the union of windows, not the cross-
+ product of (entity, window) — which previously generated one OR
+ clause per (entity, window) pair and hit SQLite's
+ ``SQLITE_MAX_EXPR_DEPTH`` limit on dashboards with many slices
+ or many historical attachment windows.
+
+ The visibility filter runs after this function (records
+ the requester can't read are silently dropped and must not
+ contribute to ``count``), so the orchestrator paginates in Python
+ over the filtered list — there is no DB-level page ``OFFSET`` here.
+ There IS a per-kind safety ceiling (``_MAX_FETCHED_RECORDS``) that
+ bounds how much history a single request materializes (to at most
+ ``n_kinds * _MAX_FETCHED_RECORDS``); when a kind exhausts it, the
+ second return value is ``True``, the returned stream is clamped to a
+ clean time cut (records older than the highest per-kind fetch floor
+ are dropped so truncation is a time boundary, not per-kind holes), and
+ the caller surfaces ``truncated`` on the response.
+
+ Returns ``(records, truncated)``. Records are ordered by
+ ``(issued_at DESC, transaction_id DESC, sequence DESC)`` — the
+ secondary keys break ties for the stable-ordering contract.
+ """
+ if not entity_window_tuples:
+ return [], False
+
+ # Group windows by (table_kind, entity_id) and by table_kind for SQL
+ # narrowing. The fetch is per-kind; the post-filter is per-entity.
+ windows_by_entity: dict[tuple[str, int], list[Window]] = {}
+ ids_by_kind: dict[str, set[int]] = {}
+ for api_kind, entity_id, windows in entity_window_tuples:
+ table_kind = API_KIND_TO_TABLE.get(api_kind)
+ if table_kind is None or not windows:
+ continue
+ ids_by_kind.setdefault(table_kind, set()).add(entity_id)
+ windows_by_entity.setdefault((table_kind, entity_id),
[]).extend(windows)
+
+ if not ids_by_kind:
+ return [], False
+
+ # Per-kind transaction_id bounds = the union of that kind's windows.
+ # Pushing these into the SQL WHERE ensures the per-statement LIMIT
+ # selects from IN-WINDOW rows. Without it, a related entity whose
+ # association window is far in the past would have the newest ``limit``
+ # (out-of-window) rows fetched and discarded, silently dropping its
+ # in-window records that lie beyond the limit. ``end_tx = None``
+ # (open-ended/current) means no upper bound for that kind.
+ bounds_by_kind: dict[str, tuple[int, int | None]] = {}
+ for (table_kind, _entity_id), windows in windows_by_entity.items():
+ for w in windows:
+ cur = bounds_by_kind.get(table_kind)
+ if cur is None:
+ bounds_by_kind[table_kind] = (w.start_tx, w.end_tx)
+ continue
+ lo = min(cur[0], w.start_tx)
+ hi = None if (cur[1] is None or w.end_tx is None) else max(cur[1],
w.end_tx)
+ bounds_by_kind[table_kind] = (lo, hi)
+
+ rows, truncated, truncation_floor = _select_change_rows_for_kinds(
+ ids_by_kind, bounds_by_kind, since, until, _MAX_FETCHED_RECORDS
+ )
+ filtered = [
+ row
+ for row in rows
+ if row_within_any_window(
+ row, windows_by_entity.get((row["entity_kind"], row["entity_id"]),
[])
+ )
+ ]
+ if truncated and truncation_floor is not None:
+ # Collapse truncation to a clean time cut. Below the highest per-kind
+ # fetch floor at least one kind is missing rows, so surfacing the
+ # other kinds there would present an incomplete ("nothing else
+ # changed") picture. Drop everything older than the floor so
+ # ``truncated=True`` means "complete at/after this instant, nothing
+ # shown before it" — a time boundary rather than per-kind holes.
+ filtered = [r for r in filtered if r["issued_at"] >= truncation_floor]
Review Comment:
**Suggestion:** Truncation is collapsed using only `issued_at`, but ordering
is actually by `(issued_at, transaction_id, sequence, ...)`. If truncation cuts
within a timestamp bucket, this filter still keeps records at that timestamp
while older siblings at the same timestamp were dropped by the limit, so the
response is not a true “clean cut” and can contain hidden holes. Use a
composite floor key matching the full sort tuple instead of timestamp-only
comparison. [incorrect condition logic]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
⚠️ Truncated activity streams incomplete at boundary timestamp instant.
⚠️ Clients may misinterpret truncated results as fully representative.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. When `get_activity()` in
`superset/versioning/activity/orchestrator.py:62-63` calls
`fetch_change_records()` for a scope with very long history,
`_select_change_rows_for_kinds()` (queries.py:365-491) may hit the per-kind
`limit`
(`_MAX_FETCHED_RECORDS = 5000` at queries.py:494-501), marking `truncated =
True` for that
kind and recording a per-kind `truncation_floor` as `min(r["issued_at"] for
r in
kind_rows)` (lines 482-488).
2. `fetch_change_records()` receives `(rows, truncated, truncation_floor)`
from
`_select_change_rows_for_kinds()` and builds `filtered` based on exact
window membership
(lines 329-338). If `truncated` is true and `truncation_floor` not None, it
then executes
`filtered = [r for r in filtered if r["issued_at"] >= truncation_floor]` at
`queries.py:339-346`, using only the timestamp floor to collapse truncation
and relying on
the docstring claim that this produces a "clean time cut" (comments at lines
340-345).
3. Final ordering of records is by the composite key `(issued_at,
transaction_id,
sequence, entity_kind, entity_id)` descending (queries.py:347-359). When
multiple change
records share the same `issued_at` value but some of them were not fetched
because the
per-kind limit was reached, the timestamp-only filter keeps the subset of
records at that
issued_at that happened to be fetched while omitting other siblings at the
same instant,
so the stream is not actually complete "at/after this instant".
4. In truncated responses (for dashboards or charts with >5000 change rows
for a kind),
clients can see some records at the boundary timestamp and assume that
instant is fully
represented, while other change records that occurred at the same timestamp
but were
excluded by the limit remain invisible. Using only `issued_at` in the
truncation filter
creates hidden per-kind holes at the boundary instant, contradicting the
intended
clean-cut semantics described in the `fetch_change_records()` docstring.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=9720fcd5c4ad4844aeec4a9fd02b09b0&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
[](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=9720fcd5c4ad4844aeec4a9fd02b09b0&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/versioning/activity/queries.py
**Line:** 346:346
**Comment:**
*Incorrect Condition Logic: Truncation is collapsed using only
`issued_at`, but ordering is actually by `(issued_at, transaction_id, sequence,
...)`. If truncation cuts within a timestamp bucket, this filter still keeps
records at that timestamp while older siblings at the same timestamp were
dropped by the limit, so the response is not a true “clean cut” and can contain
hidden holes. Use a composite floor key matching the full sort tuple instead of
timestamp-only comparison.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask
user if the user wants to fix the rest of the comments as well. if said yes,
then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=80b71502635fdf1b1d2a0528e41fce4b383cfcb912ab224511faac96d90df19e&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=80b71502635fdf1b1d2a0528e41fce4b383cfcb912ab224511faac96d90df19e&reaction=dislike'>👎</a>
##########
superset/datasets/api.py:
##########
@@ -1808,6 +1807,86 @@ def get_version(self, uuid_str: str, version_uuid_str:
str) -> Response:
404:
$ref: '#/components/responses/404'
"""
- return get_version_endpoint(
- self, SqlaTable, uuid_str, version_uuid_str,
access_kwarg="datasource"
- )
+ return get_version_endpoint(self, SqlaTable, uuid_str,
version_uuid_str)
+
+ @expose("/<uuid_str>/activity/", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs:
f"{self.__class__.__name__}.activity",
+ log_to_statsd=False,
+ )
+ def activity(self, uuid_str: str) -> Response:
Review Comment:
**Suggestion:** This new protected endpoint also lacks a permission alias to
`get`, so FAB falls back to
`can_activity_Dataset`/`can_activity_DatasetRestApi`-style permission checks
instead of normal read access. Users who can view datasets but were not
explicitly granted this new custom permission will receive 403. Map `activity`
to `get` (or add `@permission_name("get")`) to keep authorization behavior
consistent with other read endpoints. [api mismatch]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
❌ Dataset activity endpoint inaccessible to normal dataset readers.
⚠️ Activity history feature unusable without custom role updates.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Inspect `DatasetRestApi` in `superset/datasets/api.py:1-35`; note
`class_permission_name = "Dataset"` and `method_permission_name` only maps
standard CRUD
methods and `"restore"` to `"write"` (lines 7-18), with comments identical to
`DashboardRestApi` about `@protect()` falling back to `can_<method>_<class>`
when a
mapping is missing and standard roles not carrying those permissions.
2. Observe the dataset `activity` endpoint at
`superset/datasets/api.py:108-116` (diff
lines 1812-1820): it is decorated with `@expose("/<uuid_str>/activity/",
methods=("GET",))`, `@protect()`, `@safe`, `@statsd_metrics`, and logging,
but has no
`@permission_name("get")` and `"activity"` is not present in
`method_permission_name`.
3. Run Superset and log in as a user whose role allows viewing datasets (can
access
`/api/v1/dataset/<id>/` and the dataset list) but who has not been granted a
`can_activity_Dataset` or `can_activity_DatasetRestApi` permission; roles
are based on
existing read/write mappings and will not automatically include this new
method-specific
permission.
4. Call `GET /api/v1/dataset/<uuid>/activity/`, which invokes
`DatasetRestApi.activity`;
FAB resolves the required permission as `can_activity_Dataset` (or
`can_activity_DatasetRestApi`) because no alias is configured, finds it
missing on the
role, and `@protect()` returns HTTP 403, preventing normal dataset readers
from seeing
their own activity history.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=de9d0f78f1e7449eb9d8cabb4022cbf8&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
[](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=de9d0f78f1e7449eb9d8cabb4022cbf8&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/datasets/api.py
**Line:** 1812:1820
**Comment:**
*Api Mismatch: This new protected endpoint also lacks a permission
alias to `get`, so FAB falls back to
`can_activity_Dataset`/`can_activity_DatasetRestApi`-style permission checks
instead of normal read access. Users who can view datasets but were not
explicitly granted this new custom permission will receive 403. Map `activity`
to `get` (or add `@permission_name("get")`) to keep authorization behavior
consistent with other read endpoints.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask
user if the user wants to fix the rest of the comments as well. if said yes,
then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=15c4bccb52cecab4b102e9e0e1300bdcc131c39d32ec9e0abab1065d0b9c848c&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=15c4bccb52cecab4b102e9e0e1300bdcc131c39d32ec9e0abab1065d0b9c848c&reaction=dislike'>👎</a>
##########
superset/versioning/activity/queries.py:
##########
@@ -0,0 +1,754 @@
+# 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.
+"""DB-touching helpers for the activity-view read path.
+
+All Phase A relationship walks (``charts_attached_to_dashboard``,
+``datasets_used_by_chart``, ``batch_datasets_used_by_charts``),
+the Phase B change-record fetch (``fetch_change_records`` /
+``_select_change_rows_for_kinds``), the name-denormalization helpers
+(``_resolve_names_for_kind`` / ``apply_entity_name_denormalization``), the
+path-entity resolution helper (``resolve_path_entity``), and the
+tombstone-state lookup (``check_entity_tombstones``) live here.
+
+Each helper is a thin SELECT-and-shape function — no orchestration,
+no business logic. Callers in :mod:`scope`, :mod:`render`, and
+:mod:`orchestrator` compose them into the end-to-end request.
+
+**Inline imports.** Continuum's ``version_class`` / ``versioning_manager``
+and the Superset model classes are imported inside each helper because
+this package is loaded from ``init_versioning()`` before all SQLAlchemy
+mappers are configured.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+import sqlalchemy as sa
+from flask_appbuilder import Model
+
+from superset.extensions import db
+from superset.versioning.activity.kinds import (
+ API_KIND_TO_TABLE,
+ chunked_ids,
+ EntityWindows,
+ load_live_model,
+ NAME_COLUMN,
+ NOT_FOUND_EXC,
+ TABLE_KIND_TO_API,
+ Window,
+)
+from superset.versioning.activity.windows import row_within_any_window
+from superset.versioning.changes import version_changes_table
+
+logger = logging.getLogger(__name__)
+
+# ---- Path-entity resolution -----------------------------------------------
+
+
+def resolve_path_entity(model_cls: type[Model], entity_uuid: UUID) ->
tuple[Any, int]:
+ """Resolve *entity_uuid* to ``(live_entity, entity_id)`` or raise a
+ typed 404.
+
+ Soft-delete handling is inherited transparently from
+ :func:`superset.versioning.queries.find_active_by_uuid` once it
+ learns to filter out ``deleted_at IS NOT NULL`` rows; at that point
+ soft-deleted paths will also raise here.
+ """
+ # pylint: disable=import-outside-toplevel
+ from superset.versioning.queries import find_active_by_uuid
+
+ entity = find_active_by_uuid(model_cls, entity_uuid)
+ if entity is None:
+ api_kind = model_cls.__name__
+ exc_cls = NOT_FOUND_EXC.get(api_kind)
+ if exc_cls is None:
+ raise LookupError(
+ f"Activity view does not support model class {api_kind!r}"
+ )
+ raise exc_cls(str(entity_uuid))
+ return entity, entity.id
+
+
+def first_tracked_tx(
+ model_cls: type[Model], entity_id: int, entity_uuid: UUID
+) -> int | None:
+ """Return the earliest Continuum transaction_id of the shadow rows
+ that belong to *this* live entity, matched on ``(id, uuid)``; ``None``
+ when the entity has no tracked history yet.
+
+ Used to lower-bound the self-scope window. Matching on the integer
+ ``id`` alone would inherit a previously hard-deleted entity's history
+ under id reuse (SQLite/MySQL reuse ``max(id)+1``); pinning the uuid
+ too — the same discrimination :func:`mark_first_tracked_saves` uses —
+ scopes the window to the current entity's own transactions.
+ """
+ # pylint: disable=import-outside-toplevel
+ from sqlalchemy_continuum import version_class
+
+ shadow_tbl = version_class(model_cls).__table__
+ stmt = sa.select(sa.func.min(shadow_tbl.c.transaction_id)).where(
+ shadow_tbl.c.id == entity_id,
+ shadow_tbl.c.uuid == entity_uuid,
+ )
+ return db.session.connection().execute(stmt).scalar()
+
+
+# ---- Phase A: relationship-traversal queries ------------------------------
+
+
+def charts_attached_to_dashboard(dashboard_id: int) -> list[tuple[int,
Window]]:
+ """Return ``(slice_id, window)`` for every chart that has ever been on
+ *dashboard_id*, with each association's validity window in
+ transaction-id space.
+
+ Reads from ``dashboard_slices_version`` (Continuum's auto-generated
+ M2M shadow). Rows with ``operation_type = 2`` (DELETE) are excluded
+ so we don't synthesize a phantom window from a detachment row.
+ """
+ # pylint: disable=import-outside-toplevel
+ from sqlalchemy_continuum import version_class
+
+ from superset.models.dashboard import Dashboard
+
+ metadata = version_class(Dashboard).__table__.metadata
+ m2m_tbl = metadata.tables.get("dashboard_slices_version")
+ if m2m_tbl is None:
+ return []
+
+ rows = (
+ db.session.connection()
+ .execute(
+ sa.select(
+ m2m_tbl.c.slice_id,
+ m2m_tbl.c.transaction_id,
+ m2m_tbl.c.end_transaction_id,
+ ).where(
+ m2m_tbl.c.dashboard_id == dashboard_id,
+ m2m_tbl.c.operation_type != 2,
+ m2m_tbl.c.slice_id.is_not(None),
+ )
+ )
+ .all()
+ )
+ result: list[tuple[int, Window]] = []
+ for row in rows:
+ try:
+ window = Window(row[1], row[2])
+ except ValueError:
+ # A degenerate shadow row (end_tx <= start_tx) must not 500 the
+ # endpoint; skip it and leave a breadcrumb for investigation.
+ logger.warning(
+ "activity: skipping degenerate dashboard_slices_version row "
+ "(dashboard_id=%s, slice_id=%s, tx=%s, end_tx=%s)",
+ dashboard_id,
+ row[0],
+ row[1],
+ row[2],
+ )
+ continue
+ result.append((row[0], window))
+ return result
+
+
+def datasets_used_by_chart(slice_id: int) -> list[tuple[int, Window]]:
+ """Return ``(datasource_id, window)`` for every dataset that *slice_id*
+ has ever pointed at, with each association's validity window.
+
+ Single-slice form, used by ``_resolve_chart_scope`` where there
+ is only one chart to walk. The dashboard-scope path calls
+ :func:`batch_datasets_used_by_charts` instead so the query fires
+ once for all slices on the dashboard, not once per slice.
+
+ Reads from ``slices_version`` (the chart parent shadow). Filters to
+ ``datasource_type = 'table'`` because the activity view only follows
+ the chart → ``SqlaTable`` dependency edge (not legacy/other
+ datasources). Rows with ``operation_type = 2`` are excluded.
+ """
+ return batch_datasets_used_by_charts({slice_id}).get(slice_id, [])
+
+
+def batch_datasets_used_by_charts(
+ slice_ids: set[int],
+) -> dict[int, list[tuple[int, Window]]]:
+ """Batch form of :func:`datasets_used_by_chart`. Returns
+ ``{slice_id: [(dataset_id, window), ...]}`` in a single query so the
+ dashboard-scope walker doesn't fire one query per chart on the
+ dashboard. The previous per-slice shape became O(n_charts) round-
+ trips, which dominated ``get_activity`` latency on dashboards with
+ rich history (profile run 2026-05-26 showed `resolve_scope`
+ accounting for ~1.9s out of 4s p95).
+ """
+ if not slice_ids:
+ return {}
+
+ # pylint: disable=import-outside-toplevel
+ from sqlalchemy_continuum import version_class
+
+ from superset.models.slice import Slice
+
+ slices_tbl = version_class(Slice).__table__
+ grouped: dict[int, list[tuple[int, Window]]] = {}
+ # Chunk the IN-clause under SQLite's bind-variable floor (a dashboard can
+ # carry more charts than the floor allows in one statement).
+ rows: list[Any] = []
+ for chunk in chunked_ids(slice_ids):
+ rows.extend(
+ db.session.connection()
+ .execute(
+ sa.select(
+ slices_tbl.c.id,
+ slices_tbl.c.datasource_id,
+ slices_tbl.c.transaction_id,
+ slices_tbl.c.end_transaction_id,
+ ).where(
+ slices_tbl.c.id.in_(chunk),
+ slices_tbl.c.datasource_type == "table",
+ slices_tbl.c.operation_type != 2,
+ slices_tbl.c.datasource_id.is_not(None),
+ )
+ )
+ .mappings()
+ .all()
+ )
+ for row in rows:
+ try:
+ window = Window(row["transaction_id"], row["end_transaction_id"])
+ except ValueError:
+ # A degenerate shadow row (end_tx <= start_tx) must not 500 the
+ # endpoint; skip it and leave a breadcrumb for investigation.
+ logger.warning(
+ "activity: skipping degenerate slices_version row "
+ "(slice_id=%s, datasource_id=%s, tx=%s, end_tx=%s)",
+ row["id"],
+ row["datasource_id"],
+ row["transaction_id"],
+ row["end_transaction_id"],
+ )
+ continue
+ grouped.setdefault(row["id"], []).append((row["datasource_id"],
window))
+ return grouped
+
+
+# ---- Phase B: change-record fetch -----------------------------------------
+
+
+def fetch_change_records(
+ entity_window_tuples: list[EntityWindows],
+ since: datetime | None,
+ until: datetime | None,
+) -> tuple[list[dict[str, Any]], bool]:
+ """Fetch all ``version_changes`` rows matching any of the supplied
+ entity-window tuples, joined with ``version_transaction`` for
+ ``issued_at`` and ``user_id``.
+
+ Each tuple is ``(api_kind, entity_id, [(start_tx, end_tx), ...])``;
+ a record matches when ``entity_kind`` equals the table-stored form
+ of *api_kind*, ``entity_id`` matches, and ``transaction_id`` falls
+ inside at least one of the entity's windows. ``since``/``until``
+ further restrict by ``issued_at``.
+
+ Implementation: one SELECT per kind with ``entity_id IN (...)`` and
+ a wide ``transaction_id`` bound (the union of all windows for that
+ kind). Per-window precision is applied in Python afterward. This
+ keeps the SQL shape proportional to the number of *kinds* (≤3) and
+ the bound proportional to the union of windows, not the cross-
+ product of (entity, window) — which previously generated one OR
+ clause per (entity, window) pair and hit SQLite's
+ ``SQLITE_MAX_EXPR_DEPTH`` limit on dashboards with many slices
+ or many historical attachment windows.
+
+ The visibility filter runs after this function (records
+ the requester can't read are silently dropped and must not
+ contribute to ``count``), so the orchestrator paginates in Python
+ over the filtered list — there is no DB-level page ``OFFSET`` here.
+ There IS a per-kind safety ceiling (``_MAX_FETCHED_RECORDS``) that
+ bounds how much history a single request materializes (to at most
+ ``n_kinds * _MAX_FETCHED_RECORDS``); when a kind exhausts it, the
+ second return value is ``True``, the returned stream is clamped to a
+ clean time cut (records older than the highest per-kind fetch floor
+ are dropped so truncation is a time boundary, not per-kind holes), and
+ the caller surfaces ``truncated`` on the response.
+
+ Returns ``(records, truncated)``. Records are ordered by
+ ``(issued_at DESC, transaction_id DESC, sequence DESC)`` — the
+ secondary keys break ties for the stable-ordering contract.
+ """
+ if not entity_window_tuples:
+ return [], False
+
+ # Group windows by (table_kind, entity_id) and by table_kind for SQL
+ # narrowing. The fetch is per-kind; the post-filter is per-entity.
+ windows_by_entity: dict[tuple[str, int], list[Window]] = {}
+ ids_by_kind: dict[str, set[int]] = {}
+ for api_kind, entity_id, windows in entity_window_tuples:
+ table_kind = API_KIND_TO_TABLE.get(api_kind)
+ if table_kind is None or not windows:
+ continue
+ ids_by_kind.setdefault(table_kind, set()).add(entity_id)
+ windows_by_entity.setdefault((table_kind, entity_id),
[]).extend(windows)
+
+ if not ids_by_kind:
+ return [], False
+
+ # Per-kind transaction_id bounds = the union of that kind's windows.
+ # Pushing these into the SQL WHERE ensures the per-statement LIMIT
+ # selects from IN-WINDOW rows. Without it, a related entity whose
+ # association window is far in the past would have the newest ``limit``
+ # (out-of-window) rows fetched and discarded, silently dropping its
+ # in-window records that lie beyond the limit. ``end_tx = None``
+ # (open-ended/current) means no upper bound for that kind.
+ bounds_by_kind: dict[str, tuple[int, int | None]] = {}
+ for (table_kind, _entity_id), windows in windows_by_entity.items():
+ for w in windows:
+ cur = bounds_by_kind.get(table_kind)
+ if cur is None:
+ bounds_by_kind[table_kind] = (w.start_tx, w.end_tx)
+ continue
+ lo = min(cur[0], w.start_tx)
+ hi = None if (cur[1] is None or w.end_tx is None) else max(cur[1],
w.end_tx)
+ bounds_by_kind[table_kind] = (lo, hi)
+
+ rows, truncated, truncation_floor = _select_change_rows_for_kinds(
+ ids_by_kind, bounds_by_kind, since, until, _MAX_FETCHED_RECORDS
+ )
+ filtered = [
+ row
+ for row in rows
+ if row_within_any_window(
+ row, windows_by_entity.get((row["entity_kind"], row["entity_id"]),
[])
+ )
+ ]
+ if truncated and truncation_floor is not None:
+ # Collapse truncation to a clean time cut. Below the highest per-kind
+ # fetch floor at least one kind is missing rows, so surfacing the
+ # other kinds there would present an incomplete ("nothing else
+ # changed") picture. Drop everything older than the floor so
+ # ``truncated=True`` means "complete at/after this instant, nothing
+ # shown before it" — a time boundary rather than per-kind holes.
+ filtered = [r for r in filtered if r["issued_at"] >= truncation_floor]
+ # Sort key must be TOTAL so pagination is stable across requests: two
+ # records from different entities can share (issued_at, transaction_id,
+ # sequence), so append (entity_kind, entity_id) to break remaining ties
+ # deterministically. Without these the relative order of tied records
+ # depends on set-iteration order and a record could shift pages.
+ filtered.sort(
+ key=lambda r: (
+ r["issued_at"],
+ r["transaction_id"],
+ r["sequence"],
+ r["entity_kind"],
+ r["entity_id"],
+ ),
+ reverse=True,
+ )
+ return filtered, truncated
+
+
+def _select_change_rows_for_kinds(
+ ids_by_kind: dict[str, set[int]],
+ bounds_by_kind: dict[str, tuple[int, int | None]],
+ since: datetime | None,
+ until: datetime | None,
+ limit: int,
+) -> tuple[list[dict[str, Any]], bool, datetime | None]:
+ """Fire one SELECT per entity_kind with ``entity_id IN (...)``;
+ concatenate the results. Each SELECT joins ``version_transaction``
+ + ``ab_user`` so the orchestrator has the columns it needs for
+ decoration.
+
+ Per-kind, not one query: SQLAlchemy's ``tuple_(entity_kind,
+ entity_id).in_(...)`` would collapse the three queries into one,
+ but its SQL emission is not portable across Postgres, MySQL, and
+ SQLite. The per-kind shape is the correct trade-off given
+ Superset's multi-dialect requirement (at most 3 round-trips per
+ request, bounded by the kind taxonomy). Do not "optimise" into a
+ composite-tuple IN clause without verifying the SQL on all three
+ dialects.
+
+ **Init-order dependency.** ``tx_tbl.c.action_kind`` resolves only
+ after ``init_versioning()`` has run — the column is appended onto
+ Continuum's transaction Table by
+ ``superset.versioning.factory.VersionTransactionFactory`` at app
+ start via ``append_column`` + ``add_property``. This helper is
+ safe to call from request-path code because the app is fully
+ initialised by then; calling it from a script that imports the
+ versioning package without going through ``init_versioning()``
+ will raise ``AttributeError`` on the ``action_kind`` attribute
+ access below."""
+ # pylint: disable=import-outside-toplevel
+ from sqlalchemy_continuum import versioning_manager
+
+ from superset import security_manager
+
+ tx_tbl = versioning_manager.transaction_cls.__table__
+ user_tbl = security_manager.user_model.__table__
+ vc = version_changes_table
+ join_tree = vc.join(tx_tbl, vc.c.transaction_id == tx_tbl.c.id).outerjoin(
+ user_tbl, tx_tbl.c.user_id == user_tbl.c.id
+ )
+ select_cols = (
+ vc.c.transaction_id,
+ vc.c.entity_kind,
+ vc.c.entity_id,
+ vc.c.sequence,
+ vc.c.kind,
+ vc.c.operation,
+ vc.c.path,
+ vc.c.from_value,
+ vc.c.to_value,
+ tx_tbl.c.issued_at,
+ tx_tbl.c.user_id,
+ # ``action_kind`` is the high-level avenue (restore / import /
+ # clone / NULL=ordinary save) stamped by the originating
+ # command via the change-record listener. All records sharing a
+ # ``transaction_id`` share the same value. The column is
+ # declared on the Continuum Table by ``VersionTransactionFactory``,
+ # so ``tx_tbl.c.action_kind`` resolves cleanly here. See
+ # the three change-record dimensions.
+ tx_tbl.c.action_kind,
+ user_tbl.c.id.label("changed_by_id"),
+ user_tbl.c.first_name,
+ user_tbl.c.last_name,
+ )
+
+ out: list[dict[str, Any]] = []
+ truncated = False
+ truncation_floors: list[datetime] = []
+ for table_kind, entity_ids in ids_by_kind.items():
+ # The fetch budget is per KIND, not per id-chunk. A kind whose
+ # entity_ids span several bind-variable chunks shares one ``limit``
+ # across those chunks, so the rows a single request materializes are
+ # bounded by ``n_kinds * limit`` (<= 3 * _MAX_FETCHED_RECORDS), not
+ # ``n_chunks * limit``. ``entity_ids`` is chunked to stay inside
+ # SQLite's ``SQLITE_MAX_VARIABLE_NUMBER`` floor (default 999 in many
+ # builds); Postgres + MySQL accept the full list but the chunk is
+ # dialect-agnostic for simplicity.
+ kind_rows: list[dict[str, Any]] = []
+ for chunk in chunked_ids(entity_ids):
+ remaining = limit - len(kind_rows)
+ if remaining <= 0:
+ break
+ stmt = (
+ sa.select(*select_cols)
+ .select_from(join_tree)
+ .where(
+ vc.c.entity_kind == table_kind,
+ vc.c.entity_id.in_(chunk),
+ )
+ )
+ # Bound by the kind's window union so the LIMIT picks in-window
+ # rows (see fetch_change_records). The per-entity window filter
+ # still runs in Python afterwards for exact membership.
+ tx_lo, tx_hi = bounds_by_kind[table_kind]
+ stmt = stmt.where(vc.c.transaction_id >= tx_lo)
+ if tx_hi is not None:
+ stmt = stmt.where(vc.c.transaction_id < tx_hi)
+ if since is not None:
+ stmt = stmt.where(tx_tbl.c.issued_at >= since)
+ if until is not None:
+ stmt = stmt.where(tx_tbl.c.issued_at < until)
+ # Match fetch_change_records' final Python sort key order
+ # (issued_at, transaction_id, sequence, entity_id) so the
+ # per-kind budget keeps exactly the rows the final sort ranks
+ # highest. entity_kind is constant within a per-kind statement.
+ stmt = stmt.order_by(
+ tx_tbl.c.issued_at.desc(),
+ vc.c.transaction_id.desc(),
+ vc.c.sequence.desc(),
+ vc.c.entity_id.desc(),
+ ).limit(remaining)
+ kind_rows.extend(
+ dict(row)
+ for row in
db.session.connection().execute(stmt).mappings().all()
+ )
Review Comment:
**Suggestion:** The per-kind fetch limit is applied while iterating ID
chunks, which means once early chunks fill `remaining`, later chunks are never
queried. Because `entity_ids` comes from a set and chunk order is
non-deterministic, this can silently drop newer records from skipped chunks and
make results unstable across requests for large scopes (> chunk size). Fetch
each chunk independently and apply the limit only after globally
merging/sorting all chunk rows for that kind. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
❌ Newest activity records can be omitted for large dashboards.
⚠️ Activity results unstable when exceeding per-kind fetch limit.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. In `superset/versioning/activity/orchestrator.py:38-44`, `get_activity()`
calls
`resolve_scope()` to build `entity_windows` for the path entity (dashboard,
chart, or
dataset), then in `orchestrator.py:62-63` calls
`fetch_change_records(entity_windows,
since, until)` from `superset/versioning/activity/queries.py` for every
`/activity/`
request.
2. Inside `fetch_change_records()` (`queries.py:253-331`), the code groups
windows by
`(table_kind, entity_id)` and then calls
`_select_change_rows_for_kinds(ids_by_kind,
bounds_by_kind, since, until, _MAX_FETCHED_RECORDS)` with `limit =
_MAX_FETCHED_RECORDS`
(5000, defined at `queries.py:494-501`) as a per-kind ceiling on fetched
change rows.
3. `_select_change_rows_for_kinds()` (`queries.py:365-491`) builds
`ids_by_kind` values as
`set[int]` for each kind (line 366-367), then iterates `for table_kind,
entity_ids in
ids_by_kind.items():` and, for each kind, chunk-splits `entity_ids` via
`chunked_ids(entity_ids)` (line 445), which converts the set to a list
without any
deterministic sort in `superset/versioning/activity/kinds.py:54-62`.
4. For each chunk, the query at `queries.py:449-477` orders rows by
`(issued_at DESC,
transaction_id DESC, sequence DESC, entity_id DESC)` and applies
`.limit(remaining)` where
`remaining = limit - len(kind_rows)` (lines 446-447). As soon as earlier
chunks
collectively fill `kind_rows` to `limit`, later chunks are skipped (`if
remaining <= 0:
break` at line 447), so if some entity IDs happen to fall into later chunks
and have
change rows newer than many rows from earlier chunks, their records are
silently dropped.
Because `entity_ids` is a set and chunk grouping depends on arbitrary set
iteration order,
large scopes (>5000 records for a kind) can return activity streams missing
some of the
globally newest records and the exact subset depends on chunk ordering.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=c304b16de7cd48edab1afc003ebcd610&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
[](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=c304b16de7cd48edab1afc003ebcd610&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/versioning/activity/queries.py
**Line:** 444:481
**Comment:**
*Logic Error: The per-kind fetch limit is applied while iterating ID
chunks, which means once early chunks fill `remaining`, later chunks are never
queried. Because `entity_ids` comes from a set and chunk order is
non-deterministic, this can silently drop newer records from skipped chunks and
make results unstable across requests for large scopes (> chunk size). Fetch
each chunk independently and apply the limit only after globally
merging/sorting all chunk rows for that kind.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask
user if the user wants to fix the rest of the comments as well. if said yes,
then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=3d1e50c9ee30986e88a6178c21179e68b9b3d2116495f747f53225d3855acfc2&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=3d1e50c9ee30986e88a6178c21179e68b9b3d2116495f747f53225d3855acfc2&reaction=dislike'>👎</a>
##########
superset/versioning/activity/render.py:
##########
@@ -0,0 +1,268 @@
+# 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.
+"""Decoration: turn raw change-records into the ActivityRecord DTO.
+
+After fetching + filtering, each record needs the synthesized fields
+the API contract documents — ``entity_kind`` translated to the user-
+facing form, ``entity_uuid``, ``entity_deleted`` /
+``entity_deletion_state``, ``source`` (self vs. related),
+``summary`` (the headline), ``impact`` (chart-count for
+dashboard→dataset records), ``version_uuid``, ``changed_by``.
+
+This module collects all those decorations:
+
+* :func:`apply_record_decoration` — orchestrates the per-page additions in
+ one pass: pulls tombstones + uuids + impact counts in batches, then
+ walks records adding the synthesized fields and stripping the
+ internal-only columns the API contract doesn't expose.
+* :func:`_lookup_entity_uuids` — one IN-clause query per kind to
+ resolve live ``uuid`` for non-tombstoned entities.
+* :func:`_build_summary` — pure projection of (api_kind, record kind,
+ entity_name) onto the headline string.
+* :func:`_changed_by_dict` — projects the user columns onto the
+ ``changed_by`` DTO shape.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from uuid import UUID
+
+import sqlalchemy as sa
+
+from superset.extensions import db
+from superset.versioning.activity.impact import (
+ batch_chart_counts,
+ collect_impact_pairs,
+ impact_for_record,
+)
+from superset.versioning.activity.kinds import (
+ API_KIND_LABEL,
+ chunked_ids,
+ load_live_model,
+ NAME_COLUMN,
+ TABLE_KIND_TO_API,
+ USER_FACING_KIND,
+)
+from superset.versioning.activity.queries import check_entity_tombstones
+from superset.versioning.queries import derive_version_uuid
+
+_SUMMARY_VERBS: dict[str, str] = {
+ # The kind taxonomy mapped to past-tense verbs for the
+ # "<entity_label> <verb>: <entity_name>" headline. "field" is
+ # the fallback for scalar changes that don't map to a named verb.
+ "filter": "filter changed",
+ "metric": "metric changed",
+ "dimension": "dimension changed",
+ "column": "column changed",
+ "chart": "chart changed",
+ "time_range": "time range changed",
+ "color_palette": "palette changed",
+ "restore": "restored",
+ "field": "updated",
+}
+
+
+def apply_record_decoration(
+ records: list[dict[str, Any]],
+ path_kind: str,
+ path_id: int,
+) -> None:
+ """Add the synthesized ActivityRecord fields to each record in place:
+ ``entity_kind`` (translated to API form), ``entity_uuid``,
+ ``entity_deleted``, ``entity_deletion_state``, ``source``,
+ ``summary``, ``impact``, ``version_uuid``, ``changed_by``.
+
+ Mutates *records* in place; returns ``None``. Records are expected
+ to already carry ``entity_name`` from
+ :func:`apply_entity_name_denormalization`. The in-place mutation
+ avoids re-allocating thousands of dicts on hot dashboards; the
+ name + return signature make the side effect explicit instead of
+ pretending to be a pure projection.
+ """
+ if not records:
+ return
+
+ distinct: set[tuple[str, int]] = {
+ (
+ TABLE_KIND_TO_API.get(r["entity_kind"], ""),
+ r["entity_id"],
+ )
+ for r in records
+ if TABLE_KIND_TO_API.get(r["entity_kind"])
+ }
+ tombstones = check_entity_tombstones(distinct)
+ uuids = _lookup_entity_uuids(distinct, tombstones)
+ # Pre-compute impact counts for the whole page in one batch query
+ # instead of one COUNT per related record (was N+1).
+ impact_counts = batch_chart_counts(
+ path_id, collect_impact_pairs(records, path_kind)
+ )
+
+ for record in records:
+ api_kind = TABLE_KIND_TO_API.get(record["entity_kind"], "")
+ entity_id = record["entity_id"]
+ tombstone = tombstones.get(
+ (api_kind, entity_id), {"deleted": True, "deletion_state": None}
+ )
+ entity_uuid = uuids.get((api_kind, entity_id))
+ is_self = api_kind == path_kind and entity_id == path_id
+
+ # Emit the user-facing form ("dashboard"/"chart"/"dataset") on the
+ # wire; the internal class-name (api_kind) is kept above for the
+ # remaining decoration steps that key off model_cls.__name__.
+ record["entity_kind"] = USER_FACING_KIND.get(api_kind, api_kind)
+ record["entity_uuid"] = str(entity_uuid) if entity_uuid else None
+ record["entity_deleted"] = tombstone["deleted"]
+ record["entity_deletion_state"] = tombstone["deletion_state"]
+ record["source"] = "self" if is_self else "related"
+ record["version_uuid"] = (
+ str(derive_version_uuid(entity_uuid, record["transaction_id"]))
+ if entity_uuid
+ else None
+ )
+ record["changed_by"] = _changed_by_dict(record)
+
+ if is_self:
+ # Self records are left summary-less (the panel renders
+ # them from kind/path/values) — EXCEPT synthetic ``__meta__``
+ # headlines, whose entire payload IS the summary and whose
+ # primary surface is the entity's own stream ("restored to
+ # version N" must render on include=self).
+ record["summary"] = (
+ _build_summary(api_kind, record)
+ if record.get("kind") == "__meta__"
+ else ""
+ )
+ record["impact"] = None
+ else:
+ record["summary"] = _build_summary(api_kind, record)
+ record["impact"] = impact_for_record(record, path_kind,
impact_counts)
+ if record["entity_deleted"]:
+ # Security: a tombstoned related entity has no live row, so
+ # the visibility filter cannot access-gate it (there is
+ # nothing to apply the FAB access filter to). Deletion must
+ # not WIDEN what a requester entitled only to the path entity
+ # can see, so redact everything that would identify the
+ # deleted related entity or its editors: its name, the
+ # synthesized headline, the editor identity, and the raw diff
+ # CONTENT (filter values, column names, SQL/adhoc
+ # expressions). The record still appears — kind, operation,
+ # and timestamps remain, rendered as a generic
+ # "(deleted) <kind>" marker — so the stream stays honest
+ # about WHEN something changed without disclosing WHAT, WHO,
+ # or WHICH entity. Self-path tombstones are untouched: the
+ # endpoint already gated them via ``raise_for_access`` on the
+ # path entity.
+ label = API_KIND_LABEL.get(api_kind, api_kind)
+ record["entity_name"] = ""
+ record["summary"] = f"(deleted) {label}"
+ record["changed_by"] = None
+ record["from_value"] = None
+ record["to_value"] = None
+ record["path"] = None
+
+ # Strip the internal-only columns the API contract doesn't expose.
+ for key in (
+ "entity_id",
+ "sequence",
+ "user_id",
+ "changed_by_id",
+ "first_name",
+ "last_name",
+ ):
+ record.pop(key, None)
+
+
+def _lookup_entity_uuids(
+ distinct: set[tuple[str, int]],
+ tombstones: dict[tuple[str, int], dict[str, Any]],
+) -> dict[tuple[str, int], UUID | None]:
+ """Batch-fetch live ``uuid`` per ``(api_kind, entity_id)``. Tombstoned
+ entities are skipped (their ``entity_uuid`` is null).
+ """
+ result: dict[tuple[str, int], UUID | None] = {}
+ by_kind: dict[str, list[int]] = {}
+ for api_kind, entity_id in distinct:
+ if tombstones.get((api_kind, entity_id), {}).get("deleted"):
+ continue
+ by_kind.setdefault(api_kind, []).append(entity_id)
+
+ # ``no_autoflush`` mirrors the defensive posture of the baseline +
+ # change-record listeners: this helper reads from live tables to
+ # resolve uuids, and a future caller that resolves an entity before
+ # the parent flush would otherwise trigger autoflush mid-read.
+ # Today's call sites run from request-path code with no pending
+ # session state, so the cost of the guard is zero.
+ with db.session.no_autoflush:
+ for api_kind, entity_ids in by_kind.items():
+ if api_kind not in NAME_COLUMN:
+ continue
+ model_cls = load_live_model(NAME_COLUMN[api_kind][0])
+ live_tbl = model_cls.__table__
+ # Chunk the IN-clause to stay under SQLite's bind-variable floor
+ # (a wide dashboard can have more related entities than the floor).
+ for chunk in chunked_ids(entity_ids):
+ rows = (
+ db.session.connection()
+ .execute(
+ sa.select(live_tbl.c.id, live_tbl.c.uuid).where(
+ live_tbl.c.id.in_(chunk)
+ )
+ )
+ .all()
+ )
+ for row in rows:
+ result[(api_kind, row[0])] = row[1]
Review Comment:
**Suggestion:** The UUID decoration is resolved only from the current live
row by integer `id`, then reused for every historical record of that
`entity_id`. If the database reuses IDs after hard delete (already called out
elsewhere in this module family), older records from the predecessor entity
will be decorated with the new entity’s UUID and a wrong `version_uuid`.
Resolve UUID per record transaction window (or join through version/shadow rows
using `transaction_id`) instead of a single live-row lookup by `id`. [stale
reference]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
❌ Activity records show wrong entity_uuid for reused IDs.
⚠️ Derived version_uuid cannot resolve predecessors' historic snapshots.
⚠️ Cross-entity activity stream misidentifies related historical changes.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Run Superset with this PR on a database engine that reuses integer
primary keys after
hard delete (explicitly mentioned in the ID-reuse comment on
`first_tracked_tx` in
`superset/versioning/activity/queries.py:90-102`, which notes SQLite/MySQL
reuse
`max(id)+1` and warns about inheriting a hard-deleted predecessor's history
when matching
only on `id`).
2. Create an entity (e.g., a dataset or chart) and perform edits so
Continuum tracks
version rows and `version_changes` records for its `entity_id`; then
hard-delete that
entity and create a new entity of the same kind that reuses the same integer
`id` (so
historical `version_changes.entity_id` rows now refer to a different live
UUID).
3. Call an activity endpoint, for example `GET
/api/v1/dashboard/<uuid>/activity/`
implemented by `DashboardRestApi.activity` in
`superset/dashboards/api.py:2552-2581`,
which delegates to `activity_endpoint`
(`superset/dashboards/api.py:2618-2623`).
`activity_endpoint` (`superset/versioning/activity/orchestrator.py:321-35`)
resolves the
path entity via `resolve_endpoint_path_entity`
(`superset/versioning/api_helpers.py:7-59`), parses query params, and calls
`get_activity`
(`superset/versioning/activity/orchestrator.py:211-52`).
4. Inside `get_activity`, the helper builds `(api_kind, entity_id, windows)`
tuples using
`resolve_scope` (`superset/versioning/activity/scope.py:47-74`) and fetches
matching
change rows with `fetch_change_records`
(`superset/versioning/activity/queries.py:253-40`), which matches
`version_changes` rows
solely on `(entity_kind, entity_id)` plus transaction window
(queries.py:10-13). When
decoration runs, `apply_record_decoration`
(`superset/versioning/activity/render.py:80-139`) gathers a distinct set of
`(api_kind,
entity_id)` and passes it to `_lookup_entity_uuids` (`render.py:191-231).
`_lookup_entity_uuids` performs a live-table lookup by integer `id` only
(`render.py:219-230`), returning the current entity's UUID and applying it
to **all**
historical records for that `entity_id`. Older records that belong to the
hard-deleted
predecessor entity are therefore decorated with the successor entity's
`entity_uuid`, and
`derive_version_uuid` (`superset/versioning/queries.py:56-65`) computes a
`version_uuid`
for them using the wrong `entity_uuid`, so those activity entries carry an
incorrect
entity identity and an invalid version UUID.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=5cd7d86842944d1cbe89404f8ec5b3e1&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
[](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=5cd7d86842944d1cbe89404f8ec5b3e1&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/versioning/activity/render.py
**Line:** 219:230
**Comment:**
*Stale Reference: The UUID decoration is resolved only from the current
live row by integer `id`, then reused for every historical record of that
`entity_id`. If the database reuses IDs after hard delete (already called out
elsewhere in this module family), older records from the predecessor entity
will be decorated with the new entity’s UUID and a wrong `version_uuid`.
Resolve UUID per record transaction window (or join through version/shadow rows
using `transaction_id`) instead of a single live-row lookup by `id`.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask
user if the user wants to fix the rest of the comments as well. if said yes,
then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=29eba36f07b7597ea56dfb6b840e1bde6c65eb9daba9fb7df2121027464cb4d3&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=29eba36f07b7597ea56dfb6b840e1bde6c65eb9daba9fb7df2121027464cb4d3&reaction=dislike'>👎</a>
##########
superset/versioning/activity/scope.py:
##########
@@ -0,0 +1,118 @@
+# 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.
+"""Scope resolution — turn a path entity into the related-entity walk.
+
+Composes :mod:`~superset.versioning.activity.queries` (Phase A
+relationship walks) and :mod:`~superset.versioning.activity.windows`
+(pure interval arithmetic) into the
+``list[EntityWindows]`` scope that
+:func:`~superset.versioning.activity.queries.fetch_change_records`
+consumes.
+
+The functions here read the DB (via the Phase A helpers in
+:mod:`~superset.versioning.activity.queries`); the pure window-
+arithmetic functions previously colocated here now live in
+:mod:`~superset.versioning.activity.windows` so the package no longer
+needs a lazy import to dodge a ``scope ↔ queries`` cycle.
+"""
+
+from __future__ import annotations
+
+from superset.versioning.activity.kinds import EntityWindows, Window
+from superset.versioning.activity.queries import (
+ batch_datasets_used_by_charts,
+ charts_attached_to_dashboard,
+ datasets_used_by_chart,
+)
+from superset.versioning.activity.windows import (
+ intersect_windows,
+ merge_entity_windows,
+)
+
+
+def resolve_scope(
+ path_kind: str,
+ path_id: int,
+ include: str,
+ self_start_tx: int | None = None,
+) -> list[EntityWindows]:
+ """Build the ``[(api_kind, entity_id, [windows])]`` list that
+ :func:`~superset.versioning.activity.queries.fetch_change_records`
+ consumes, branching by *path_kind* and *include* mode.
+
+ *self_start_tx* lower-bounds the self window at the path entity's first
+ tracked transaction (see
+ :func:`~superset.versioning.activity.queries.first_tracked_tx`). This
+ scopes the self stream to the current entity's own history: matching
+ self records on the bare integer id would otherwise inherit a
+ hard-deleted predecessor's change records under id reuse
+ (SQLite/MySQL reuse ``max(id)+1``). ``None`` means the entity has no
+ tracked history yet, so the self stream contributes nothing.
+ """
+ want_self = include in ("all", "self")
+ want_related = include in ("all", "related")
+
+ scope: list[EntityWindows] = []
+ if want_self and self_start_tx is not None:
+ scope.append((path_kind, path_id, [Window(self_start_tx, None)]))
+ if want_related:
+ scope.extend(_resolve_related_scope(path_kind, path_id))
+ return scope
+
+
+def _resolve_related_scope(path_kind: str, path_id: int) ->
list[EntityWindows]:
+ """Walk the dependency edges from the path entity to its related
+ entities. Datasets have no transitive layer in V2."""
+ if path_kind == "Dashboard":
+ return _resolve_dashboard_scope(path_id)
+ if path_kind == "Slice":
+ return _resolve_chart_scope(path_id)
+ return []
+
+
+def _resolve_dashboard_scope(dashboard_id: int) -> list[EntityWindows]:
+ """Charts on the dashboard during their attachment window, plus
+ datasets each chart pointed at during the intersection of (chart-
+ attachment, chart-on-dataset)."""
+ scope: list[EntityWindows] = []
+ chart_windows: dict[int, list[Window]] = {}
+ for slice_id, window in charts_attached_to_dashboard(dashboard_id):
+ chart_windows.setdefault(slice_id, []).append(window)
+
+ # One query for the dataset-history of every chart on the dashboard,
+ # not one query per chart. The per-slice form was O(n_charts) round-
+ # trips which dominated p95 on rich dashboards.
+ dataset_windows_by_slice =
batch_datasets_used_by_charts(set(chart_windows))
+
+ for slice_id, attachment_windows in chart_windows.items():
+ scope.append(("Slice", slice_id, list(attachment_windows)))
+ dataset_windows = dataset_windows_by_slice.get(slice_id, [])
+ for attachment in attachment_windows:
+ for dataset_id, chart_dataset_window in dataset_windows:
+ if (
+ intersect := intersect_windows(attachment,
chart_dataset_window)
+ ) is not None:
+ scope.append(("SqlaTable", dataset_id, [intersect]))
Review Comment:
**Suggestion:** Related-entity scope is built from relationship/history rows
keyed only by integer IDs, without any UUID disambiguation like the self-scope
`self_start_tx` guard. Under ID reuse, this can pull predecessor chart/dataset
history into a different current entity’s activity stream. Add UUID-aware
scoping (or transaction lower bounds tied to the current related entity
identity) for related entities before emitting windows. [stale reference]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
❌ Dashboard activity stream includes predecessor dashboard relationships.
⚠️ Chart activity stream can inherit unrelated dataset history.
⚠️ Activity view misleads about changes for current entity.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. On a deployment using a database where integer IDs can be reused after
hard delete
(captured in the ID-reuse warning in `first_tracked_tx` at
`superset/versioning/activity/queries.py:90-102`), create dashboard A with
id `N`, attach
charts and datasets so relationship history is recorded in Continuum’s
shadow tables, then
hard-delete dashboard A and later create dashboard B that reuses integer id
`N`.
2. Invoke the dashboard activity endpoint `GET
/api/v1/dashboard/<uuid>/activity/`, whose
handler `DashboardRestApi.activity` in
`superset/dashboards/api.py:2552-2581` delegates to
the shared `activity_endpoint` helper
(`superset/dashboards/api.py:2618-2623`).
`activity_endpoint` (`superset/versioning/activity/orchestrator.py:321-35`)
resolves the
path entity via `resolve_endpoint_path_entity`, parses query params, and
calls
`get_activity` (`orchestrator.py:211-52`).
3. Within `get_activity`, Superset lowers the self window using
`first_tracked_tx(model_cls, path_id, path_entity.uuid)` (queries.py:90-102)
so the
self-stream for dashboard B does not inherit dashboard A’s own history, then
calls
`resolve_scope(path_kind, path_id, include, self_start_tx)`
(`superset/versioning/activity/scope.py:47-74). However,
`_resolve_dashboard_scope`
(`scope.py:87-110`) builds related-entity windows purely from integer IDs:
it calls
`charts_attached_to_dashboard(dashboard_id)` to read dashboard–chart
attachment windows
(`superset/versioning/activity/queries.py:117-168`) and
`batch_datasets_used_by_charts(set(chart_windows))` for chart–dataset windows
(`queries.py:188-247`), never applying `self_start_tx` or any UUID
discrimination to clip
these windows to dashboard B’s lifetime.
4. Because the shadow tables `dashboard_slices_version` and `slices_version`
are keyed
only by `dashboard_id` and `slice_id`, the windows returned for id `N` cover
attachment
history for both dashboards A and B when IDs are reused.
`merge_entity_windows`
(`superset/versioning/activity/windows.py:59-80`) merges all windows per
`(api_kind,
entity_id)` tuple, and `fetch_change_records` (`queries.py:253-40`) matches
`version_changes` rows whenever `(entity_kind, entity_id, transaction_id)`
fall inside any
of those windows. The result is that dashboard B’s `/activity/` stream
includes related
chart/dataset change records that actually belong to dashboard A’s historical
relationships. A similar pattern applies to chart activity:
`_resolve_chart_scope(slice_id)` (`scope.py:113-118) uses
`datasets_used_by_chart(slice_id)` (queries.py:171-185) keyed only on
integer `slice_id`,
so a chart B reusing an ID can inherit predecessor chart A’s dataset history
into its
`/activity/` stream.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=b0d45729e90c460fb2c3c090d67922b2&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
[](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=b0d45729e90c460fb2c3c090d67922b2&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/versioning/activity/scope.py
**Line:** 93:109
**Comment:**
*Stale Reference: Related-entity scope is built from
relationship/history rows keyed only by integer IDs, without any UUID
disambiguation like the self-scope `self_start_tx` guard. Under ID reuse, this
can pull predecessor chart/dataset history into a different current entity’s
activity stream. Add UUID-aware scoping (or transaction lower bounds tied to
the current related entity identity) for related entities before emitting
windows.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask
user if the user wants to fix the rest of the comments as well. if said yes,
then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=22e7c0a544debfa6c3dfa0179fd405db6dde05bbc6994acdd077c7d07cd654df&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41076&comment_hash=22e7c0a544debfa6c3dfa0179fd405db6dde05bbc6994acdd077c7d07cd654df&reaction=dislike'>👎</a>
--
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]